[RFC PATCH 2/3] fs: support tasks with a null root or cwd

From: John Ericson

Date: Mon Jun 29 2026 - 03:06:22 EST


From: John Ericson <mail@xxxxxxxxxxxxxx>

A task's root directory (`fs->root`) and current working directory
(`fs->pwd`) are normally established by `chroot(2)`/`pivot_root(2)` and
`chdir(2)`/`fchdir(2)` (or inherited across `fork(2)`). Allow either to
instead be the null path, as documented in `struct fs_struct`. The two
are independent: a task may opt out of one, the other, or both.

A task with no root cannot use absolute pathnames, and its `..` is no
longer bounded by a process root: it climbs to the root of the mount the
walk is in (the security implications are discussed in `struct
fs_struct`). A task with no cwd cannot use `AT_FDCWD`-relative
pathnames. Either way it can still name files through the `*at(2)`
descriptors it holds.

Teach the readers of these fields to cope instead of dereferencing the
NULL dentry, each checking the field it uses:

- namei: `set_root()` now tolerates a NULL root (skipping the
`nd->root.dentry->d_seq` read), so `nd_jump_root()` returns `-ENOENT`
for absolute paths and symlinks, while `..` falls through to
`follow_dotdot()` -- which already treats a NULL `nd->root` as "no
boundary" and climbs. The `AT_FDCWD` legs of `path_init()` return
`-ENOENT` with no cwd; real-dirfd lookups (`openat(2)`, `openat2(2)`)
are unaffected.

- `getcwd(2)`, `/proc/PID/{root,cwd}`, `open_by_handle_at()` with
`AT_FDCWD`, and the cachefiles `cull`/`inuse` commands return an error
rather than dereferencing the NULL path.

The setters need no change: `chdir(2)`/`chroot(2)`/`pivot_root(2)`
resolve via `filename_lookup(AT_FDCWD, ...)`, which simply fails with no
root or cwd, and `fchdir(2)` installs a cwd from an fd without
consulting the old one. `d_path()` is unaffected: `__prepend_path()`
only compares against the root.

These opt-outs are not sticky; keeping a task rootless or cwd-less is an
orthogonal policy decision (e.g. seccomp filtering the setters above).

Link: https://lore.kernel.org/all/a49ce818-f38d-41b0-bbf7-80b8aad998b1@xxxxxxxxxxxxxxxx/
Signed-off-by: John Ericson <mail@xxxxxxxxxxxxxx>
Assisted-by: Claude:claude-opus-4-8
---
fs/cachefiles/daemon.c | 6 ++++--
fs/d_path.c | 6 +++++-
fs/fhandle.c | 3 +++
fs/namei.c | 22 ++++++++++++++++++++--
fs/proc/base.c | 8 ++++++--
include/linux/fs_struct.h | 13 +++++++++++++
6 files changed, 51 insertions(+), 7 deletions(-)

diff --git a/fs/cachefiles/daemon.c b/fs/cachefiles/daemon.c
index 4117b145ac94..344feeb89c61 100644
--- a/fs/cachefiles/daemon.c
+++ b/fs/cachefiles/daemon.c
@@ -652,7 +652,8 @@ static int cachefiles_daemon_cull(struct cachefiles_cache *cache, char *args)

get_fs_pwd(current->fs, &path);

- if (!d_can_lookup(path.dentry))
+ /* A task may have no cwd. */
+ if (!path.mnt || !d_can_lookup(path.dentry))
goto notdir;

cachefiles_begin_secure(cache, &saved_cred);
@@ -723,7 +724,8 @@ static int cachefiles_daemon_inuse(struct cachefiles_cache *cache, char *args)

get_fs_pwd(current->fs, &path);

- if (!d_can_lookup(path.dentry))
+ /* A task may have no cwd. */
+ if (!path.mnt || !d_can_lookup(path.dentry))
goto notdir;

cachefiles_begin_secure(cache, &saved_cred);
diff --git a/fs/d_path.c b/fs/d_path.c
index a48957c0971e..5f16d1efa37c 100644
--- a/fs/d_path.c
+++ b/fs/d_path.c
@@ -422,7 +422,11 @@ SYSCALL_DEFINE2(getcwd, char __user *, buf, unsigned long, size)
rcu_read_lock();
get_fs_root_and_pwd_rcu(current->fs, &root, &pwd);

- if (unlikely(d_unlinked(pwd.dentry))) {
+ /* A task may have no cwd. */
+ if (unlikely(!pwd.mnt)) {
+ rcu_read_unlock();
+ error = -ENOENT;
+ } else if (unlikely(d_unlinked(pwd.dentry))) {
rcu_read_unlock();
error = -ENOENT;
} else {
diff --git a/fs/fhandle.c b/fs/fhandle.c
index 1ca7eb3a6cb5..560f88f53633 100644
--- a/fs/fhandle.c
+++ b/fs/fhandle.c
@@ -180,6 +180,9 @@ static int get_path_anchor(int fd, struct path *root)

if (fd == AT_FDCWD) {
get_fs_pwd(current->fs, root);
+ /* A task may have no cwd. */
+ if (!root->mnt)
+ return -ENOENT;
return 0;
}

diff --git a/fs/namei.c b/fs/namei.c
index 5cc9f0f466b8..06b16815e866 100644
--- a/fs/namei.c
+++ b/fs/namei.c
@@ -1120,11 +1120,20 @@ static int set_root(struct nameidata *nd)
do {
seq = read_seqbegin(&fs->seq);
nd->root = fs->root;
- nd->root_seq = __read_seqcount_begin(&nd->root.dentry->d_seq);
+ /*
+ * A task may have no root. Leave nd->root as the NULL
+ * path and skip the d_seq read: absolute lookups turn
+ * the absence into -ENOENT in nd_jump_root(), while ".."
+ * treats a NULL root as "no boundary" and climbs to its
+ * mount root.
+ */
+ if (likely(nd->root.mnt))
+ nd->root_seq = __read_seqcount_begin(&nd->root.dentry->d_seq);
} while (read_seqretry(&fs->seq, seq));
} else {
get_fs_root(fs, &nd->root);
- nd->state |= ND_ROOT_GRABBED;
+ if (likely(nd->root.mnt))
+ nd->state |= ND_ROOT_GRABBED;
}
return 0;
}
@@ -1143,6 +1152,9 @@ static int nd_jump_root(struct nameidata *nd)
if (unlikely(error))
return error;
}
+ /* Absolute paths need a root to jump to; a task may have none. */
+ if (unlikely(!nd->root.mnt))
+ return -ENOENT;
if (nd->flags & LOOKUP_RCU) {
struct dentry *d;
nd->path = nd->root;
@@ -2732,11 +2744,17 @@ static const char *path_init(struct nameidata *nd, unsigned flags)
do {
seq = read_seqbegin(&fs->seq);
nd->path = fs->pwd;
+ /* A task may have no cwd. */
+ if (unlikely(!nd->path.mnt))
+ return ERR_PTR(-ENOENT);
nd->inode = nd->path.dentry->d_inode;
nd->seq = __read_seqcount_begin(&nd->path.dentry->d_seq);
} while (read_seqretry(&fs->seq, seq));
} else {
get_fs_pwd(current->fs, &nd->path);
+ /* A task may have no cwd. */
+ if (unlikely(!nd->path.mnt))
+ return ERR_PTR(-ENOENT);
nd->inode = nd->path.dentry->d_inode;
}
} else {
diff --git a/fs/proc/base.c b/fs/proc/base.c
index 780f81259052..7f7cc86ce262 100644
--- a/fs/proc/base.c
+++ b/fs/proc/base.c
@@ -213,7 +213,9 @@ static int get_task_root(struct task_struct *task, struct path *root)
task_lock(task);
if (task->fs) {
get_fs_root(task->fs, root);
- result = 0;
+ /* A task may have no root. */
+ if (root->mnt)
+ result = 0;
}
task_unlock(task);
return result;
@@ -227,7 +229,9 @@ static int proc_cwd_link(struct dentry *dentry, struct path *path,
task_lock(task);
if (task->fs) {
get_fs_pwd(task->fs, path);
- result = 0;
+ /* A task may have no cwd. */
+ if (path->mnt)
+ result = 0;
}
task_unlock(task);
return result;
diff --git a/include/linux/fs_struct.h b/include/linux/fs_struct.h
index b5db5de9eb01..84423b4bd21a 100644
--- a/include/linux/fs_struct.h
+++ b/include/linux/fs_struct.h
@@ -13,18 +13,31 @@ struct fs_struct {
int umask;
int in_exec;

+ /*
+ * Note that these paths are explicitly intended to be nullable.
+ * Since they are inline structs and not pointers, we use `.mnt
+ * == NULL` to indicate nullability of the path as a whole.
+ */
+
/*
* The root directory for the task(s) that points to this
* `fs_struct`. The root directory also controls how `..`
* resolve; path traversal is not allowed to resolve upwards
* beyond the root directory. (It is for this latter reason that
* `chroot` is a privileged operation.)
+ *
+ * If null (as described above), absolute paths will not
+ * resolve. In addition `..` will be unbounded, until one
+ * reaches the top of the mount tree.
*/
struct path root;

/*
* The current working directory for the task(s) that points to
* this `fs_struct`.
+ *
+ * If null (as described above), relative paths with `AT_FDCWD`
+ * will not resolve.
*/
struct path pwd;
} __randomize_layout;
--
2.51.2