Filesystems · Filesystem Fundamentals

Path Resolution Component by Component

The walk behind every open: how a pathname is split, when symlinks are expanded, how mount points and dot-dot interrupt the tree, and where the interface allows a race.

open("/var/log/app/current.log") takes one syscall and one pathname, and the work behind it is a sequence of independent lookups, one per component, each of which can fail for its own reason. A failure reported as ENOENT may come from the last component or from the third, and the difference matters when diagnosing a mount that has not happened yet. Treating resolution as a single operation hides the structure that explains its costs and its races.

Splitting is a scan for a separator

The kernel copies the pathname into kernel memory, bounded by PATH_MAX of 4096 bytes including the terminating NUL, and then splits on /. Three rules apply during the split, and each exists to keep the walk’s state simple.

A pathname beginning with / starts at the process root, which is normally the root of the mount namespace. Every other pathname starts at the descriptor passed to the call: the current working directory for the plain open, or the directory named by a dirfd for openat. The distinction is the entire reason openat exists; it lets a program hold a directory open and resolve names relative to it without depending on the process’s working directory, which another thread may change.

Runs of slashes collapse. // is treated as / on Linux, so a path built by concatenating a directory ending in / with a name beginning in / resolves rather than failing. POSIX leaves the meaning of exactly two leading slashes implementation-defined for historical network-path reasons, and Linux resolves it as a single root.

Empty components elsewhere are skipped, so a//b is the same as a/b. The exception is a trailing slash, which is not skipped: it imposes a requirement that the resolved object be a directory. open("/etc/passwd/") fails with ENOTDIR on Linux rather than opening the file, and the check is applied after resolution rather than during the split.

An empty pathname is not a path to the current directory. It fails with ENOENT, and the standard requires the failure.

Each component is a lookup in a directory

Resolution of one component is a lookup in the current directory’s entry map, the structure described in Directory Entries as a Name-to-Inode Map. The kernel first consults the dentry cache, a per-directory hash table of recently resolved names holding both positive entries and negative ones, so a repeated lookup of a name that does not exist costs no disk read. A hit requires no I/O at all; a miss falls through to the filesystem’s lookup operation, which reads the directory’s data blocks and returns the inode.

The result of a successful lookup is a dentry, and the dentry carries a reference to the mount it belongs to. Resolution proceeds on the pair, not on the dentry alone, because the same directory can appear at several places in the tree through bind mounts, and the parent recorded in the directory’s .. entry describes only the on-disk position.

Lookup fails with ENOENT if the name is absent, and with ENOTDIR if the current position is not a directory. The second error is worth distinguishing: a path like /etc/passwd/x fails at the component x because passwd is a regular file, and the message from the kernel names the whole path, not the failing component.

Permission is checked during the walk, not only at the end. Each intermediate directory must grant execute permission, called search, to the resolving process, or the walk fails with EACCES. Read permission is not required to traverse a directory, which is why a directory with mode --x can be walked through if the exact names are known but cannot be listed. The permission model itself is the subject of Permission Checks on Open and the Cost of Traversal.

When a resolved component is a symbolic link and the walk is allowed to follow it, the resolver does not continue with the next component. It substitutes the link’s target for the component just consumed and restarts the scan from the target’s beginning.

If the target is absolute, the restart is at the process root. If it is relative, the restart is at the directory containing the link, not at the directory of the original path’s start. A link /a/b/link holding ../c resolves to /a/c, because the parent of the containing directory is /a/b and .. applies there.

The link’s own content is limited. Linux caps a symlink target at PATH_MAX bytes minus one, and stores short targets inside the inode itself, in the space that would otherwise hold block pointers. Ext4’s threshold for this fast-symlink form is 60 bytes; a longer target causes the filesystem to allocate a block, so a symlink with a short target consumes no data block and one with a long target consumes a whole one.

Following is bounded. The resolver counts links followed and fails with ELOOP after 40, a limit Linux enforces per resolution rather than per link. The bound is what makes symlink cycles safe: two links pointing at each other resolve to ELOOP instead of looping forever, and the failure is cheap because the counter stops the walk long before any resource is exhausted.

Whether the final component is followed depends on the flags and on the syscall. lstat does not follow the last component, stat does. open with O_NOFOLLOW fails with ELOOP if the final component is a link, but follows links in intermediate components regardless. That asymmetry is a common source of misunderstanding: O_NOFOLLOW protects the last step only, and a path with a symlinked directory in the middle still leaves the tree the caller expected.

O_PATH is the exception that allows a descriptor to be opened on a symlink itself, without following it, for use as a dirfd or with fstat. It is the only way to hold a link as an object rather than as a redirection.

Mount points and the upward step

A component that resolves to a directory which is a mount point triggers a step to the root of the mounted filesystem. The resolver detects this by comparing the dentry and mount against the mount hash, and the walk continues at the mount’s root rather than at the directory that hosts it. From the perspective of the path, the mounted filesystem’s root is the directory’s content.

.. inverts the step. If the current position is the root of a mounted filesystem, .. moves to the directory the filesystem is mounted on, not to any parent within the mounted filesystem, because the mounted filesystem’s root has no parent of its own. This is why a process that enters a mounted filesystem and then walks upward leaves it, and why a mount of a subtree onto a directory inside itself is refused by the kernel: the resulting structure would make the upward walk non-terminating.

The root of the process is the other place .. stops. A walk at the process root that encounters .. stays there, which is the property that makes a chroot or a mount namespace a boundary for path resolution rather than merely a change of directory.

Mount points also affect the trailing-slash rule and the type check. A path ending in a slash that crosses a mount must land on a directory at the far side, and a mount whose root is a file, as can happen with a bind mount of a single file, cannot be reached by a path ending in a slash.

Holding a directory instead of a path

The *at family exists because a path is resolved afresh on every call and the directory it passes through may change between calls. openat(dirfd, "name", ...) resolves name relative to the directory the descriptor refers to, and the descriptor pins that directory even if it is renamed or unlinked. A program that opens a directory once and then resolves many names through it cannot be redirected by replacing a component higher in the path, because no higher component is consulted.

This is the standard defence against a class of attack in which a directory in the path is replaced with a symlink between the check and the use. The descriptor names the directory object, not the path to it, so replacing the path changes nothing about what the descriptor resolves against.

openat2 extends the idea with a flag word that constrains the walk itself. RESOLVE_BENEATH refuses any resolution that would leave the directory the descriptor names, RESOLVE_NO_SYMLINKS refuses any symlink at all, and RESOLVE_IN_ROOT treats the descriptor’s directory as the root so that absolute paths and .. cannot escape it. These are kernel-enforced restrictions on the resolver rather than post-hoc checks on the result, which is the only form that closes the race.

struct open_how how = {
    .flags = O_RDONLY | O_CLOEXEC,
    .resolve = RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
};
int fd = openat2(dirfd, "sub/file", &how, sizeof how);
/* Fails with EXDEV if the walk would leave dirfd, ELOOP on any symlink. */

The race the interface does not close

Plain path resolution is not atomic with respect to concurrent modification of the directories it walks. Between the lookup of a component and the lookup of the next, another process can rename a directory, replace it with a symlink, or mount something over it. The resolver holds references to each object as it goes, so it never operates on a freed object, but the objects it reaches are whatever the tree contained at the moment each step was taken.

The classic consequence is a check-then-use gap: a program calls stat on a path to verify that it is a regular file, then calls open on the same path, and the second call may resolve to a different object. The window is small and the fix is structural: open first, then fstat the descriptor and check the result. The descriptor names one object for its whole life, so the check applies to exactly the object that will be used.

A second consequence is that a resolution can fail midway with an error that has nothing to do with the name the caller asked for. A path into a directory that is being renamed returns ENOENT from a component that existed when the call began. Programs that treat ENOENT as “the file does not exist” are correct in the steady state and wrong during concurrent modification, which is why retry loops around path operations are common and why descriptor-relative resolution is preferred where the race matters.

The number of disk reads a resolution costs is not proportional to the path length alone. Each component is a lookup, and each miss is a directory read; a cold cache turns a deep path into a sequence of reads proportional to depth, while a warm dentry cache resolves the same path with no I/O. The cache is the reason a compiled program that opens the same configuration file on every request pays almost nothing for the path, and the reason a program that constructs a fresh unique name per call pays a lookup per call even though the directory is in cache.

Limits that surface as errors

PATH_MAX bounds the string, not the depth. A tree deeper than 4096 bytes of path can still be traversed by opening directories one at a time and using openat with each descriptor, because no single pathname ever exceeds the limit. Tools that recurse with full paths fail on such a tree with ENAMETOOLONG, and the failure is a property of their implementation rather than of the filesystem.

NAME_MAX of 255 bytes bounds each component, and it is enforced at creation as well as at lookup. A name longer than that fails with ENAMETOOLONG before any directory is read.

The remaining error that surprises callers is EACCES versus ENOENT. A component inside a directory the process cannot search produces EACCES on Linux, and the standard permits either error, since revealing which of the two conditions holds would leak the directory’s contents. Code that distinguishes “missing” from “forbidden” by error code is relying on an implementation choice, and the only portable way to tell them apart is to check the permission of the parent separately, which reintroduces the race the descriptor-relative calls were meant to remove.

References

  1. path_resolution(7) — Linux manual page — man7.org
  2. openat2(2) — Linux manual page — man7.org
  3. The Open Group Base Specifications Issue 7, 2018 edition — Pathname Resolution — The Open Group