Mount Point Traversal During Lookup

When the kernel walks a pathname like /home/user/file, the dentry it lands on at /home may be a mount point — some other filesystem has been grafted on top of it. Path resolution must not stop at that mounted-on dentry; it must cross the boundary and continue inside the mounted filesystem’s root. The mechanism is a one-line flag check followed by a hash-table lookup: a dentry that has something mounted on it carries DCACHE_MOUNTED in dentry->d_flags, and lookup_mnt() (per fs/namespace.c) consults the mount hash table to find the child mount whose root the walk should jump to. The walk tracks position as a struct path — a (vfsmount, dentry) pair, not a bare dentry — precisely so that crossing a mount can swap both halves atomically. This note dissects how handle_mounts()/__traverse_mounts() in fs/namei.c jump from the mounted-on dentry to the mounted root, how .. walks back out of a mount, and why this is the seam that stitches path lookup to the mount tree. (Pinned to Linux 6.12 LTS, source as of 2026-06-04.)

Mental Model — Position Is a (mnt, dentry) Pair, Not a Dentry

The single most important idea is that the kernel never tracks “where the walk currently is” as just a directory entry. It tracks a struct path, which is two pointers:

struct path {
    struct vfsmount *mnt;     /* which mounted filesystem instance */
    struct dentry   *dentry;  /* which name within it */
};

The reason is that the same struct dentry can legitimately appear in the tree in multiple mounts — bind mounts make a single dentry reachable through several vfsmounts — so a dentry alone is ambiguous about which mount you reached it through. The vfsmount half disambiguates. When the walk crosses a mount point, it replaces the dentry (the mounted-on directory becomes the mounted filesystem’s root dentry) and the mnt (the old vfsmount becomes the child vfsmount). Crossing back out with .. does the reverse. Everything in this note is an operation on that pair.

flowchart TB
  subgraph rootmnt["vfsmount A (root fs, /dev/sda1)"]
    slash["dentry /"]
    mnt_d["dentry /mnt<br/>DCACHE_MOUNTED set"]
    slash --> mnt_d
  end
  subgraph childmnt["vfsmount B (mounted at /mnt, /dev/sdb1)"]
    broot["mnt_root: dentry / of B"]
    sub["dentry /data"]
    broot --> sub
  end
  mnt_d -. "lookup_mnt() finds child;<br/>path.mnt A to B,<br/>path.dentry /mnt to mnt_root" .-> broot

Crossing a mount point during lookup. What it shows: the walk reaches /mnt in the root filesystem (vfsmount A), notices DCACHE_MOUNTED is set, and lookup_mnt() returns vfsmount B mounted there; the walk’s struct path is then rewritten so path.mnt = B and path.dentry = B->mnt_root, after which the next component (data) is looked up inside B. The insight to take: the mounted-on dentry (/mnt in A) and the mount’s root dentry (/ of B) are two distinct dentries in two distinct vfsmounts — traversal swaps the whole pair, which is why a bare dentry could never represent the walk’s position unambiguously.

Mechanical Walk-through

Where mount crossing hooks into the component loop

Path resolution walks one component at a time inside link_path_walk(), which for each LAST_NORM component calls walk_component(), which (after looking the name up in the dcache via lookup_fast()/lookup_slow()) calls step_into(). step_into() is the universal “we have a found dentry, now advance to it” routine, and its very first action is handle_mounts() (fs/namei.c, step_into() calls int err = handle_mounts(nd, dentry, &path);). So mount crossing happens as part of stepping onto every component — before the kernel even asks whether the new component is itself a symlink. This ordering matters: you cross into the mount first, then decide what kind of object you landed on.

The kernel documentation states this plainly: “As the last step of walk_component(), step_into() will be called … It calls handle_mounts(), to check and handle mount points, in which a new struct path is created containing a counted reference to the new dentry and a reference to the new vfsmount which is only counted if it is different from the previous vfsmount.” (path-lookup.rst)

The fast path — most dentries are not mount points

handle_mounts() builds a struct path from the candidate dentry and the current nd->path.mnt, then dispatches. In the common ref-walk case it calls traverse_mounts(), which loads the dentry’s flags with an acquire barrier and short-circuits immediately if nothing special is set:

static inline int traverse_mounts(struct path *path, bool *jumped,
                                  int *count, unsigned lookup_flags)
{
    unsigned flags = smp_load_acquire(&path->dentry->d_flags);
 
    /* fastpath */
    if (likely(!(flags & DCACHE_MANAGED_DENTRY))) {
        *jumped = false;
        if (unlikely(d_flags_negative(flags)))
            return -ENOENT;
        return 0;
    }
    return __traverse_mounts(path, flags, jumped, count, lookup_flags);
}

DCACHE_MANAGED_DENTRY is a composite flag covering all three “managed dentry” behaviors — DCACHE_MOUNTED (something is mounted here), DCACHE_NEED_AUTOMOUNT (autofs trigger), and DCACHE_MANAGE_TRANSIT (filesystem wants a callback before transit). The overwhelming majority of dentries have none of these, so the hot path is a single bit test and return. Only a managed dentry drops into the out-of-line __traverse_mounts().

The slow path — __traverse_mounts() and the mount-jump loop

__traverse_mounts() loops because mount points can be stacked: you can mount filesystem B on /mnt, then mount C on /mnt again, in which case the visible mount is C-on-top-of-B-on-top-of-the-real-/mnt. Each iteration peels one layer. The mount-crossing core is:

while (flags & DCACHE_MANAGED_DENTRY) {
    if (flags & DCACHE_MANAGE_TRANSIT) {       /* autofs callback */
        ret = path->dentry->d_op->d_manage(path, false);
        flags = smp_load_acquire(&path->dentry->d_flags);
        if (ret < 0) break;
    }
    if (flags & DCACHE_MOUNTED) {              /* something's mounted on it.. */
        struct vfsmount *mounted = lookup_mnt(path);
        if (mounted) {                         /* ...in our namespace */
            dput(path->dentry);
            if (need_mntput) mntput(path->mnt);
            path->mnt = mounted;
            path->dentry = dget(mounted->mnt_root);
            flags = path->dentry->d_flags;
            need_mntput = true;
            continue;                          /* re-test the new root for further mounts */
        }
    }
    if (!(flags & DCACHE_NEED_AUTOMOUNT)) break;
    ret = follow_automount(path, count, lookup_flags);   /* uncovered automount point */
    flags = smp_load_acquire(&path->dentry->d_flags);
    if (ret < 0) break;
}

Read line by line: if DCACHE_MOUNTED is set, lookup_mnt(path) is asked for the child mount. If it returns non-NULL, the walk drops its reference to the mounted-on dentry (dput(path->dentry)), swaps path->mnt to the child mount, and takes a reference on the child’s root dentry (path->dentry = dget(mounted->mnt_root)). Then continue re-tests the new dentry’s flags — because the root of B might itself be a mount point for C. The loop only exits when it reaches a dentry that is not mounted on (and not an automount trigger). need_mntput tracks whether the walk now holds a counted reference on a vfsmount that differs from the one it started with, so it can be released correctly on the way out.

The crucial qualifier is the comment “…in our namespace.” DCACHE_MOUNTED is set on the dentry globally, but whether this process’s mount namespace actually has a mount there is a separate question. The kernel doc calls the flag “a hint, not a promise”: “As Linux supports multiple filesystem namespaces, it is possible that the dentry may not be mounted on in this namespace, just in some other. So this flag is seen as a hint.” (path-lookup.rst) lookup_mnt() is what resolves the hint against the caller’s actual namespace, and it can legitimately return NULL even when DCACHE_MOUNTED is set.

How lookup_mnt() finds the child — the mount hash table

lookup_mnt() lives in fs/namespace.c and is a thin, RCU-protected wrapper over __lookup_mnt():

struct vfsmount *lookup_mnt(const struct path *path)
{
    struct mount *child_mnt;
    struct vfsmount *m;
    unsigned seq;
 
    rcu_read_lock();
    do {
        seq = read_seqbegin(&mount_lock);
        child_mnt = __lookup_mnt(path->mnt, path->dentry);
        m = child_mnt ? &child_mnt->mnt : NULL;
    } while (!legitimize_mnt(m, seq));
    rcu_read_unlock();
    return m;
}

The key is the lookup key: (path->mnt, path->dentry). A mount is uniquely identified not by its mounted-on dentry alone but by the parent vfsmount plus the mounted-on dentry together — again the (mnt, dentry) pair, because the same dentry mounted in two different parent mounts (via bind mounts) is two different children. __lookup_mnt() hashes that pair into mount_hashtable and walks the chain:

struct mount *__lookup_mnt(struct vfsmount *mnt, struct dentry *dentry)
{
    struct hlist_head *head = m_hash(mnt, dentry);
    struct mount *p;
 
    hlist_for_each_entry_rcu(p, head, mnt_hash)
        if (&p->mnt_parent->mnt == mnt && p->mnt_mountpoint == dentry)
            return p;
    return NULL;
}

m_hash(mnt, dentry) combines the two pointers into a bucket index; the chain walk confirms an exact (mnt_parent == mnt && mnt_mountpoint == dentry) match. Note the function returns the first child mounted there — if multiple mounts are stacked at the same point, this returns the bottommost (chronologically first), and __traverse_mounts()’s loop climbs the rest. The lookup_mnt() documentation gives the canonical example: after three successive mounts on /mnt, repeated traversal “will return successively the root dentry and vfsmount of /dev/sda1, then /dev/sda2, then /dev/sda3, then NULL.”

The whole thing runs under the mount_lock seqlock (a DEFINE_SEQLOCK in fs/namespace.c) sampled by read_seqbegin(), and legitimize_mnt() takes a reference on the found mount and re-checks the sequence — if the mount table changed concurrently, the loop retries. This is the same mount_lock/m_seq that RCU-walk uses to validate every mount crossing lock-free (see below).

The RCU-walk variant — __follow_mount_rcu()

In RCU-walk mode, handle_mounts() first tries __follow_mount_rcu(), which does the same jump but without taking any references — it cannot dget/mntget because RCU-walk’s whole point is to avoid touching reference counts. It calls __lookup_mnt() directly (not the reference-taking lookup_mnt()), points path->mnt/path->dentry straight at the child, records nd->next_seq from the new dentry’s d_seq, sets ND_JUMPED, and — critically — re-checks mount_lock:

if (flags & DCACHE_MOUNTED) {
    struct mount *mounted = __lookup_mnt(path->mnt, dentry);
    if (mounted) {
        path->mnt = &mounted->mnt;
        dentry = path->dentry = mounted->mnt.mnt_root;
        nd->state |= ND_JUMPED;
        nd->next_seq = read_seqcount_begin(&dentry->d_seq);
        flags = dentry->d_flags;
        if (read_seqretry(&mount_lock, nd->m_seq))   /* mount table changed? bail */
            return false;
        continue;
    }
    ...
}

If mount_lock shows any change since the walk began (nd->m_seq was sampled in path_init()), __follow_mount_rcu() returns false, handle_mounts() calls try_to_unlazy_next() to drop out of RCU-walk into ref-walk, and the crossing is redone the slow, reference-counted way. The kernel doc summarizes the invariant: sampling mount_lock once and validating it “is used to validate all accesses to all vfsmounts, and all mount point crossings … If RCU-walk finds that mount_lock hasn’t changed then it can be sure that, had REF-walk taken counted references on each vfsmount, the results would have been the same.” (path-lookup.rst)

Walking out with ..follow_dotdot and choose_mountpoint

Going down into a mount is dentry == mnt_root becoming path.mnt = child. Going up with .. is the inverse, but it is subtler because .. at the root of a mount must climb back to the mounted-on dentry in the parent mount, not to the parent within the same filesystem. handle_dots() calls follow_dotdot() (or follow_dotdot_rcu()), whose first check is exactly this:

static struct dentry *follow_dotdot(struct nameidata *nd)
{
    if (path_equal(&nd->path, &nd->root))
        goto in_root;                          /* "/.." == "/" — can't escape root */
    if (unlikely(nd->path.dentry == nd->path.mnt->mnt_root)) {
        struct path path;
        if (!choose_mountpoint(real_mount(nd->path.mnt), &nd->root, &path))
            goto in_root;
        path_put(&nd->path);
        nd->path = path;                       /* jump to mounted-on dentry in parent mount */
        nd->inode = path.dentry->d_inode;
        if (unlikely(nd->flags & LOOKUP_NO_XDEV))
            return ERR_PTR(-EXDEV);
    }
    parent = dget_parent(nd->path.dentry);     /* ordinary parent within the filesystem */
    ...
}

Two cases. If the current dentry is not the mount root, .. is just dget_parent() — an ordinary step up within the same filesystem. But if the current dentry is mnt->mnt_root, the walk is sitting at the top of a mounted filesystem, and .. must cross the mount boundary upward. choose_mountpoint() climbs the mnt_parent/mnt_mountpoint chain to find the dentry in the parent mount that this filesystem is mounted on, and the nd->path is rewritten to that (parent_mnt, mountpoint_dentry) pair. This is the man-page guarantee: “One can walk out of a mounted filesystem: path/.. refers to the parent directory of path, outside of the filesystem hierarchy on dev.” (path_resolution(7))

choose_mountpoint_rcu() also enforces the root barrier: if the mountpoint it is about to climb to equals nd->root, it stops (break) — .. can never escape above the process’s root directory. That is why /.. is the same as /, and why a process inside a pivot_rooted or chrooted subtree cannot .. its way out.

Configuration and Observability

There is no syscall that “crosses a mount” explicitly — crossing is implicit in every path that traverses a mount point. But the behavior is controllable at lookup time, and observable from userspace.

Blocking mount crossing — openat2(2) and RESOLVE_NO_XDEV. The modern openat2(2) syscall (man page) accepts a resolve field whose RESOLVE_NO_XDEV flag refuses to cross any mount point during the walk:

struct open_how how = {
    .flags   = O_RDONLY,
    .resolve = RESOLVE_NO_XDEV,
};
int fd = syscall(SYS_openat2, dirfd, "subdir/file",
                 &how, sizeof(how));
/* fails with EXDEV if "subdir" is a mount point */

Internally RESOLVE_NO_XDEV becomes LOOKUP_NO_XDEV (include/linux/namei.h, #define LOOKUP_NO_XDEV 0x040000). In handle_mounts(), after a downward crossing jumped is true and the kernel converts that into -EXDEV; in follow_dotdot() an upward crossing under LOOKUP_NO_XDEV returns -EXDEV directly. The man page warns that this “also restricts bind mount traversal,” because a bind mount is still a distinct vfsmount even though it shares a superblock.

Seeing the mount tree. The (mnt, dentry) pairs are exposed in /proc/self/mountinfo, whose per-line fields include the mount ID, parent mount ID, the root of the mount within its filesystem, and the mount point — exactly the data __lookup_mnt() matches against:

36 35 98:0 /mnt1 /mnt rw,noatime - ext4 /dev/sdb1 rw
^  ^         ^     ^
id parent    root  mountpoint

The findmnt(8) tool renders the same tree; findmnt --target /mnt/data answers “which mount backs this path,” which is the userspace echo of the lookup_mnt() walk.

Failure Modes and Subtleties

  • DCACHE_MOUNTED is a hint, not a promise. Because the flag is global but mounts are per-namespace, code must never assume a mounted-on dentry is mounted in the current namespace. lookup_mnt() returning NULL with the flag set is normal — it means “mounted somewhere, but not here.” Treating the flag as authoritative is a classic namespace-confusion bug.

  • Stacked mounts and shadow mounts. __lookup_mnt() returns the first child; resolving the full stack is the loop’s job. Mount propagation can also create “shadow mounts” where two mounts share the same (parent, mountpoint) — the __lookup_mnt() kernel-doc comment explicitly notes “the child mount @c need not be unique.” Tooling that assumes one mount per mountpoint will miss these.

  • .. does not undo symlink jumps. Crossing a mount with .. follows the mount tree (mnt_parent/mnt_mountpoint), not the lexical path the user typed. If you reached a directory via a symlink that jumped across mounts, .. climbs the real mount tree, which can land you somewhere the textual path would not predict. This is the deliberate POSIX semantics: .. is a real directory entry, not string manipulation.

  • The root barrier. .. at the root mount (or at nd->root) stays put — /.. is /. Combined with pivot_root/chroot this is a security boundary, but it is only a textual/.. boundary; magic links and absolute symlinks that nd_jump_root() to nd->root respect it too, but a process with CAP_SYS_ADMIN can still move mounts. Path-walk’s root barrier is not a substitute for a proper namespace jail.

  • Automount latency. When the managed dentry is an autofs trigger (DCACHE_NEED_AUTOMOUNT), follow_automount() can block arbitrarily while a userspace daemon mounts the filesystem on demand. __traverse_mounts() runs this with only counted references (never locks) held, because the delay can be long. RCU-walk cannot do this at all — an automount trigger forces a drop to ref-walk.

How This Connects

Mount traversal is the join between two subsystems that are otherwise independent. Path lookup owns the component-by-component walk and the nameidata state machine; the mount tree owns the struct mount/vfsmount graph, the mount_hashtable, and mount_lock. The point where they meet is handle_mounts() calling lookup_mnt(). Understand that one call and the rest follows: a read() that opens a file inside a mounted filesystem necessarily crossed at least one mount boundary, and every such crossing was a (mnt, dentry) pair swap validated against mount_lock. The same machinery underlies bind mounts (which make one dentry reachable through several vfsmounts) and mount namespaces (which make lookup_mnt() return different answers per process).

See Also