Symlink Resolution and Loop Detection
A symbolic link is a file whose contents are a pathname; when path resolution lands on one mid-walk, it must substitute the link’s target for the link component and keep walking. Linux does this without editing the path string and without recursion — it pushes the remaining components of the current path onto a small symlink stack inside
nameidata, reads the link body via the filesystem’sinode_operations->get_link(or the inlinei_linkpointer), and resumes the walk at the target. Because a link can point at another link, this nests, and because a link can point at itself — directly or through a cycle — the kernel caps the total work: at mostMAXSYMLINKS(40) symlinks per single lookup, beyond which it returnsELOOP(“Too many levels of symbolic links”) (include/linux/namei.h,#define MAXSYMLINKS 40; path-lookup.rst). This note walks the actual code path infs/namei.c—step_into→pick_link→ the stack-driven loop inlink_path_walk— plus theO_NOFOLLOW/AT_SYMLINK_NOFOLLOWopt-outs and the special “magic links” under/proc/<pid>/fd/. (Pinned to Linux 6.12 LTS, source as of 2026-06-04.)
Mental Model — A Stack of Path Remnants, Not Recursion
Conceptually, following a -> b/c while walking a/d/e means: replace a with b/c, giving b/c/d/e, then walk that. The kernel does not literally splice strings. Instead it keeps the unfinished tail of each path on a stack: when it steps onto a and finds it is a symlink, it pushes the remnant d/e onto the stack and switches the active name to the link body b/c. When b/c finishes resolving, it pops d/e and continues. Nesting is just a deeper stack. This design — chosen in the Linux 4.2 rework that “eliminate[d] the use of recursion” (path_resolution(7)) — means the only resource cap needed is the stack depth and a total-link counter, not a C-call-recursion limit.
flowchart TB start["walk a/d/e"] hita["step onto 'a' = symlink to b/c"] push["push remnant 'd/e' onto<br/>nd->stack (nd->depth++);<br/>active name becomes 'b/c'"] walkbc["walk b/c to completion"] pop["pop 'd/e' (put_link, nd->depth--);<br/>resume walking d/e"] done["final component reached"] start --> hita --> push --> walkbc --> pop --> done cnt["each link bumps<br/>nd->total_link_count;<br/>>= 40 returns ELOOP"] hita -. checked at .-> cnt
The symlink stack in action. What it shows: stepping onto a symlink pushes the path’s unresolved tail onto nd->stack and redirects the walk to the link body; when the body finishes, the tail is popped and resumed. A per-lookup counter (nd->total_link_count) is incremented at every link and trips ELOOP at 40. The insight to take: symlink following is iterative, not recursive — the “depth” is data on a stack (nd->depth), and loop protection is a simple budget on the total number of links followed, which catches both true cycles and merely pathologically deep chains.
Mechanical Walk-through
Detecting a symlink — step_into
After walk_component() finds a dentry and handle_mounts() crosses any mount point, control reaches step_into(). It inspects the landed-on dentry:
static const char *step_into(struct nameidata *nd, int flags,
struct dentry *dentry)
{
struct path path;
int err = handle_mounts(nd, dentry, &path); /* cross mounts first */
...
inode = path.dentry->d_inode;
if (likely(!d_is_symlink(path.dentry)) ||
((flags & WALK_TRAILING) && !(nd->flags & LOOKUP_FOLLOW)) ||
(flags & WALK_NOFOLLOW)) {
/* not a symlink, or we should not follow it: just install the path */
nd->path = path; nd->inode = inode; nd->seq = nd->next_seq;
return NULL;
}
...
return pick_link(nd, &path, inode, flags); /* it IS a symlink we must follow */
}Three independent reasons not to follow: the object is not a symlink (!d_is_symlink), or it is the trailing component and the caller did not pass LOOKUP_FOLLOW (this is how O_NOFOLLOW/lstat get a handle on the link itself), or the caller explicitly set WALK_NOFOLLOW (used for .. and a few internal cases). Otherwise pick_link() is called to follow it. Note d_is_symlink() is a flag check on the dentry, not an inode-mode read — symlink-ness is cached in d_flags for speed.
Following a symlink — pick_link and the link body
pick_link() does the real work. First it reserves a stack slot, which is also where the loop limit is enforced:
static int reserve_stack(struct nameidata *nd, struct path *link)
{
if (unlikely(nd->total_link_count++ >= MAXSYMLINKS))
return -ELOOP;
if (likely(nd->depth != EMBEDDED_LEVELS))
return 0; /* embedded stack still has room */
...
if (likely(nd_alloc_stack(nd))) /* grow to a 40-slot heap stack */
return 0;
...
}Every symlink increments nd->total_link_count, and the first thing checked is whether it has reached MAXSYMLINKS (40) — if so, -ELOOP. This single counter is the entire loop-detection mechanism (more below). If under budget, pick_link() pushes the link onto the stack and reads its body:
last = nd->stack + nd->depth++; /* push */
last->link = *link;
last->seq = nd->next_seq;
...
if (unlikely(nd->flags & LOOKUP_NO_SYMLINKS) ||
unlikely(link->mnt->mnt_flags & MNT_NOSYMFOLLOW))
return ERR_PTR(-ELOOP); /* RESOLVE_NO_SYMLINKS / nosymfollow mount */
...
res = READ_ONCE(inode->i_link); /* fast path: inline symlink body */
if (!res) {
get = inode->i_op->get_link; /* slow path: ask the filesystem */
if (nd->flags & LOOKUP_RCU) {
res = get(NULL, inode, &last->done);
if (res == ERR_PTR(-ECHILD) && try_to_unlazy(nd))
res = get(link->dentry, inode, &last->done);
} else {
res = get(link->dentry, inode, &last->done);
}
...
}
if (*res == '/') { /* absolute link: restart at root */
error = nd_jump_root(nd);
while (unlikely(*++res == '/')) ; /* skip leading slashes */
}
if (*res)
return res; /* hand the body back to link_path_walk */
all_done:
put_link(nd); /* link body was empty / pure jump */
return NULL;Two ways to get the link body. The fast path reads inode->i_link — a pointer the filesystem sets for symlinks stored inline in the inode (e.g. ext4 “fast symlinks” whose target fits in the i_block array: ext4_inode_is_fast_symlink() treats a link of fewer than EXT4_N_BLOCKS * 4 = 60 bytes as inline, holding the target in the 15 i_block slots rather than a separate data block — verified in fs/ext4/inode.c). The kernel doc explains: “Short symlinks are often stored directly in the inode … the i_link pointer in the inode is set to point to wherever the symlink is stored and it can be accessed directly whenever needed.” (path-lookup.rst)
The slow path calls inode->i_op->get_link(), the per-filesystem method that returns the link body (typically from the page cache for longer symlinks). It can register a delayed_call (&last->done) so the kernel releases the page reference via put_link() once the remnant is consumed — that is why the stack slot stores a struct delayed_call done, not just the name. In RCU-walk, get_link(NULL, ...) is tried first; a filesystem that cannot serve the link lock-free returns -ECHILD, and the walk drops to ref-walk (try_to_unlazy) and retries with the dentry.
get_link superseded the older ->follow_link/->put_link pair: in 6.12, inode_operations exposes only get_link (verified in include/linux/fs.h, where the single declaration is const char *(*get_link)(struct dentry *, struct inode *, struct delayed_call *) — no follow_link/put_link remain). The delayed_call argument folds the old separate ->put_link cleanup into the same call.
Uncertain
Verify: the exact kernel version in which
->follow_link/->put_linkwere replaced by->get_linkwith thedelayed_call. Reason: not directly addressed by a primary source consulted here — the 4.2 rework (commit894bc8c) eliminated recursion, which is a distinct change from theget_linkinterface introduction (believed to be slightly later, ~4.5). To resolve: bisect theinode_operationsstruct history ininclude/linux/fs.hor read theget_linkintroduction commit. The 6.12 end-state (onlyget_linkexists) is verified; only the introduction version is unpinned. uncertain
If the body begins with /, it is absolute: nd_jump_root() resets the walk to the process’s root (nd->root, respecting any chroot/pivot_root) and the leading slashes are skipped. Otherwise the body is relative and resolution simply continues from the link’s parent directory. Either way the non-empty body string is returned to link_path_walk().
Resuming the walk — the loop in link_path_walk
link_path_walk() is the driver. When walk_component() (via step_into/pick_link) returns a non-NULL string, that string is a symlink body to be walked next, and the current unfinished name is pushed:
link = walk_component(nd, WALK_MORE);
...
if (unlikely(link)) {
if (IS_ERR(link))
return PTR_ERR(link);
/* a symlink to follow */
nd->stack[depth++].name = name; /* save remnant of current path */
name = link; /* switch active name to link body */
continue;
}So nd->stack[].name holds the leftover of the outer path, and name becomes the link body. When the body’s final component is reached, the loop pops the saved remnant and continues:
OK:
if (!depth) { ... return 0; } /* whole path done */
/* last component of nested symlink */
name = nd->stack[--depth].name; /* pop the outer remnant */
link = walk_component(nd, 0);This is the iteration that replaces recursion: depth rises as links nest and falls as each link’s tail is consumed, all without any C recursion. The nd->stack array is the embedded internal[EMBEDDED_LEVELS] (EMBEDDED_LEVELS = 2) until a third nested link forces nd_alloc_stack() to allocate a 40-slot heap stack. The doc notes this two-tier design: “a small stack that can be used to store the remaining part of up to two symlinks. In many cases this will be sufficient. If it isn’t, a separate stack is allocated with room for 40 symlinks.” (path-lookup.rst)
Loop detection — why a budget, not cycle tracking
Linux does not detect symlink loops by remembering which inodes it has visited. It simply counts. nd->total_link_count is incremented at every link and capped at 40; a true cycle (a -> b, b -> a) and a merely-too-deep chain both blow the budget and yield ELOOP. The path-lookup doc quotes Linus on the rationale: “it’s a latency and DoS issue too. We need to react well to true loops, but also to ‘very deep’ non-loops. It’s not about memory use, it’s about users triggering unreasonable CPU resources.” (path-lookup.rst) A budget catches both cases with one cheap counter.
The 40 limit has history worth knowing: before Linux 2.6.18 the recursion-depth limit was 5; 2.6.18 raised it to 8 (_POSIX_SYMLOOP_MAX is 8); and the 4.2 stack-based rework collapsed the separate recursion-depth and total-count limits into the single MAXSYMLINKS == 40 total (path_resolution(7)). So in 6.12 there is exactly one limit: 40 links total per lookup, regardless of nesting shape.
Mount-point automounts share the same budget: follow_automount() checks (*count)++ >= MAXSYMLINKS against the same nd->total_link_count, so a pathological chain of automount triggers also trips ELOOP rather than running forever.
Configuration and the Opt-Outs
O_NOFOLLOW and AT_SYMLINK_NOFOLLOW
These do not set a “nofollow” lookup flag; they withhold LOOKUP_FOLLOW for the trailing component. Recall step_into’s test: (flags & WALK_TRAILING) && !(nd->flags & LOOKUP_FOLLOW) means “don’t follow the final symlink.” So:
/* open(2) on the link itself, not its target */
int fd = open("/path/to/link", O_PATH | O_NOFOLLOW);
/* if the final component is a symlink, open() with O_NOFOLLOW (without
* O_PATH) fails with ELOOP; with O_PATH you get an fd to the link itself */The symlink(7) man page is explicit that O_NOFOLLOW affects only the last component: “If the trailing component (i.e., basename) of pathname is a symbolic link, then the open fails, with the error ELOOP.” Intermediate symlinks in the path are still followed. AT_SYMLINK_NOFOLLOW is the equivalent bit for the *at() family (fstatat, linkat, fchownat, etc.), and lstat(2) is effectively stat with this behavior baked in — it operates on the link, while stat(2) follows it. (symlink(7))
RESOLVE_NO_SYMLINKS and nosymfollow mounts
To forbid all symlink following (not just the trailing one), openat2(2) offers RESOLVE_NO_SYMLINKS, which maps to LOOKUP_NO_SYMLINKS and makes pick_link() return -ELOOP for any symlink encountered. Independently, a mount may carry the nosymfollow flag (MNT_NOSYMFOLLOW), which pick_link() also checks — symlinks on that mount are never followed. Both appear in the same guard:
if (unlikely(nd->flags & LOOKUP_NO_SYMLINKS) ||
unlikely(link->mnt->mnt_flags & MNT_NOSYMFOLLOW))
return ERR_PTR(-ELOOP);protected_symlinks hardening
The fs.protected_symlinks sysctl gates following symlinks in sticky, world-writable directories like /tmp, to defeat a classic time-of-check/time-of-use attack where an attacker plants a symlink. (Many distributions enable it by default, but the default is distribution policy, not a kernel default — the kernel initializes sysctl_protected_symlinks to 0; check sysctl fs.protected_symlinks on a given system.) may_follow_link() (invoked from pick_link for trailing links) allows the follow only if the follower owns the link, or the directory is not sticky+world-writable, or the directory owner matches the link owner — otherwise -EACCES and an audit log (AUDIT_ANOM_LINK, “follow_link”). This is a security policy layered on top of resolution, not part of resolution proper.
Magic Links — /proc/<pid>/fd/ and nd_jump_link
Some pseudo-filesystem “symlinks” are not text pathnames at all. /proc/<pid>/fd/N, /proc/<pid>/exe, /proc/<pid>/cwd, and /proc/<pid>/root are magic links: following them does not re-resolve a string — it teleports the walk directly to a kernel-held struct path. The mechanism is nd_jump_link(), called from proc_pid_get_link() (fs/proc/base.c):
static const char *proc_pid_get_link(struct dentry *dentry,
struct inode *inode,
struct delayed_call *done)
{
...
if (!proc_fd_access_allowed(inode)) /* ptrace-grade permission check */
goto out;
error = PROC_I(inode)->op.proc_get_link(dentry, &path); /* fetch the real path */
...
error = nd_jump_link(&path); /* jump the walk straight to it */
...
}nd_jump_link() replaces nd->path with the supplied (mnt, dentry) pair outright:
int nd_jump_link(const struct path *path)
{
struct nameidata *nd = current->nameidata;
if (unlikely(nd->flags & LOOKUP_NO_MAGICLINKS)) /* RESOLVE_NO_MAGICLINKS */
goto err; /* returns -ELOOP */
if (unlikely(nd->flags & LOOKUP_NO_XDEV)) {
if (nd->path.mnt != path->mnt) goto err; /* -EXDEV */
}
if (unlikely(nd->flags & LOOKUP_IS_SCOPED)) /* not safe for scoped lookups */
goto err;
path_put(&nd->path);
nd->path = *path;
nd->inode = nd->path.dentry->d_inode;
nd->state |= ND_JUMPED;
return 0;
}Because a magic link is a direct file-handle reference, it can reach files that have no name — an unlinked-but-still-open file is reachable via /proc/<pid>/fd/N even though no path leads to it. It also bypasses textual containment: symlink(7) warns “Because they can bypass ordinary mount_namespaces(7)-based restrictions, magic links have been used as attack vectors in various exploits.” Hence the guards: RESOLVE_NO_MAGICLINKS (→ LOOKUP_NO_MAGICLINKS) blocks the jump entirely, and scoped lookups (RESOLVE_BENEATH/RESOLVE_IN_ROOT) refuse magic links because a jump can land arbitrarily outside the scope. Magic links also carry permission semantics ordinary symlinks lack: proc_fd_access_allowed() enforces a ptrace-style check before the jump, and magic links can have a non-0777 mode (ordinary symlinks are always 0777). (symlink(7), proc_pid_fd(5))
Failure Modes and Subtleties
-
ELOOPis not only loops. A 41-deep acyclic chain of symlinks returnsELOOPtoo. The error string “Too many levels of symbolic links” is accurate; “loop” is a misnomer for the deep-chain case. Diagnose withnamei -l /pathorreadlink -f, which trace the chain. -
O_NOFOLLOWprotects only the last component. A path/tmp/evil/targetwhere/tmp/evilis an attacker symlink is still traversed underO_NOFOLLOW— onlytargetitself is protected. For full protection useopenat2(2)withRESOLVE_NO_SYMLINKSorRESOLVE_BENEATH/RESOLVE_IN_ROOT. -
Trailing slash forces a follow.
lookup_last()setsLOOKUP_FOLLOW | LOOKUP_DIRECTORYwhen the trailing component has a trailing/, soopen("link/", O_NOFOLLOW)behaves differently fromopen("link", O_NOFOLLOW)— the slash demands the target be a directory, which overrides the nofollow intent on the trailing component. -
Magic links and unlinked files. Tooling that walks
/proc/*/fd/to “find what a process has open” is using magic-link jumps; the targets may be deleted (readlinkshows them suffixed ” (deleted)”). This is by design and is howlsof-style introspection works. -
RCU-walk fallback on symlinks. A symlink whose body lives in the page cache may force a drop from RCU-walk to ref-walk if
get_linkcan’t serve it lock-free (-ECHILD). Atime updates on the link (touch_atimeinpick_link) can also forcetry_to_unlazy. So symlink-heavy paths are less likely to stay on the fast lockless walk — a real, if minor, performance consideration. -
The link body is bounded by
PATH_MAX. A symlink target longer thanPATH_MAX(4096) cannot be stored, and an intermediate path produced during resolution that exceedsPATH_MAXyieldsENAMETOOLONG, a separate limit fromELOOP.
See Also
- Path Resolution and Name Lookup — the
link_path_walkdriver and thenameidatastate this note’s stack lives in - Mount Point Traversal During Lookup — the other thing
step_intotriggers; magic-link jumps and..interact with mount crossing - RCU-Walk and Ref-Walk — why a page-cached symlink body can force a fallback to ref-walk
- The Page Cache and address_space — where longer symlink bodies are stored and served by
get_link - procfs and the proc Filesystem — the source of magic links (
/proc/<pid>/fd/,exe,cwd,root) - Open Flags and Access Modes —
O_NOFOLLOW,O_PATH, and how the*at()family carriesAT_SYMLINK_NOFOLLOW - pivot_root and Changing the Root — the root that absolute links (
nd_jump_root) restart from - VFS Inode Object —
i_op->get_linkand the inlinei_linkpointer - MOC: Linux Filesystems and VFS MOC