summaryrefslogtreecommitdiff
path: root/tools/testing/selftests/filesystems
diff options
context:
space:
mode:
Diffstat (limited to 'tools/testing/selftests/filesystems')
-rw-r--r--tools/testing/selftests/filesystems/.gitignore2
-rw-r--r--tools/testing/selftests/filesystems/Makefile6
-rw-r--r--tools/testing/selftests/filesystems/anon_inode_test.c5
-rw-r--r--tools/testing/selftests/filesystems/devpts_pts.c2
-rw-r--r--tools/testing/selftests/filesystems/empty_mntns/.gitignore4
-rw-r--r--tools/testing/selftests/filesystems/empty_mntns/Makefile12
-rw-r--r--tools/testing/selftests/filesystems/empty_mntns/clone3_empty_mntns_test.c938
-rw-r--r--tools/testing/selftests/filesystems/empty_mntns/empty_mntns.h25
-rw-r--r--tools/testing/selftests/filesystems/empty_mntns/empty_mntns_test.c725
-rw-r--r--tools/testing/selftests/filesystems/empty_mntns/overmount_chroot_test.c225
-rw-r--r--tools/testing/selftests/filesystems/epoll/epoll_wakeup_test.c68
-rw-r--r--tools/testing/selftests/filesystems/failfs/.gitignore2
-rw-r--r--tools/testing/selftests/filesystems/failfs/Makefile5
-rw-r--r--tools/testing/selftests/filesystems/failfs/failfs_test.c585
-rw-r--r--tools/testing/selftests/filesystems/fclog.c4
-rw-r--r--tools/testing/selftests/filesystems/fscontext_ns/Makefile10
-rw-r--r--tools/testing/selftests/filesystems/fscontext_ns/fscontext_ns_test.c239
-rw-r--r--tools/testing/selftests/filesystems/fsmount_ns/.gitignore1
-rw-r--r--tools/testing/selftests/filesystems/fsmount_ns/Makefile10
-rw-r--r--tools/testing/selftests/filesystems/fsmount_ns/fsmount_ns_test.c1135
-rw-r--r--tools/testing/selftests/filesystems/fuse/.gitignore1
-rw-r--r--tools/testing/selftests/filesystems/fuse/Makefile13
-rw-r--r--tools/testing/selftests/filesystems/fuse/fuse_acl_cache_test.c347
-rw-r--r--tools/testing/selftests/filesystems/fuse/write_extend_eof_test.c368
-rw-r--r--tools/testing/selftests/filesystems/idmapped_tmpfile.c168
-rw-r--r--tools/testing/selftests/filesystems/move_mount/.gitignore2
-rw-r--r--tools/testing/selftests/filesystems/move_mount/Makefile10
-rw-r--r--tools/testing/selftests/filesystems/move_mount/move_mount_test.c492
-rw-r--r--tools/testing/selftests/filesystems/nsfs/iterate_mntns.c25
-rw-r--r--tools/testing/selftests/filesystems/open_tree_ns/.gitignore1
-rw-r--r--tools/testing/selftests/filesystems/open_tree_ns/Makefile10
-rw-r--r--tools/testing/selftests/filesystems/open_tree_ns/open_tree_ns_test.c1007
-rw-r--r--tools/testing/selftests/filesystems/openat2/.gitignore2
-rw-r--r--tools/testing/selftests/filesystems/openat2/Makefile17
-rw-r--r--tools/testing/selftests/filesystems/openat2/emptypath_test.c77
-rw-r--r--tools/testing/selftests/filesystems/openat2/helpers.h135
-rw-r--r--tools/testing/selftests/filesystems/openat2/openat2_test.c362
-rw-r--r--tools/testing/selftests/filesystems/openat2/rename_attack_test.c159
-rw-r--r--tools/testing/selftests/filesystems/openat2/resolve_test.c561
-rw-r--r--tools/testing/selftests/filesystems/overlayfs/.gitignore1
-rw-r--r--tools/testing/selftests/filesystems/overlayfs/Makefile2
-rw-r--r--tools/testing/selftests/filesystems/overlayfs/idmapped_mounts.c501
-rw-r--r--tools/testing/selftests/filesystems/overlayfs/set_layers_via_fds.c16
-rw-r--r--tools/testing/selftests/filesystems/statmount/statmount.h66
-rw-r--r--tools/testing/selftests/filesystems/statmount/statmount_test.c281
-rw-r--r--tools/testing/selftests/filesystems/statmount/statmount_test_ns.c126
-rw-r--r--tools/testing/selftests/filesystems/ustat_test.c135
-rw-r--r--tools/testing/selftests/filesystems/utils.c30
-rw-r--r--tools/testing/selftests/filesystems/utils.h3
-rw-r--r--tools/testing/selftests/filesystems/xattr/.gitignore3
-rw-r--r--tools/testing/selftests/filesystems/xattr/Makefile6
-rw-r--r--tools/testing/selftests/filesystems/xattr/xattr_socket_test.c470
-rw-r--r--tools/testing/selftests/filesystems/xattr/xattr_socket_types_test.c177
-rw-r--r--tools/testing/selftests/filesystems/xattr/xattr_sockfs_test.c363
54 files changed, 9837 insertions, 103 deletions
diff --git a/tools/testing/selftests/filesystems/.gitignore b/tools/testing/selftests/filesystems/.gitignore
index 64ac0dfa46b7..9eb185fb2f9d 100644
--- a/tools/testing/selftests/filesystems/.gitignore
+++ b/tools/testing/selftests/filesystems/.gitignore
@@ -5,3 +5,5 @@ fclog
file_stressor
anon_inode_test
kernfs_test
+idmapped_tmpfile
+ustat_test
diff --git a/tools/testing/selftests/filesystems/Makefile b/tools/testing/selftests/filesystems/Makefile
index 85427d7f19b9..03be337c1f35 100644
--- a/tools/testing/selftests/filesystems/Makefile
+++ b/tools/testing/selftests/filesystems/Makefile
@@ -1,7 +1,11 @@
# SPDX-License-Identifier: GPL-2.0
CFLAGS += $(KHDR_INCLUDES)
-TEST_GEN_PROGS := devpts_pts file_stressor anon_inode_test kernfs_test fclog
+TEST_GEN_PROGS := devpts_pts file_stressor anon_inode_test kernfs_test fclog ustat_test
+TEST_GEN_PROGS += idmapped_tmpfile
TEST_GEN_PROGS_EXTENDED := dnotify_test
include ../lib.mk
+
+$(OUTPUT)/idmapped_tmpfile: LDLIBS += -lcap
+$(OUTPUT)/idmapped_tmpfile: utils.c
diff --git a/tools/testing/selftests/filesystems/anon_inode_test.c b/tools/testing/selftests/filesystems/anon_inode_test.c
index 94c6c81c2301..2c4c50500116 100644
--- a/tools/testing/selftests/filesystems/anon_inode_test.c
+++ b/tools/testing/selftests/filesystems/anon_inode_test.c
@@ -42,7 +42,10 @@ TEST(anon_inode_no_exec)
fd_context = sys_fsopen("tmpfs", 0);
ASSERT_GE(fd_context, 0);
- ASSERT_LT(execveat(fd_context, "", NULL, NULL, AT_EMPTY_PATH), 0);
+ char *const empty_argv[] = {NULL};
+ char *const empty_envp[] = {NULL};
+
+ ASSERT_LT(execveat(fd_context, "", empty_argv, empty_envp, AT_EMPTY_PATH), 0);
ASSERT_EQ(errno, EACCES);
EXPECT_EQ(close(fd_context), 0);
diff --git a/tools/testing/selftests/filesystems/devpts_pts.c b/tools/testing/selftests/filesystems/devpts_pts.c
index 54fea349204e..aa8d5324f2a6 100644
--- a/tools/testing/selftests/filesystems/devpts_pts.c
+++ b/tools/testing/selftests/filesystems/devpts_pts.c
@@ -119,9 +119,7 @@ static int do_tiocgptpeer(char *ptmx, char *expected_procfd_contents)
goto do_cleanup;
}
-#ifdef TIOCGPTPEER
slave = ioctl(master, TIOCGPTPEER, O_RDWR | O_NOCTTY | O_CLOEXEC);
-#endif
if (slave < 0) {
if (errno == EINVAL) {
fprintf(stderr, "TIOCGPTPEER is not supported. "
diff --git a/tools/testing/selftests/filesystems/empty_mntns/.gitignore b/tools/testing/selftests/filesystems/empty_mntns/.gitignore
new file mode 100644
index 000000000000..99f89d329db2
--- /dev/null
+++ b/tools/testing/selftests/filesystems/empty_mntns/.gitignore
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0-only
+clone3_empty_mntns_test
+empty_mntns_test
+overmount_chroot_test
diff --git a/tools/testing/selftests/filesystems/empty_mntns/Makefile b/tools/testing/selftests/filesystems/empty_mntns/Makefile
new file mode 100644
index 000000000000..22e3fb915e81
--- /dev/null
+++ b/tools/testing/selftests/filesystems/empty_mntns/Makefile
@@ -0,0 +1,12 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+CFLAGS += -Wall -O2 -g $(KHDR_INCLUDES) $(TOOLS_INCLUDES)
+LDLIBS += -lcap
+
+TEST_GEN_PROGS := empty_mntns_test overmount_chroot_test clone3_empty_mntns_test
+
+include ../../lib.mk
+
+$(OUTPUT)/empty_mntns_test: ../utils.c
+$(OUTPUT)/overmount_chroot_test: ../utils.c
+$(OUTPUT)/clone3_empty_mntns_test: ../utils.c
diff --git a/tools/testing/selftests/filesystems/empty_mntns/clone3_empty_mntns_test.c b/tools/testing/selftests/filesystems/empty_mntns/clone3_empty_mntns_test.c
new file mode 100644
index 000000000000..6370086f886d
--- /dev/null
+++ b/tools/testing/selftests/filesystems/empty_mntns/clone3_empty_mntns_test.c
@@ -0,0 +1,938 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Tests for empty mount namespace creation via clone3() CLONE_EMPTY_MNTNS
+ *
+ * These tests exercise the clone3() code path for creating empty mount
+ * namespaces, which is distinct from the unshare() path tested in
+ * empty_mntns_test.c. With clone3(), CLONE_EMPTY_MNTNS (0x2000000000ULL)
+ * is a 64-bit flag that implies CLONE_NEWNS. The implication happens in
+ * kernel_clone() before copy_process(), unlike unshare() where it goes
+ * through UNSHARE_EMPTY_MNTNS -> CLONE_EMPTY_MNTNS conversion in
+ * unshare_nsproxy_namespaces().
+ *
+ * Copyright (c) 2024 Christian Brauner <brauner@kernel.org>
+ */
+
+#define _GNU_SOURCE
+#include <fcntl.h>
+#include <linux/mount.h>
+#include <linux/stat.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/mount.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <unistd.h>
+
+#include "../utils.h"
+#include "../wrappers.h"
+#include "clone3/clone3_selftests.h"
+#include "empty_mntns.h"
+#include "kselftest_harness.h"
+
+static pid_t clone3_empty_mntns(uint64_t extra_flags)
+{
+ struct __clone_args args = {
+ .flags = CLONE_EMPTY_MNTNS | extra_flags,
+ .exit_signal = SIGCHLD,
+ };
+
+ return sys_clone3(&args, sizeof(args));
+}
+
+static bool clone3_empty_mntns_supported(void)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ if (pid < 0)
+ return false;
+
+ if (pid == 0) {
+ if (enter_userns())
+ _exit(1);
+
+ pid = clone3_empty_mntns(0);
+ if (pid < 0)
+ _exit(1);
+
+ if (pid == 0)
+ _exit(0);
+
+ _exit(wait_for_pid(pid) != 0);
+ }
+
+ if (waitpid(pid, &status, 0) != pid)
+ return false;
+
+ if (!WIFEXITED(status))
+ return false;
+
+ return WEXITSTATUS(status) == 0;
+}
+
+FIXTURE(clone3_empty_mntns) {};
+
+FIXTURE_SETUP(clone3_empty_mntns)
+{
+ if (!clone3_empty_mntns_supported())
+ SKIP(return, "CLONE_EMPTY_MNTNS via clone3 not supported");
+}
+
+FIXTURE_TEARDOWN(clone3_empty_mntns) {}
+
+/*
+ * Basic clone3() with CLONE_EMPTY_MNTNS: child gets empty mount namespace
+ * with exactly 1 mount and root == cwd.
+ */
+TEST_F(clone3_empty_mntns, basic)
+{
+ pid_t pid, inner;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ if (enter_userns())
+ _exit(1);
+
+ inner = clone3_empty_mntns(0);
+ if (inner < 0)
+ _exit(2);
+
+ if (inner == 0) {
+ uint64_t root_id, cwd_id;
+
+ if (count_mounts() != 1)
+ _exit(3);
+
+ root_id = get_unique_mnt_id("/");
+ cwd_id = get_unique_mnt_id(".");
+ if (root_id == 0 || cwd_id == 0)
+ _exit(4);
+
+ if (root_id != cwd_id)
+ _exit(5);
+
+ _exit(0);
+ }
+
+ _exit(wait_for_pid(inner));
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * CLONE_EMPTY_MNTNS implies CLONE_NEWNS. Verify that it works without
+ * explicitly setting CLONE_NEWNS (tests fork.c:2627-2630).
+ */
+TEST_F(clone3_empty_mntns, implies_newns)
+{
+ pid_t pid, inner;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ ssize_t parent_mounts;
+
+ if (enter_userns())
+ _exit(1);
+
+ /* Verify we have mounts in our current namespace. */
+ parent_mounts = count_mounts();
+ if (parent_mounts < 1)
+ _exit(2);
+
+ /* Only CLONE_EMPTY_MNTNS, no explicit CLONE_NEWNS. */
+ inner = clone3_empty_mntns(0);
+ if (inner < 0)
+ _exit(3);
+
+ if (inner == 0) {
+ if (count_mounts() != 1)
+ _exit(4);
+
+ _exit(0);
+ }
+
+ /* Parent still has its mounts. */
+ if (count_mounts() != parent_mounts)
+ _exit(5);
+
+ _exit(wait_for_pid(inner));
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Helper macro: generate a test that clones with CLONE_EMPTY_MNTNS |
+ * @extra_flags and verifies the child has exactly one mount.
+ */
+#define TEST_CLONE3_FLAGS(test_name, extra_flags) \
+TEST_F(clone3_empty_mntns, test_name) \
+{ \
+ pid_t pid, inner; \
+ \
+ pid = fork(); \
+ ASSERT_GE(pid, 0); \
+ \
+ if (pid == 0) { \
+ if (enter_userns()) \
+ _exit(1); \
+ \
+ inner = clone3_empty_mntns(extra_flags); \
+ if (inner < 0) \
+ _exit(2); \
+ \
+ if (inner == 0) { \
+ if (count_mounts() != 1) \
+ _exit(3); \
+ _exit(0); \
+ } \
+ \
+ _exit(wait_for_pid(inner)); \
+ } \
+ \
+ ASSERT_EQ(wait_for_pid(pid), 0); \
+}
+
+/* Redundant CLONE_NEWNS | CLONE_EMPTY_MNTNS should succeed. */
+TEST_CLONE3_FLAGS(with_explicit_newns, CLONE_NEWNS)
+
+/* CLONE_EMPTY_MNTNS combined with CLONE_NEWUSER. */
+TEST_CLONE3_FLAGS(with_newuser, CLONE_NEWUSER)
+
+/* CLONE_EMPTY_MNTNS combined with other namespace flags. */
+TEST_CLONE3_FLAGS(with_other_ns_flags, CLONE_NEWUTS | CLONE_NEWIPC)
+
+/*
+ * CLONE_EMPTY_MNTNS combined with CLONE_NEWPID.
+ */
+TEST_F(clone3_empty_mntns, with_newpid)
+{
+ pid_t pid, inner;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ if (enter_userns())
+ _exit(1);
+
+ inner = clone3_empty_mntns(CLONE_NEWPID);
+ if (inner < 0)
+ _exit(2);
+
+ if (inner == 0) {
+ if (count_mounts() != 1)
+ _exit(3);
+
+ /* In a new PID namespace, getpid() returns 1. */
+ if (getpid() != 1)
+ _exit(4);
+
+ _exit(0);
+ }
+
+ _exit(wait_for_pid(inner));
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * CLONE_EMPTY_MNTNS | CLONE_FS must fail because the implied CLONE_NEWNS
+ * and CLONE_FS are mutually exclusive (fork.c:1981).
+ */
+TEST_F(clone3_empty_mntns, with_clone_fs_fails)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ struct __clone_args args = {
+ .flags = CLONE_EMPTY_MNTNS | CLONE_FS,
+ .exit_signal = SIGCHLD,
+ };
+ pid_t ret;
+
+ if (enter_userns())
+ _exit(1);
+
+ ret = sys_clone3(&args, sizeof(args));
+ if (ret >= 0) {
+ if (ret == 0)
+ _exit(0);
+ wait_for_pid(ret);
+ _exit(2);
+ }
+
+ if (errno != EINVAL)
+ _exit(3);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * CLONE_EMPTY_MNTNS combined with CLONE_PIDFD returns a valid pidfd.
+ */
+TEST_F(clone3_empty_mntns, with_pidfd)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ struct __clone_args args = {
+ .flags = CLONE_EMPTY_MNTNS | CLONE_PIDFD,
+ .exit_signal = SIGCHLD,
+ };
+ int pidfd = -1;
+ pid_t inner;
+
+ if (enter_userns())
+ _exit(1);
+
+ args.pidfd = (uintptr_t)&pidfd;
+
+ inner = sys_clone3(&args, sizeof(args));
+ if (inner < 0)
+ _exit(2);
+
+ if (inner == 0) {
+ if (count_mounts() != 1)
+ _exit(3);
+
+ _exit(0);
+ }
+
+ /* Verify we got a valid pidfd. */
+ if (pidfd < 0)
+ _exit(4);
+
+ close(pidfd);
+ _exit(wait_for_pid(inner));
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * clone3 without CAP_SYS_ADMIN must fail with EPERM.
+ */
+TEST_F(clone3_empty_mntns, eperm_without_caps)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ pid_t ret;
+
+ /* Skip if already root. */
+ if (getuid() == 0)
+ _exit(0);
+
+ ret = clone3_empty_mntns(0);
+ if (ret >= 0) {
+ if (ret == 0)
+ _exit(0);
+ wait_for_pid(ret);
+ _exit(1);
+ }
+
+ if (errno != EPERM)
+ _exit(2);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Parent's mount namespace is unaffected after clone3 with CLONE_EMPTY_MNTNS.
+ */
+TEST_F(clone3_empty_mntns, parent_unchanged)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ ssize_t nr_before, nr_after;
+ pid_t inner;
+
+ if (enter_userns())
+ _exit(1);
+
+ nr_before = count_mounts();
+ if (nr_before < 1)
+ _exit(2);
+
+ inner = clone3_empty_mntns(0);
+ if (inner < 0)
+ _exit(3);
+
+ if (inner == 0)
+ _exit(0);
+
+ if (wait_for_pid(inner) != 0)
+ _exit(4);
+
+ nr_after = count_mounts();
+ if (nr_after != nr_before)
+ _exit(5);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Parent with many mounts: child still gets exactly 1 mount.
+ */
+TEST_F(clone3_empty_mntns, many_parent_mounts)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ char tmpdir[] = "/tmp/clone3_mntns_test.XXXXXX";
+ pid_t inner;
+ int i;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(CLONE_NEWNS))
+ _exit(2);
+
+ if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL))
+ _exit(3);
+
+ if (!mkdtemp(tmpdir))
+ _exit(4);
+
+ if (mount("tmpfs", tmpdir, "tmpfs", 0, "size=1M"))
+ _exit(5);
+
+ for (i = 0; i < 5; i++) {
+ char subdir[256];
+
+ snprintf(subdir, sizeof(subdir), "%s/sub%d", tmpdir, i);
+ if (mkdir(subdir, 0755) && errno != EEXIST)
+ _exit(6);
+ if (mount(subdir, subdir, NULL, MS_BIND, NULL))
+ _exit(7);
+ }
+
+ if (count_mounts() < 5)
+ _exit(8);
+
+ inner = clone3_empty_mntns(0);
+ if (inner < 0)
+ _exit(9);
+
+ if (inner == 0) {
+ if (count_mounts() != 1)
+ _exit(10);
+
+ _exit(0);
+ }
+
+ _exit(wait_for_pid(inner));
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Verify the child's root mount is nullfs with expected statmount properties.
+ */
+TEST_F(clone3_empty_mntns, mount_properties)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ pid_t inner;
+
+ if (enter_userns())
+ _exit(1);
+
+ inner = clone3_empty_mntns(0);
+ if (inner < 0)
+ _exit(2);
+
+ if (inner == 0) {
+ struct statmount *sm;
+ uint64_t root_id;
+
+ root_id = get_unique_mnt_id("/");
+ if (!root_id)
+ _exit(3);
+
+ sm = statmount_alloc(root_id, 0,
+ STATMOUNT_MNT_BASIC |
+ STATMOUNT_MNT_POINT |
+ STATMOUNT_FS_TYPE, 0);
+ if (!sm)
+ _exit(4);
+
+ /* Root mount point is "/". */
+ if (!(sm->mask & STATMOUNT_MNT_POINT))
+ _exit(5);
+ if (strcmp(sm->str + sm->mnt_point, "/") != 0)
+ _exit(6);
+
+ /* Filesystem type is nullfs. */
+ if (!(sm->mask & STATMOUNT_FS_TYPE))
+ _exit(7);
+ if (strcmp(sm->str + sm->fs_type, "nullfs") != 0)
+ _exit(8);
+
+ /* Root mount is its own parent. */
+ if (!(sm->mask & STATMOUNT_MNT_BASIC))
+ _exit(9);
+ if (sm->mnt_parent_id != sm->mnt_id)
+ _exit(10);
+
+ free(sm);
+ _exit(0);
+ }
+
+ _exit(wait_for_pid(inner));
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Listmount returns only the root mount in the child's empty namespace.
+ */
+TEST_F(clone3_empty_mntns, listmount_single_entry)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ pid_t inner;
+
+ if (enter_userns())
+ _exit(1);
+
+ inner = clone3_empty_mntns(0);
+ if (inner < 0)
+ _exit(2);
+
+ if (inner == 0) {
+ uint64_t list[16];
+ ssize_t nr_mounts;
+ uint64_t root_id;
+
+ nr_mounts = listmount(LSMT_ROOT, 0, 0, list, 16, 0);
+ if (nr_mounts != 1)
+ _exit(3);
+
+ root_id = get_unique_mnt_id("/");
+ if (!root_id)
+ _exit(4);
+
+ if (list[0] != root_id)
+ _exit(5);
+
+ _exit(0);
+ }
+
+ _exit(wait_for_pid(inner));
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Child can mount tmpfs over nullfs root (the primary container use case).
+ *
+ * Uses the new mount API (fsopen/fsmount/move_mount) because resolving
+ * "/" returns the process root directly without following overmounts.
+ * The mount fd from fsmount lets us fchdir + chroot into the new tmpfs.
+ */
+TEST_F(clone3_empty_mntns, child_overmount_tmpfs)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ pid_t inner;
+
+ if (enter_userns())
+ _exit(1);
+
+ inner = clone3_empty_mntns(0);
+ if (inner < 0)
+ _exit(2);
+
+ if (inner == 0) {
+ struct statmount *sm;
+ uint64_t root_id;
+ int fd, fsfd, mntfd;
+
+ if (count_mounts() != 1)
+ _exit(3);
+
+ /* Verify root is nullfs. */
+ root_id = get_unique_mnt_id("/");
+ if (!root_id)
+ _exit(4);
+
+ sm = statmount_alloc(root_id, 0, STATMOUNT_FS_TYPE, 0);
+ if (!sm)
+ _exit(5);
+ if (!(sm->mask & STATMOUNT_FS_TYPE))
+ _exit(6);
+ if (strcmp(sm->str + sm->fs_type, "nullfs") != 0)
+ _exit(7);
+ free(sm);
+
+ /* Create tmpfs via the new mount API. */
+ fsfd = sys_fsopen("tmpfs", 0);
+ if (fsfd < 0)
+ _exit(8);
+
+ if (sys_fsconfig(fsfd, FSCONFIG_SET_STRING,
+ "size", "1M", 0)) {
+ close(fsfd);
+ _exit(9);
+ }
+
+ if (sys_fsconfig(fsfd, FSCONFIG_CMD_CREATE,
+ NULL, NULL, 0)) {
+ close(fsfd);
+ _exit(10);
+ }
+
+ mntfd = sys_fsmount(fsfd, 0, 0);
+ close(fsfd);
+ if (mntfd < 0)
+ _exit(11);
+
+ /* Attach tmpfs to "/". */
+ if (sys_move_mount(mntfd, "", AT_FDCWD, "/",
+ MOVE_MOUNT_F_EMPTY_PATH)) {
+ close(mntfd);
+ _exit(12);
+ }
+
+ if (count_mounts() != 2) {
+ close(mntfd);
+ _exit(13);
+ }
+
+ /* Enter the tmpfs. */
+ if (fchdir(mntfd)) {
+ close(mntfd);
+ _exit(14);
+ }
+
+ if (chroot(".")) {
+ close(mntfd);
+ _exit(15);
+ }
+
+ close(mntfd);
+
+ /* Verify "/" is now tmpfs. */
+ root_id = get_unique_mnt_id("/");
+ if (!root_id)
+ _exit(16);
+
+ sm = statmount_alloc(root_id, 0, STATMOUNT_FS_TYPE, 0);
+ if (!sm)
+ _exit(17);
+ if (!(sm->mask & STATMOUNT_FS_TYPE))
+ _exit(18);
+ if (strcmp(sm->str + sm->fs_type, "tmpfs") != 0)
+ _exit(19);
+ free(sm);
+
+ /* Verify tmpfs is writable. */
+ fd = open("/testfile", O_CREAT | O_RDWR, 0644);
+ if (fd < 0)
+ _exit(20);
+
+ if (write(fd, "test", 4) != 4) {
+ close(fd);
+ _exit(21);
+ }
+ close(fd);
+
+ if (access("/testfile", F_OK))
+ _exit(22);
+
+ _exit(0);
+ }
+
+ _exit(wait_for_pid(inner));
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Multiple clone3 calls with CLONE_EMPTY_MNTNS produce children with
+ * distinct mount namespace root mount IDs.
+ */
+TEST_F(clone3_empty_mntns, repeated)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ int pipe1[2], pipe2[2];
+ uint64_t id1 = 0, id2 = 0;
+ pid_t inner1, inner2;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (pipe(pipe1) || pipe(pipe2))
+ _exit(2);
+
+ inner1 = clone3_empty_mntns(0);
+ if (inner1 < 0)
+ _exit(3);
+
+ if (inner1 == 0) {
+ uint64_t root_id;
+
+ close(pipe1[0]);
+ root_id = get_unique_mnt_id("/");
+ if (write(pipe1[1], &root_id, sizeof(root_id)) != sizeof(root_id))
+ _exit(1);
+ close(pipe1[1]);
+ _exit(0);
+ }
+
+ inner2 = clone3_empty_mntns(0);
+ if (inner2 < 0)
+ _exit(4);
+
+ if (inner2 == 0) {
+ uint64_t root_id;
+
+ close(pipe2[0]);
+ root_id = get_unique_mnt_id("/");
+ if (write(pipe2[1], &root_id, sizeof(root_id)) != sizeof(root_id))
+ _exit(1);
+ close(pipe2[1]);
+ _exit(0);
+ }
+
+ close(pipe1[1]);
+ close(pipe2[1]);
+
+ if (read(pipe1[0], &id1, sizeof(id1)) != sizeof(id1))
+ _exit(5);
+ if (read(pipe2[0], &id2, sizeof(id2)) != sizeof(id2))
+ _exit(6);
+
+ close(pipe1[0]);
+ close(pipe2[0]);
+
+ if (wait_for_pid(inner1) || wait_for_pid(inner2))
+ _exit(7);
+
+ /* Each child must have a distinct root mount ID. */
+ if (id1 == 0 || id2 == 0)
+ _exit(8);
+ if (id1 == id2)
+ _exit(9);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Verify setns() into a child's empty mount namespace works.
+ */
+TEST_F(clone3_empty_mntns, setns_into_child_mntns)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ int pipe_fd[2];
+ pid_t inner;
+ char c;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (pipe(pipe_fd))
+ _exit(2);
+
+ inner = clone3_empty_mntns(0);
+ if (inner < 0)
+ _exit(3);
+
+ if (inner == 0) {
+ /* Signal parent we're ready. */
+ close(pipe_fd[0]);
+ if (write(pipe_fd[1], "r", 1) != 1)
+ _exit(1);
+
+ /*
+ * Wait for parent to finish. Reading from our
+ * write end will block until the parent closes
+ * its read end, giving us an implicit barrier.
+ */
+ if (read(pipe_fd[1], &c, 1) < 0)
+ ;
+ close(pipe_fd[1]);
+ _exit(0);
+ }
+
+ close(pipe_fd[1]);
+
+ /* Wait for child to be ready. */
+ if (read(pipe_fd[0], &c, 1) != 1)
+ _exit(4);
+
+ /* Open child's mount namespace. */
+ {
+ char path[64];
+ int mntns_fd;
+
+ snprintf(path, sizeof(path), "/proc/%d/ns/mnt", inner);
+ mntns_fd = open(path, O_RDONLY);
+ if (mntns_fd < 0)
+ _exit(5);
+
+ if (setns(mntns_fd, CLONE_NEWNS))
+ _exit(6);
+
+ close(mntns_fd);
+ }
+
+ /* Now we should be in the child's empty mntns. */
+ if (count_mounts() != 1)
+ _exit(7);
+
+ close(pipe_fd[0]);
+ _exit(wait_for_pid(inner));
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Tests below do not require CLONE_EMPTY_MNTNS support.
+ */
+
+/*
+ * Unknown 64-bit flags beyond the known set are rejected.
+ */
+TEST(unknown_flags_rejected)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ struct __clone_args args = {
+ .flags = 0x800000000ULL,
+ .exit_signal = SIGCHLD,
+ };
+ pid_t ret;
+
+ ret = sys_clone3(&args, sizeof(args));
+ if (ret >= 0) {
+ if (ret == 0)
+ _exit(0);
+ wait_for_pid(ret);
+ _exit(1);
+ }
+
+ if (errno != EINVAL)
+ _exit(2);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Regular clone3 with CLONE_NEWNS (without CLONE_EMPTY_MNTNS) still
+ * copies the full mount tree.
+ */
+TEST(clone3_newns_full_copy)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ struct __clone_args args = {
+ .flags = CLONE_NEWNS,
+ .exit_signal = SIGCHLD,
+ };
+ ssize_t parent_mounts;
+ pid_t inner;
+
+ if (enter_userns())
+ _exit(1);
+
+ parent_mounts = count_mounts();
+ if (parent_mounts < 1)
+ _exit(2);
+
+ inner = sys_clone3(&args, sizeof(args));
+ if (inner < 0)
+ _exit(3);
+
+ if (inner == 0) {
+ /* Full copy should have at least as many mounts. */
+ if (count_mounts() < parent_mounts)
+ _exit(1);
+
+ _exit(0);
+ }
+
+ _exit(wait_for_pid(inner));
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/empty_mntns/empty_mntns.h b/tools/testing/selftests/filesystems/empty_mntns/empty_mntns.h
new file mode 100644
index 000000000000..3d9c6b14bbef
--- /dev/null
+++ b/tools/testing/selftests/filesystems/empty_mntns/empty_mntns.h
@@ -0,0 +1,25 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+#ifndef EMPTY_MNTNS_H
+#define EMPTY_MNTNS_H
+
+#include <errno.h>
+#include <stdlib.h>
+
+#include "../statmount/statmount.h"
+
+#ifndef UNSHARE_EMPTY_MNTNS
+#define UNSHARE_EMPTY_MNTNS 0x00100000
+#endif
+
+#ifndef CLONE_EMPTY_MNTNS
+#define CLONE_EMPTY_MNTNS (1ULL << 37)
+#endif
+
+static inline ssize_t count_mounts(void)
+{
+ uint64_t list[4096];
+
+ return listmount(LSMT_ROOT, 0, 0, list, sizeof(list) / sizeof(list[0]), 0);
+}
+
+#endif /* EMPTY_MNTNS_H */
diff --git a/tools/testing/selftests/filesystems/empty_mntns/empty_mntns_test.c b/tools/testing/selftests/filesystems/empty_mntns/empty_mntns_test.c
new file mode 100644
index 000000000000..43e296b97d84
--- /dev/null
+++ b/tools/testing/selftests/filesystems/empty_mntns/empty_mntns_test.c
@@ -0,0 +1,725 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Tests for empty mount namespace creation via UNSHARE_EMPTY_MNTNS
+ *
+ * Copyright (c) 2024 Christian Brauner <brauner@kernel.org>
+ */
+
+#define _GNU_SOURCE
+#include <fcntl.h>
+#include <linux/mount.h>
+#include <linux/stat.h>
+#include <sched.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/mount.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "../utils.h"
+#include "../wrappers.h"
+#include "empty_mntns.h"
+#include "kselftest_harness.h"
+
+static bool unshare_empty_mntns_supported(void)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ if (pid < 0)
+ return false;
+
+ if (pid == 0) {
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS) && errno == EINVAL)
+ _exit(1);
+ _exit(0);
+ }
+
+ if (waitpid(pid, &status, 0) != pid)
+ return false;
+
+ if (!WIFEXITED(status))
+ return false;
+
+ return WEXITSTATUS(status) == 0;
+}
+
+
+FIXTURE(empty_mntns) {};
+
+FIXTURE_SETUP(empty_mntns)
+{
+ if (!unshare_empty_mntns_supported())
+ SKIP(return, "UNSHARE_EMPTY_MNTNS not supported");
+}
+
+FIXTURE_TEARDOWN(empty_mntns) {}
+
+/* Verify unshare succeeds, produces exactly 1 mount, and root == cwd */
+TEST_F(empty_mntns, basic)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t root_id, cwd_id;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS))
+ _exit(2);
+
+ if (count_mounts() != 1)
+ _exit(3);
+
+ root_id = get_unique_mnt_id("/");
+ cwd_id = get_unique_mnt_id(".");
+ if (root_id == 0 || cwd_id == 0)
+ _exit(4);
+
+ if (root_id != cwd_id)
+ _exit(5);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * UNSHARE_EMPTY_MNTNS combined with CLONE_NEWUSER.
+ *
+ * The user namespace must be created first so /proc is still accessible
+ * for writing uid_map/gid_map. The empty mount namespace is created
+ * afterwards.
+ */
+TEST_F(empty_mntns, with_clone_newuser)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uid_t uid = getuid();
+ gid_t gid = getgid();
+ char map[100];
+
+ if (unshare(CLONE_NEWUSER))
+ _exit(1);
+
+ snprintf(map, sizeof(map), "0 %d 1", uid);
+ if (write_file("/proc/self/uid_map", map))
+ _exit(2);
+
+ if (write_file("/proc/self/setgroups", "deny"))
+ _exit(3);
+
+ snprintf(map, sizeof(map), "0 %d 1", gid);
+ if (write_file("/proc/self/gid_map", map))
+ _exit(4);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS))
+ _exit(5);
+
+ if (count_mounts() != 1)
+ _exit(6);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/* UNSHARE_EMPTY_MNTNS combined with other namespace flags */
+TEST_F(empty_mntns, with_other_ns_flags)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS | CLONE_NEWUTS | CLONE_NEWIPC))
+ _exit(2);
+
+ if (count_mounts() != 1)
+ _exit(3);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/* EPERM without proper capabilities */
+TEST_F(empty_mntns, eperm_without_caps)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ /* Skip if already root */
+ if (getuid() == 0)
+ _exit(0);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS) == 0)
+ _exit(1);
+
+ if (errno != EPERM)
+ _exit(2);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/* Many source mounts still result in exactly 1 mount */
+TEST_F(empty_mntns, many_source_mounts)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ char tmpdir[] = "/tmp/empty_mntns_test.XXXXXX";
+ int i;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(CLONE_NEWNS))
+ _exit(2);
+
+ if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL))
+ _exit(3);
+
+ if (!mkdtemp(tmpdir))
+ _exit(4);
+
+ if (mount("tmpfs", tmpdir, "tmpfs", 0, "size=1M"))
+ _exit(5);
+
+ for (i = 0; i < 5; i++) {
+ char subdir[256];
+
+ snprintf(subdir, sizeof(subdir), "%s/sub%d", tmpdir, i);
+ if (mkdir(subdir, 0755) && errno != EEXIST)
+ _exit(6);
+ if (mount(subdir, subdir, NULL, MS_BIND, NULL))
+ _exit(7);
+ }
+
+ if (count_mounts() < 5)
+ _exit(8);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS))
+ _exit(9);
+
+ if (count_mounts() != 1)
+ _exit(10);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/* CWD on a different mount gets reset to root */
+TEST_F(empty_mntns, cwd_reset)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ char tmpdir[] = "/tmp/empty_mntns_cwd.XXXXXX";
+ uint64_t root_id, cwd_id;
+ struct statmount *sm;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(CLONE_NEWNS))
+ _exit(2);
+
+ if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL))
+ _exit(3);
+
+ if (!mkdtemp(tmpdir))
+ _exit(4);
+
+ if (mount("tmpfs", tmpdir, "tmpfs", 0, "size=1M"))
+ _exit(5);
+
+ if (chdir(tmpdir))
+ _exit(6);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS))
+ _exit(7);
+
+ root_id = get_unique_mnt_id("/");
+ cwd_id = get_unique_mnt_id(".");
+ if (root_id == 0 || cwd_id == 0)
+ _exit(8);
+
+ if (root_id != cwd_id)
+ _exit(9);
+
+ sm = statmount_alloc(root_id, 0, STATMOUNT_MNT_ROOT | STATMOUNT_MNT_POINT, 0);
+ if (!sm)
+ _exit(10);
+
+ if (strcmp(sm->str + sm->mnt_point, "/") != 0)
+ _exit(11);
+
+ free(sm);
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/* Verify statmount properties of the root mount */
+TEST_F(empty_mntns, mount_properties)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ struct statmount *sm;
+ uint64_t root_id;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS))
+ _exit(2);
+
+ root_id = get_unique_mnt_id("/");
+ if (!root_id)
+ _exit(3);
+
+ sm = statmount_alloc(root_id, 0, STATMOUNT_MNT_BASIC | STATMOUNT_MNT_ROOT |
+ STATMOUNT_MNT_POINT | STATMOUNT_FS_TYPE, 0);
+ if (!sm)
+ _exit(4);
+
+ if (!(sm->mask & STATMOUNT_MNT_POINT))
+ _exit(5);
+
+ if (strcmp(sm->str + sm->mnt_point, "/") != 0)
+ _exit(6);
+
+ if (!(sm->mask & STATMOUNT_MNT_BASIC))
+ _exit(7);
+
+ if (sm->mnt_id != root_id)
+ _exit(8);
+
+ free(sm);
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/* Consecutive UNSHARE_EMPTY_MNTNS calls produce new namespaces */
+TEST_F(empty_mntns, repeated_unshare)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t first_root_id, second_root_id;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS))
+ _exit(2);
+
+ if (count_mounts() != 1)
+ _exit(3);
+
+ first_root_id = get_unique_mnt_id("/");
+
+ if (unshare(UNSHARE_EMPTY_MNTNS))
+ _exit(4);
+
+ if (count_mounts() != 1)
+ _exit(5);
+
+ second_root_id = get_unique_mnt_id("/");
+
+ if (first_root_id == second_root_id)
+ _exit(6);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/* Root mount's parent is itself */
+TEST_F(empty_mntns, root_is_own_parent)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ struct statmount sm;
+ uint64_t root_id;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS))
+ _exit(2);
+
+ root_id = get_unique_mnt_id("/");
+ if (!root_id)
+ _exit(3);
+
+ if (statmount(root_id, 0, 0, STATMOUNT_MNT_BASIC, &sm, sizeof(sm), 0) < 0)
+ _exit(4);
+
+ if (!(sm.mask & STATMOUNT_MNT_BASIC))
+ _exit(5);
+
+ if (sm.mnt_parent_id != sm.mnt_id)
+ _exit(6);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/* Listmount returns only the root mount */
+TEST_F(empty_mntns, listmount_single_entry)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t list[16];
+ ssize_t nr_mounts;
+ uint64_t root_id;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS))
+ _exit(2);
+
+ nr_mounts = listmount(LSMT_ROOT, 0, 0, list, 16, 0);
+ if (nr_mounts != 1)
+ _exit(3);
+
+ root_id = get_unique_mnt_id("/");
+ if (!root_id)
+ _exit(4);
+
+ if (list[0] != root_id)
+ _exit(5);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Mount tmpfs over nullfs root to build a writable filesystem from scratch.
+ * This exercises the intended usage pattern: create an empty mount namespace
+ * (which has a nullfs root), then mount a real filesystem over it.
+ *
+ * Because resolving "/" returns the process root directly (via nd_jump_root)
+ * without following overmounts, we use the new mount API (fsopen/fsmount)
+ * to obtain a mount fd, then fchdir + chroot to enter the new filesystem.
+ */
+TEST_F(empty_mntns, overmount_tmpfs)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ struct statmount *sm;
+ uint64_t root_id, cwd_id;
+ int fd, fsfd, mntfd;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(UNSHARE_EMPTY_MNTNS))
+ _exit(2);
+
+ if (count_mounts() != 1)
+ _exit(3);
+
+ root_id = get_unique_mnt_id("/");
+ if (!root_id)
+ _exit(4);
+
+ /* Verify root is nullfs */
+ sm = statmount_alloc(root_id, 0, STATMOUNT_FS_TYPE, 0);
+ if (!sm)
+ _exit(5);
+
+ if (!(sm->mask & STATMOUNT_FS_TYPE))
+ _exit(6);
+
+ if (strcmp(sm->str + sm->fs_type, "nullfs") != 0)
+ _exit(7);
+
+ free(sm);
+
+ cwd_id = get_unique_mnt_id(".");
+ if (!cwd_id || root_id != cwd_id)
+ _exit(8);
+
+ /*
+ * nullfs root is immutable. open(O_CREAT) returns ENOENT
+ * because empty_dir_lookup() returns -ENOENT before the
+ * IS_IMMUTABLE permission check in may_o_create() is reached.
+ */
+ fd = open("/test", O_CREAT | O_RDWR, 0644);
+ if (fd >= 0) {
+ close(fd);
+ _exit(9);
+ }
+ if (errno != ENOENT)
+ _exit(10);
+
+ /*
+ * Use the new mount API to create tmpfs and get a mount fd.
+ * We need the fd because after attaching the tmpfs on top of
+ * "/", path resolution of "/" still returns the process root
+ * (nullfs) without following the overmount. The mount fd
+ * lets us fchdir + chroot into the tmpfs.
+ */
+ fsfd = sys_fsopen("tmpfs", 0);
+ if (fsfd < 0)
+ _exit(11);
+
+ if (sys_fsconfig(fsfd, FSCONFIG_SET_STRING, "size", "1M", 0)) {
+ close(fsfd);
+ _exit(12);
+ }
+
+ if (sys_fsconfig(fsfd, FSCONFIG_CMD_CREATE, NULL, NULL, 0)) {
+ close(fsfd);
+ _exit(13);
+ }
+
+ mntfd = sys_fsmount(fsfd, 0, 0);
+ close(fsfd);
+ if (mntfd < 0)
+ _exit(14);
+
+ if (sys_move_mount(mntfd, "", AT_FDCWD, "/",
+ MOVE_MOUNT_F_EMPTY_PATH)) {
+ close(mntfd);
+ _exit(15);
+ }
+
+ if (count_mounts() != 2) {
+ close(mntfd);
+ _exit(16);
+ }
+
+ /* Enter the tmpfs via the mount fd */
+ if (fchdir(mntfd)) {
+ close(mntfd);
+ _exit(17);
+ }
+
+ if (chroot(".")) {
+ close(mntfd);
+ _exit(18);
+ }
+
+ close(mntfd);
+
+ /* Verify "/" now resolves to tmpfs */
+ root_id = get_unique_mnt_id("/");
+ if (!root_id)
+ _exit(19);
+
+ sm = statmount_alloc(root_id, 0, STATMOUNT_FS_TYPE, 0);
+ if (!sm)
+ _exit(20);
+
+ if (!(sm->mask & STATMOUNT_FS_TYPE))
+ _exit(21);
+
+ if (strcmp(sm->str + sm->fs_type, "tmpfs") != 0)
+ _exit(22);
+
+ free(sm);
+
+ /* Verify tmpfs is writable */
+ fd = open("/testfile", O_CREAT | O_RDWR, 0644);
+ if (fd < 0)
+ _exit(23);
+
+ if (write(fd, "test", 4) != 4) {
+ close(fd);
+ _exit(24);
+ }
+
+ close(fd);
+
+ if (access("/testfile", F_OK))
+ _exit(25);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/*
+ * Tests below do not require UNSHARE_EMPTY_MNTNS support.
+ */
+
+/* Invalid unshare flags return EINVAL */
+TEST(invalid_flags)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(0x80000000) == 0)
+ _exit(2);
+
+ if (errno != EINVAL)
+ _exit(3);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/* Regular CLONE_NEWNS still copies the full mount tree */
+TEST(clone_newns_full_copy)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ ssize_t nr_mounts_before, nr_mounts_after;
+ char tmpdir[] = "/tmp/empty_mntns_regr.XXXXXX";
+ int i;
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(CLONE_NEWNS))
+ _exit(2);
+
+ if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL))
+ _exit(3);
+
+ if (!mkdtemp(tmpdir))
+ _exit(4);
+
+ if (mount("tmpfs", tmpdir, "tmpfs", 0, "size=1M"))
+ _exit(5);
+
+ for (i = 0; i < 3; i++) {
+ char subdir[256];
+
+ snprintf(subdir, sizeof(subdir), "%s/sub%d", tmpdir, i);
+ if (mkdir(subdir, 0755) && errno != EEXIST)
+ _exit(6);
+ if (mount(subdir, subdir, NULL, MS_BIND, NULL))
+ _exit(7);
+ }
+
+ nr_mounts_before = count_mounts();
+ if (nr_mounts_before < 3)
+ _exit(8);
+
+ if (unshare(CLONE_NEWNS))
+ _exit(9);
+
+ nr_mounts_after = count_mounts();
+ if (nr_mounts_after < nr_mounts_before)
+ _exit(10);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+/* Other namespace unshares are unaffected */
+TEST(other_ns_unaffected)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ char hostname[256];
+
+ if (enter_userns())
+ _exit(1);
+
+ if (unshare(CLONE_NEWUTS))
+ _exit(2);
+
+ if (sethostname("test-empty-mntns", 16))
+ _exit(3);
+
+ if (gethostname(hostname, sizeof(hostname)))
+ _exit(4);
+
+ if (strcmp(hostname, "test-empty-mntns") != 0)
+ _exit(5);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/empty_mntns/overmount_chroot_test.c b/tools/testing/selftests/filesystems/empty_mntns/overmount_chroot_test.c
new file mode 100644
index 000000000000..6e21c58258c3
--- /dev/null
+++ b/tools/testing/selftests/filesystems/empty_mntns/overmount_chroot_test.c
@@ -0,0 +1,225 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Test: rootfs overmounted multiple times with chroot into topmost
+ *
+ * This test creates a scenario where:
+ * 1. A new mount namespace is created with a tmpfs root (via pivot_root)
+ * 2. A mountpoint is created and overmounted multiple times
+ * 3. The caller chroots into the topmost mount layer
+ *
+ * The test verifies that:
+ * - Multiple overmounts create separate mount layers
+ * - Each layer's files are isolated
+ * - chroot correctly sets the process's root to the topmost layer
+ * - After chroot, only the topmost layer's files are visible
+ *
+ * Copyright (c) 2024 Christian Brauner <brauner@kernel.org>
+ */
+
+#define _GNU_SOURCE
+#include <fcntl.h>
+#include <linux/mount.h>
+#include <linux/stat.h>
+#include <sched.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/mount.h>
+#include <sys/stat.h>
+#include <sys/syscall.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "../utils.h"
+#include "empty_mntns.h"
+#include "kselftest_harness.h"
+
+#define NR_OVERMOUNTS 5
+
+/*
+ * Setup a proper root filesystem using pivot_root.
+ * This ensures we own the root directory in our user namespace.
+ */
+static int setup_root(void)
+{
+ char tmpdir[] = "/tmp/overmount_test.XXXXXX";
+ char oldroot[256];
+
+ if (!mkdtemp(tmpdir))
+ return -1;
+
+ /* Mount tmpfs at the temporary directory */
+ if (mount("tmpfs", tmpdir, "tmpfs", 0, "size=10M"))
+ return -1;
+
+ /* Create directory for old root */
+ snprintf(oldroot, sizeof(oldroot), "%s/oldroot", tmpdir);
+ if (mkdir(oldroot, 0755))
+ return -1;
+
+ /* pivot_root to use the tmpfs as new root */
+ if (syscall(SYS_pivot_root, tmpdir, oldroot))
+ return -1;
+
+ if (chdir("/"))
+ return -1;
+
+ /* Unmount old root */
+ if (umount2("/oldroot", MNT_DETACH))
+ return -1;
+
+ /* Remove oldroot directory */
+ if (rmdir("/oldroot"))
+ return -1;
+
+ return 0;
+}
+
+/*
+ * Test scenario:
+ * 1. Enter a user namespace to gain CAP_SYS_ADMIN
+ * 2. Create a new mount namespace
+ * 3. Setup a tmpfs root via pivot_root
+ * 4. Create a mountpoint /newroot and overmount it multiple times
+ * 5. Create a marker file in each layer
+ * 6. Chroot into /newroot (the topmost overmount)
+ * 7. Verify we're in the topmost layer (only topmost marker visible)
+ */
+TEST(overmount_chroot)
+{
+ pid_t pid;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ ssize_t nr_mounts;
+ uint64_t mnt_ids[NR_OVERMOUNTS + 1];
+ uint64_t root_id_before, root_id_after;
+ struct statmount *sm;
+ char marker[64];
+ int fd, i;
+
+ /* Step 1: Enter user namespace for privileges */
+ if (enter_userns())
+ _exit(1);
+
+ /* Step 2: Create a new mount namespace */
+ if (unshare(CLONE_NEWNS))
+ _exit(2);
+
+ /* Step 3: Make the mount tree private */
+ if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL))
+ _exit(3);
+
+ /* Step 4: Setup a proper tmpfs root via pivot_root */
+ if (setup_root())
+ _exit(4);
+
+ /* Create the base mount point for overmounting */
+ if (mkdir("/newroot", 0755))
+ _exit(5);
+
+ /* Mount base tmpfs on /newroot */
+ if (mount("tmpfs", "/newroot", "tmpfs", 0, "size=1M"))
+ _exit(6);
+
+ /* Record base mount ID */
+ mnt_ids[0] = get_unique_mnt_id("/newroot");
+ if (!mnt_ids[0])
+ _exit(7);
+
+ /* Create marker in base layer */
+ fd = open("/newroot/layer_0", O_CREAT | O_RDWR, 0644);
+ if (fd < 0)
+ _exit(8);
+ if (write(fd, "layer_0", 7) != 7) {
+ close(fd);
+ _exit(9);
+ }
+ close(fd);
+
+ /* Step 5: Overmount /newroot multiple times with tmpfs */
+ for (i = 0; i < NR_OVERMOUNTS; i++) {
+ if (mount("tmpfs", "/newroot", "tmpfs", 0, "size=1M"))
+ _exit(10);
+
+ /* Record mount ID for this layer */
+ mnt_ids[i + 1] = get_unique_mnt_id("/newroot");
+ if (!mnt_ids[i + 1])
+ _exit(11);
+
+ /* Create a marker file in each layer */
+ snprintf(marker, sizeof(marker), "/newroot/layer_%d", i + 1);
+ fd = open(marker, O_CREAT | O_RDWR, 0644);
+ if (fd < 0)
+ _exit(12);
+
+ if (write(fd, marker, strlen(marker)) != (ssize_t)strlen(marker)) {
+ close(fd);
+ _exit(13);
+ }
+ close(fd);
+ }
+
+ /* Verify mount count increased */
+ nr_mounts = count_mounts();
+ if (nr_mounts < NR_OVERMOUNTS + 2)
+ _exit(14);
+
+ /* Record root mount ID before chroot */
+ root_id_before = get_unique_mnt_id("/newroot");
+
+ /* Verify this is the topmost layer's mount */
+ if (root_id_before != mnt_ids[NR_OVERMOUNTS])
+ _exit(15);
+
+ /* Step 6: Chroot into /newroot (the topmost overmount) */
+ if (chroot("/newroot"))
+ _exit(16);
+
+ /* Change to root directory within the chroot */
+ if (chdir("/"))
+ _exit(17);
+
+ /* Step 7: Verify we're in the topmost layer */
+ root_id_after = get_unique_mnt_id("/");
+
+ /* The mount ID should be the same as the topmost layer */
+ if (root_id_after != mnt_ids[NR_OVERMOUNTS])
+ _exit(18);
+
+ /* Verify the topmost layer's marker file exists */
+ snprintf(marker, sizeof(marker), "/layer_%d", NR_OVERMOUNTS);
+ if (access(marker, F_OK))
+ _exit(19);
+
+ /* Verify we cannot see markers from lower layers (they're hidden) */
+ for (i = 0; i < NR_OVERMOUNTS; i++) {
+ snprintf(marker, sizeof(marker), "/layer_%d", i);
+ if (access(marker, F_OK) == 0)
+ _exit(20);
+ }
+
+ /* Verify the root mount is tmpfs */
+ sm = statmount_alloc(root_id_after, 0,
+ STATMOUNT_MNT_BASIC | STATMOUNT_MNT_ROOT |
+ STATMOUNT_MNT_POINT | STATMOUNT_FS_TYPE, 0);
+ if (!sm)
+ _exit(21);
+
+ if (sm->mask & STATMOUNT_FS_TYPE) {
+ if (strcmp(sm->str + sm->fs_type, "tmpfs") != 0) {
+ free(sm);
+ _exit(22);
+ }
+ }
+
+ free(sm);
+ _exit(0);
+ }
+
+ ASSERT_EQ(wait_for_pid(pid), 0);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/epoll/epoll_wakeup_test.c b/tools/testing/selftests/filesystems/epoll/epoll_wakeup_test.c
index 8bc57a2ef966..81a994943e12 100644
--- a/tools/testing/selftests/filesystems/epoll/epoll_wakeup_test.c
+++ b/tools/testing/selftests/filesystems/epoll/epoll_wakeup_test.c
@@ -3493,4 +3493,72 @@ TEST(epoll64)
close(ctx.sfd[1]);
}
+static void *epoll65_wait(void *ctx_)
+{
+ struct epoll_mtcontext *ctx = ctx_;
+ struct epoll_event event;
+
+ for (int i = 0; i < 100000; ++i) {
+ if (!epoll_wait(ctx->efd[0], &event, 1, 0))
+ return (void *)ENODATA;
+ }
+
+ return (void *)0;
+}
+
+TEST(epoll65)
+{
+ struct epoll_mtcontext ctx;
+ struct epoll_event event;
+ int64_t dummy_data = 99;
+ pthread_t threads[64];
+ uintptr_t ret;
+ int i, err;
+
+ ctx.efd[0] = epoll_create(1);
+ ASSERT_GE(ctx.efd[0], 0);
+ ctx.efd[1] = eventfd(0, 0);
+ ASSERT_GE(ctx.efd[1], 0);
+
+ event.events = EPOLLIN;
+ err = epoll_ctl(ctx.efd[0], EPOLL_CTL_ADD, ctx.efd[1], &event);
+ ASSERT_EQ(err, 0);
+
+ write(ctx.efd[1], &dummy_data, sizeof(dummy_data));
+
+ for (i = 0; i < ARRAY_SIZE(threads); ++i)
+ ASSERT_EQ(pthread_create(&threads[i], NULL, epoll65_wait, &ctx), 0);
+
+ for (i = 0; i < ARRAY_SIZE(threads); ++i) {
+ ASSERT_EQ(pthread_join(threads[i], (void **)&ret), 0);
+ ASSERT_EQ(ret, 0);
+ }
+
+ close(ctx.efd[0]);
+ close(ctx.efd[1]);
+}
+
+TEST(epoll66)
+{
+ struct epoll_event event;
+ int pfd[2], efd;
+
+ ASSERT_EQ(pipe(pfd), 0);
+
+ efd = epoll_create1(0);
+ ASSERT_GE(efd, 0);
+
+ event.events = EPOLLIN | EPOLLET;
+ ASSERT_EQ(epoll_ctl(efd, EPOLL_CTL_ADD, pfd[0], &event), 0);
+
+ for (int i = 0; i < 2; ++i) {
+ ASSERT_EQ(write(pfd[1], "", 1), 1);
+ EXPECT_EQ(epoll_wait(efd, &event, 1, 0), 1);
+ }
+
+ close(pfd[0]);
+ close(pfd[1]);
+ close(efd);
+}
+
TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/failfs/.gitignore b/tools/testing/selftests/filesystems/failfs/.gitignore
new file mode 100644
index 000000000000..cd3b5d884d7e
--- /dev/null
+++ b/tools/testing/selftests/filesystems/failfs/.gitignore
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: GPL-2.0-only
+failfs_test
diff --git a/tools/testing/selftests/filesystems/failfs/Makefile b/tools/testing/selftests/filesystems/failfs/Makefile
new file mode 100644
index 000000000000..3c5d98b4fe72
--- /dev/null
+++ b/tools/testing/selftests/filesystems/failfs/Makefile
@@ -0,0 +1,5 @@
+# SPDX-License-Identifier: GPL-2.0
+CFLAGS += -Wall -O2 -g $(KHDR_INCLUDES)
+TEST_GEN_PROGS := failfs_test
+
+include ../../lib.mk
diff --git a/tools/testing/selftests/filesystems/failfs/failfs_test.c b/tools/testing/selftests/filesystems/failfs/failfs_test.c
new file mode 100644
index 000000000000..29a3c294127e
--- /dev/null
+++ b/tools/testing/selftests/filesystems/failfs/failfs_test.c
@@ -0,0 +1,585 @@
+// SPDX-License-Identifier: GPL-2.0
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <link.h>
+#include <sched.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/mount.h>
+#include <sys/prctl.h>
+#include <sys/stat.h>
+#include <sys/syscall.h>
+#include <sys/types.h>
+#include <sys/vfs.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "../../kselftest_harness.h"
+
+#ifndef __NR_fchroot
+#define __NR_fchroot 472
+#endif
+
+#ifndef FD_PIDFS_ROOT
+#define FD_PIDFS_ROOT -10002
+#endif
+
+#ifndef FD_NSFS_ROOT
+#define FD_NSFS_ROOT -10003
+#endif
+
+#ifndef FD_FAILFS_ROOT
+#define FD_FAILFS_ROOT -10004
+#endif
+
+#define NOBODY_UID 65534
+
+/* Child sentinel exit code: the exec was blocked as expected. */
+#define FAILFS_EXEC_BLOCKED 99
+
+/* Stack for the CLONE_FS helper in fchroot_sentinel_shared_fs_struct. */
+#define FAILFS_CLONE_STACK (64 * 1024)
+
+static int sys_fchroot(int fd, unsigned int flags)
+{
+ return syscall(__NR_fchroot, fd, flags);
+}
+
+/*
+ * Raw syscall: glibc's getcwd() rejects the kernel's "(unreachable)"
+ * result and falls back to a generic implementation.
+ */
+static long sys_getcwd(char *buf, size_t size)
+{
+ return syscall(__NR_getcwd, buf, size);
+}
+
+static int drop_to_nobody(void)
+{
+ return setresuid(NOBODY_UID, NOBODY_UID, NOBODY_UID);
+}
+
+/* Parked CLONE_FS child; dies with its parent so it never leaks. */
+static int failfs_park(void *arg)
+{
+ pid_t parent = (pid_t)(long)arg;
+
+ prctl(PR_SET_PDEATHSIG, SIGKILL);
+ /* The parent may have died before the death signal was armed. */
+ if (getppid() != parent)
+ _exit(0);
+ pause();
+ return 0;
+}
+
+/* Is fd a dynamically linked ELF with an absolute PT_INTERP interpreter? */
+static int elf_has_absolute_interp(int fd)
+{
+ ElfW(Ehdr) ehdr;
+ ElfW(Phdr) phdr;
+ char interp;
+ int i;
+
+ if (pread(fd, &ehdr, sizeof(ehdr), 0) != sizeof(ehdr))
+ return 0;
+ if (memcmp(ehdr.e_ident, ELFMAG, SELFMAG) != 0)
+ return 0;
+
+ for (i = 0; i < ehdr.e_phnum; i++) {
+ if (pread(fd, &phdr, sizeof(phdr),
+ ehdr.e_phoff + i * sizeof(phdr)) != sizeof(phdr))
+ return 0;
+ if (phdr.p_type != PT_INTERP)
+ continue;
+ if (pread(fd, &interp, 1, phdr.p_offset) != 1)
+ return 0;
+ return interp == '/';
+ }
+
+ return 0;
+}
+
+TEST(fchdir_sentinel)
+{
+ char buf[PATH_MAX];
+ int fd;
+
+ ASSERT_EQ(fchdir(FD_FAILFS_ROOT), 0);
+
+ /* The working directory is unreachable from the process root. */
+ ASSERT_GT(sys_getcwd(buf, sizeof(buf)), 0);
+ ASSERT_EQ(strncmp(buf, "(unreachable)", 13), 0);
+
+ /* Every AT_FDCWD-relative lookup fails. */
+ ASSERT_EQ(openat(AT_FDCWD, "foo", O_RDONLY), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+ ASSERT_EQ(openat(AT_FDCWD, ".", O_RDONLY), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+ ASSERT_EQ(openat(AT_FDCWD, "..", O_RDONLY), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+ ASSERT_EQ(openat(AT_FDCWD, "foo", O_WRONLY | O_CREAT, 0600), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+
+ /* The cwd cannot be pinned by following /proc/self/cwd into it. */
+ ASSERT_EQ(open("/proc/self/cwd", O_PATH), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+
+ /* The root is untouched so absolute lookups keep working... */
+ fd = open("/", O_RDONLY | O_DIRECTORY);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(close(fd), 0);
+
+ /* ... and the working directory can be recovered. */
+ ASSERT_EQ(chdir("/"), 0);
+ ASSERT_GT(sys_getcwd(buf, sizeof(buf)), 0);
+ ASSERT_EQ(strcmp(buf, "/"), 0);
+}
+
+TEST(fchdir_rejects_other_sentinels)
+{
+ ASSERT_EQ(fchdir(FD_PIDFS_ROOT), -1);
+ ASSERT_EQ(errno, EBADF);
+ ASSERT_EQ(fchdir(FD_NSFS_ROOT), -1);
+ ASSERT_EQ(errno, EBADF);
+ ASSERT_EQ(fchdir(-10009), -1);
+ ASSERT_EQ(errno, EBADF);
+}
+
+TEST(fchroot_flags)
+{
+ int fd;
+
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 1), -1);
+ ASSERT_EQ(errno, EINVAL);
+
+ fd = open("/", O_PATH | O_DIRECTORY);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(sys_fchroot(fd, 1), -1);
+ ASSERT_EQ(errno, EINVAL);
+ ASSERT_EQ(close(fd), 0);
+}
+
+TEST(fchroot_bad_fd)
+{
+ ASSERT_EQ(sys_fchroot(-1, 0), -1);
+ ASSERT_EQ(errno, EBADF);
+
+ /* Only FD_FAILFS_ROOT is a valid sentinel. */
+ ASSERT_EQ(sys_fchroot(FD_PIDFS_ROOT, 0), -1);
+ ASSERT_EQ(errno, EBADF);
+ ASSERT_EQ(sys_fchroot(FD_NSFS_ROOT, 0), -1);
+ ASSERT_EQ(errno, EBADF);
+}
+
+TEST(fchroot_notdir)
+{
+ int fd;
+
+ fd = open("/proc/self/status", O_RDONLY);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(sys_fchroot(fd, 0), -1);
+ ASSERT_EQ(errno, ENOTDIR);
+ ASSERT_EQ(close(fd), 0);
+}
+
+TEST(fchroot_realfd_requires_cap)
+{
+ int fd;
+
+ if (geteuid() == 0)
+ ASSERT_EQ(drop_to_nobody(), 0);
+
+ fd = open("/", O_PATH | O_DIRECTORY);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(sys_fchroot(fd, 0), -1);
+ ASSERT_EQ(errno, EPERM);
+ ASSERT_EQ(close(fd), 0);
+}
+
+TEST(fchroot_realfd)
+{
+ char template[] = "/tmp/failfs_test.XXXXXX";
+ char path[PATH_MAX];
+ struct stat st;
+ int tmpfd, dfd, fd;
+
+ if (geteuid() != 0)
+ SKIP(return, "fchroot() with a regular fd requires CAP_SYS_CHROOT");
+
+ tmpfd = open("/tmp", O_PATH | O_DIRECTORY);
+ ASSERT_GE(tmpfd, 0);
+
+ ASSERT_NE(mkdtemp(template), NULL);
+ snprintf(path, sizeof(path), "%s/canary", template);
+ fd = open(path, O_WRONLY | O_CREAT, 0600);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(close(fd), 0);
+
+ dfd = open(template, O_PATH | O_DIRECTORY);
+ ASSERT_GE(dfd, 0);
+ ASSERT_EQ(sys_fchroot(dfd, 0), 0);
+ ASSERT_EQ(close(dfd), 0);
+
+ ASSERT_EQ(stat("/canary", &st), 0);
+
+ /* Best-effort cleanup: dirfd-anchored I/O works with the new root. */
+ snprintf(path, sizeof(path), "%s/canary", template + strlen("/tmp/"));
+ unlinkat(tmpfd, path, 0);
+ unlinkat(tmpfd, template + strlen("/tmp/"), AT_REMOVEDIR);
+}
+
+TEST(fchroot_sentinel)
+{
+ char template[] = "/tmp/failfs_test.XXXXXX";
+ struct stat realroot, st;
+ struct statfs sfs;
+ char buf[PATH_MAX];
+ int procfd, tmpfd, dfd, fd;
+ struct {
+ struct file_handle handle;
+ unsigned char f_handle[MAX_HANDLE_SZ];
+ } fh;
+ int mntid;
+ ssize_t ret;
+
+ if (geteuid() != 0)
+ SKIP(return, "privileged fchroot(FD_FAILFS_ROOT) requires CAP_SYS_CHROOT");
+
+ ASSERT_EQ(stat("/", &realroot), 0);
+ procfd = open("/proc", O_PATH | O_DIRECTORY);
+ ASSERT_GE(procfd, 0);
+ tmpfd = open("/tmp", O_PATH | O_DIRECTORY);
+ ASSERT_GE(tmpfd, 0);
+ ASSERT_NE(mkdtemp(template), NULL);
+ dfd = open(template, O_RDONLY | O_DIRECTORY);
+ ASSERT_GE(dfd, 0);
+
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), 0);
+
+ /* Absolute lookups fail. */
+ ASSERT_EQ(open("/etc/passwd", O_RDONLY), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+ ASSERT_EQ(mkdir("/foo", 0700), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+
+ /*
+ * The root cannot be referenced at all - not even an O_PATH open,
+ * which skips ->permission(), because it lands on the root as a
+ * jumped walk terminal that ->d_weak_revalidate() refuses.
+ */
+ ASSERT_EQ(open("/", O_RDONLY | O_DIRECTORY), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+ ASSERT_EQ(open("/", O_PATH), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+ ASSERT_EQ(statfs("/", &sfs), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+
+ /*
+ * It cannot be pinned by following /proc/self/root into it either
+ * (only the root is in failfs here, so self/cwd is still real).
+ */
+ ASSERT_EQ(openat(procfd, "self/root", O_PATH), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+
+ /* Nor encoded into a file handle. */
+ fh.handle.handle_bytes = MAX_HANDLE_SZ;
+ ASSERT_EQ(name_to_handle_at(AT_FDCWD, "/", &fh.handle, &mntid, 0), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+
+ /* The working directory is now unreachable from the root. */
+ ASSERT_GT(sys_getcwd(buf, sizeof(buf)), 0);
+ ASSERT_EQ(strncmp(buf, "(unreachable)", 13), 0);
+
+ /* Lookups anchored at real directories keep working. */
+ fd = openat(AT_FDCWD, ".", O_RDONLY | O_DIRECTORY);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(close(fd), 0);
+ fd = openat(dfd, "canary", O_WRONLY | O_CREAT, 0600);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(write(fd, "x", 1), 1);
+ ASSERT_EQ(close(fd), 0);
+ fd = openat(dfd, "canary", O_RDONLY);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(close(fd), 0);
+
+ /* ".." walks clamp at the top of the mount tree, not at failfs. */
+ fd = openat(AT_FDCWD, "../../../../../../../../../..", O_PATH);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(fstat(fd, &st), 0);
+ ASSERT_EQ(st.st_dev, realroot.st_dev);
+ ASSERT_EQ(st.st_ino, realroot.st_ino);
+ ASSERT_EQ(close(fd), 0);
+
+ /* readlink of the magic link still works: it does not follow. */
+ ret = readlinkat(procfd, "self/root", buf, sizeof(buf) - 1);
+ ASSERT_GT(ret, 0);
+ buf[ret] = '\0';
+ TH_LOG("/proc/self/root points to '%s'", buf);
+ /* d_path() names the failfs root synthetically, never as a real path. */
+ ASSERT_EQ(strcmp(buf, "failfs:/"), 0);
+
+ /* But following it into failfs is refused. */
+ ASSERT_EQ(fstatat(procfd, "self/root", &st, 0), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+
+ /* Best-effort cleanup via the pre-opened dirfds. */
+ unlinkat(dfd, "canary", 0);
+ unlinkat(tmpfd, template + strlen("/tmp/"), AT_REMOVEDIR);
+}
+
+TEST(fchroot_sentinel_absolute_symlink)
+{
+ char template[] = "/tmp/failfs_test.XXXXXX";
+ int tmpfd, dfd, fd;
+
+ if (geteuid() != 0)
+ SKIP(return, "privileged fchroot(FD_FAILFS_ROOT) requires CAP_SYS_CHROOT");
+
+ tmpfd = open("/tmp", O_PATH | O_DIRECTORY);
+ ASSERT_GE(tmpfd, 0);
+ ASSERT_NE(mkdtemp(template), NULL);
+ dfd = open(template, O_RDONLY | O_DIRECTORY);
+ ASSERT_GE(dfd, 0);
+
+ fd = openat(dfd, "target", O_WRONLY | O_CREAT, 0600);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(close(fd), 0);
+ ASSERT_EQ(symlinkat("target", dfd, "rel"), 0);
+ ASSERT_EQ(symlinkat("/etc", dfd, "abs"), 0);
+
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), 0);
+
+ /* Relative symlinks keep resolving within the dirfd-anchored walk... */
+ fd = openat(dfd, "rel", O_RDONLY);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(close(fd), 0);
+
+ /* ... absolute symlinks restart the walk at the failfs root. */
+ ASSERT_EQ(openat(dfd, "abs", O_RDONLY), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+
+ /* Best-effort cleanup via the pre-opened dirfds. */
+ unlinkat(dfd, "abs", 0);
+ unlinkat(dfd, "rel", 0);
+ unlinkat(dfd, "target", 0);
+ unlinkat(tmpfd, template + strlen("/tmp/"), AT_REMOVEDIR);
+}
+
+TEST(fchroot_sentinel_unprivileged)
+{
+ char buf[PATH_MAX];
+
+ if (geteuid() == 0)
+ ASSERT_EQ(drop_to_nobody(), 0);
+
+ /* Without no_new_privs entering failfs is not allowed... */
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), -1);
+ ASSERT_EQ(errno, EPERM);
+
+ /* ... with no_new_privs set it is allowed. */
+ ASSERT_EQ(prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0), 0);
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), 0);
+
+ ASSERT_EQ(open("/etc/passwd", O_RDONLY), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+
+ /* The task counts as chrooted: no user namespaces anymore. */
+ ASSERT_EQ(unshare(CLONE_NEWUSER), -1);
+ ASSERT_EQ(errno, EPERM);
+
+ /* With both root and cwd in failfs getcwd() reports "/". */
+ ASSERT_EQ(fchdir(FD_FAILFS_ROOT), 0);
+ ASSERT_GT(sys_getcwd(buf, sizeof(buf)), 0);
+ ASSERT_EQ(strcmp(buf, "/"), 0);
+}
+
+TEST(fchroot_sentinel_rejected_when_chrooted)
+{
+ char template[] = "/tmp/failfs_test.XXXXXX";
+ int tmpfd;
+
+ if (geteuid() != 0)
+ SKIP(return, "chroot() requires CAP_SYS_CHROOT");
+
+ tmpfd = open("/tmp", O_PATH | O_DIRECTORY);
+ ASSERT_GE(tmpfd, 0);
+ ASSERT_NE(mkdtemp(template), NULL);
+ ASSERT_EQ(chroot(template), 0);
+ ASSERT_EQ(chdir("/"), 0);
+
+ /* Remove the jail while still privileged; sticky /tmp blocks nobody. */
+ unlinkat(tmpfd, template + strlen("/tmp/"), AT_REMOVEDIR);
+
+ ASSERT_EQ(drop_to_nobody(), 0);
+ ASSERT_EQ(prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0), 0);
+
+ /* An unprivileged chrooted task must not lift its ".." barrier. */
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), -1);
+ ASSERT_EQ(errno, EPERM);
+}
+
+TEST(fchroot_sentinel_shared_fs_struct)
+{
+ char stack[FAILFS_CLONE_STACK];
+ pid_t pid;
+
+ if (geteuid() == 0)
+ ASSERT_EQ(drop_to_nobody(), 0);
+
+ /* A CLONE_FS sibling shares the fs_struct: bump fs->users to 2. */
+ pid = clone(failfs_park, stack + sizeof(stack), CLONE_FS | SIGCHLD,
+ (void *)(long)getpid());
+ ASSERT_GE(pid, 0);
+
+ ASSERT_EQ(prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0), 0);
+
+ /*
+ * A sibling without no_new_privs could exec a setuid binary with
+ * the failfs root, so a shared fs_struct is refused even with
+ * no_new_privs set.
+ */
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), -1);
+ ASSERT_EQ(errno, EINVAL);
+
+ ASSERT_EQ(kill(pid, SIGKILL), 0);
+ ASSERT_EQ(waitpid(pid, NULL, 0), pid);
+}
+
+TEST(fchroot_sentinel_no_overmount)
+{
+ if (geteuid() != 0)
+ SKIP(return, "mounting requires privileges");
+
+ /*
+ * Contain the blast radius: if failfs ever regressed and "/"
+ * resolved to the real root, the tmpfs mount below must not touch
+ * the host. A private mount namespace keeps it local to this child.
+ */
+ ASSERT_EQ(unshare(CLONE_NEWNS), 0);
+ ASSERT_EQ(mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL), 0);
+
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), 0);
+
+ /*
+ * Nothing can be mounted on top of the failfs root. It cannot even
+ * be named as a mount target: resolving "/" is refused before the
+ * mount machinery (which, failfs being in no mount namespace, would
+ * reject it anyway) is ever reached. open_tree(OPEN_TREE_CLONE) is
+ * likewise moot since no fd to the root can be obtained.
+ */
+ ASSERT_EQ(mount("none", "/", "tmpfs", 0, NULL), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+}
+
+TEST(fchroot_sentinel_setns_escape)
+{
+ struct stat realroot, st;
+ int nsfd;
+
+ if (geteuid() != 0)
+ SKIP(return, "setns() to a mount namespace requires privileges");
+
+ ASSERT_EQ(stat("/", &realroot), 0);
+ nsfd = open("/proc/self/ns/mnt", O_RDONLY);
+ ASSERT_GE(nsfd, 0);
+
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), 0);
+ ASSERT_EQ(open("/etc", O_PATH), -1);
+ ASSERT_EQ(errno, EOPNOTSUPP);
+
+ /* A mount namespace fd is the key out: it resets root and cwd. */
+ ASSERT_EQ(setns(nsfd, CLONE_NEWNS), 0);
+ ASSERT_EQ(close(nsfd), 0);
+
+ ASSERT_EQ(stat("/", &st), 0);
+ ASSERT_EQ(st.st_dev, realroot.st_dev);
+ ASSERT_EQ(st.st_ino, realroot.st_ino);
+}
+
+TEST(fchroot_sentinel_exec)
+{
+ pid_t pid;
+ int status;
+
+ if (geteuid() != 0)
+ SKIP(return, "privileged fchroot(FD_FAILFS_ROOT) requires CAP_SYS_CHROOT");
+
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), 0);
+
+ /*
+ * Exec in a child: a wrongly successful exec would replace the test
+ * image and its exit code would not match the sentinel below.
+ */
+ pid = fork();
+ ASSERT_GE(pid, 0);
+ if (pid == 0) {
+ execl("/bin/true", "true", NULL);
+ _exit(errno == EOPNOTSUPP ? FAILFS_EXEC_BLOCKED : 1);
+ }
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+ ASSERT_EQ(WEXITSTATUS(status), FAILFS_EXEC_BLOCKED);
+}
+
+TEST(fchroot_sentinel_exec_interpreter)
+{
+ static const char * const argv[] = { "failfs_test", NULL };
+ static const char * const envp[] = { NULL };
+ pid_t pid;
+ int status, exefd;
+
+ if (geteuid() != 0)
+ SKIP(return, "privileged fchroot(FD_FAILFS_ROOT) requires CAP_SYS_CHROOT");
+
+ /* Exec ourselves: the one binary guaranteed to be around. */
+ exefd = open("/proc/self/exe", O_RDONLY);
+ ASSERT_GE(exefd, 0);
+ if (!elf_has_absolute_interp(exefd))
+ SKIP(return, "test binary has no absolute PT_INTERP interpreter");
+
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), 0);
+
+ /*
+ * The binary itself needs no path lookup - it is executed by fd -
+ * but loading it fails on opening the absolute PT_INTERP
+ * interpreter. Run it in a child so a wrongly successful exec does
+ * not replace the test image and masquerade as a pass.
+ */
+ pid = fork();
+ ASSERT_GE(pid, 0);
+ if (pid == 0) {
+ syscall(__NR_execveat, exefd, "", argv, envp, AT_EMPTY_PATH);
+ _exit(errno == EOPNOTSUPP ? FAILFS_EXEC_BLOCKED : 1);
+ }
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+ ASSERT_EQ(WEXITSTATUS(status), FAILFS_EXEC_BLOCKED);
+}
+
+TEST(fchroot_sentinel_inherited)
+{
+ pid_t pid;
+ int status;
+
+ if (geteuid() != 0)
+ SKIP(return, "privileged fchroot(FD_FAILFS_ROOT) requires CAP_SYS_CHROOT");
+
+ ASSERT_EQ(sys_fchroot(FD_FAILFS_ROOT, 0), 0);
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+ if (pid == 0) {
+ if (open("/etc", O_PATH) != -1 || errno != EOPNOTSUPP)
+ _exit(1);
+ _exit(0);
+ }
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+ ASSERT_EQ(WEXITSTATUS(status), 0);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/fclog.c b/tools/testing/selftests/filesystems/fclog.c
index 551c4a0f395a..593a5136e991 100644
--- a/tools/testing/selftests/filesystems/fclog.c
+++ b/tools/testing/selftests/filesystems/fclog.c
@@ -6,10 +6,8 @@
#include <assert.h>
#include <errno.h>
+#include <fcntl.h>
#include <sched.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
#include <unistd.h>
#include <sys/mount.h>
diff --git a/tools/testing/selftests/filesystems/fscontext_ns/Makefile b/tools/testing/selftests/filesystems/fscontext_ns/Makefile
new file mode 100644
index 000000000000..7e3506294757
--- /dev/null
+++ b/tools/testing/selftests/filesystems/fscontext_ns/Makefile
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: GPL-2.0
+TEST_GEN_PROGS := fscontext_ns_test
+
+CFLAGS += -Wall -O0 -g $(KHDR_INCLUDES) $(TOOLS_INCLUDES)
+LDLIBS := -lcap
+
+include ../../lib.mk
+
+$(OUTPUT)/fscontext_ns_test: fscontext_ns_test.c ../utils.c
+ $(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)
diff --git a/tools/testing/selftests/filesystems/fscontext_ns/fscontext_ns_test.c b/tools/testing/selftests/filesystems/fscontext_ns/fscontext_ns_test.c
new file mode 100644
index 000000000000..0f30a3e6e197
--- /dev/null
+++ b/tools/testing/selftests/filesystems/fscontext_ns/fscontext_ns_test.c
@@ -0,0 +1,239 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (C) 2026 Christian Brauner <brauner@kernel.org>
+ *
+ * Test that completing a filesystem context from another user namespace
+ * doesn't warn.
+ *
+ * fsopen() records the caller's user namespace in fc->user_ns and hands
+ * back an ordinary file descriptor. The task that issues
+ * FSCONFIG_CMD_CREATE need not be the one that created the context: the fd
+ * is inherited across fork() and exec() and it can be passed over a unix
+ * socket. vfs_cmd_create() authorizes the create with mount_capable(),
+ * which for FS_USERNS_MOUNT checks ns_capable(fc->user_ns, CAP_SYS_ADMIN),
+ * and that succeeds for a task holding CAP_SYS_ADMIN in an ancestor of
+ * fc->user_ns.
+ *
+ * binfmt_misc and overlayfs used to WARN_ON() that mismatch, which let an
+ * unprivileged user taint the kernel, flood the log and panic a kernel
+ * booted with panic_on_warn. The mount must still be refused, but it must
+ * not warn.
+ */
+#define _GNU_SOURCE
+
+#include <errno.h>
+#include <sched.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "../wrappers.h"
+#include "../utils.h"
+#include "../../kselftest_harness.h"
+
+#ifndef FSCONFIG_CMD_CREATE
+#define FSCONFIG_CMD_CREATE 6
+#endif
+
+/* TAINT_WARN, i.e. bit 9 of /proc/sys/kernel/tainted. */
+#define TAINT_WARN_BIT 9
+
+static bool taint_warn_set(void)
+{
+ unsigned long taint = 0;
+ FILE *f;
+
+ f = fopen("/proc/sys/kernel/tainted", "r");
+ if (!f)
+ return false;
+ if (fscanf(f, "%lu", &taint) != 1)
+ taint = 0;
+ fclose(f);
+
+ return taint & (1UL << TAINT_WARN_BIT);
+}
+
+static int send_fd(int sock, int fd)
+{
+ char cmsgbuf[CMSG_SPACE(sizeof(int))] = {};
+ char b[1] = { 'x' };
+ struct iovec iov = { .iov_base = b, .iov_len = sizeof(b) };
+ struct msghdr msg = {
+ .msg_iov = &iov,
+ .msg_iovlen = 1,
+ .msg_control = cmsgbuf,
+ .msg_controllen = sizeof(cmsgbuf),
+ };
+ struct cmsghdr *cmsg;
+
+ cmsg = CMSG_FIRSTHDR(&msg);
+ cmsg->cmsg_level = SOL_SOCKET;
+ cmsg->cmsg_type = SCM_RIGHTS;
+ cmsg->cmsg_len = CMSG_LEN(sizeof(int));
+ memcpy(CMSG_DATA(cmsg), &fd, sizeof(int));
+
+ return sendmsg(sock, &msg, 0) < 0 ? -1 : 0;
+}
+
+static int recv_fd(int sock)
+{
+ char cmsgbuf[CMSG_SPACE(sizeof(int))] = {};
+ char b[1];
+ struct iovec iov = { .iov_base = b, .iov_len = sizeof(b) };
+ struct msghdr msg = {
+ .msg_iov = &iov,
+ .msg_iovlen = 1,
+ .msg_control = cmsgbuf,
+ .msg_controllen = sizeof(cmsgbuf),
+ };
+ struct cmsghdr *cmsg;
+ int fd = -1;
+
+ if (recvmsg(sock, &msg, 0) <= 0)
+ return -1;
+
+ cmsg = CMSG_FIRSTHDR(&msg);
+ if (!cmsg || cmsg->cmsg_type != SCM_RIGHTS)
+ return -1;
+ memcpy(&fd, CMSG_DATA(cmsg), sizeof(int));
+
+ return fd;
+}
+
+/*
+ * Create a context for @fsname in a child and complete it here. With @nest
+ * the child first creates its own user namespace, so that the context is
+ * created in a descendant of the namespace completing it. The child needs a
+ * mount namespace of its own as well: fsopen() gates on may_mount(), which
+ * asks for CAP_SYS_ADMIN in the user namespace owning the caller's mount
+ * namespace.
+ *
+ * Returns the result of FSCONFIG_CMD_CREATE with errno set, or -ENODATA if
+ * the child could not create the context at all.
+ */
+static int create_from_child(const char *fsname, bool nest)
+{
+ int sock[2], fd, ret, status;
+ pid_t pid;
+
+ if (socketpair(AF_UNIX, SOCK_STREAM, 0, sock))
+ return -ENODATA;
+
+ pid = fork();
+ if (pid < 0) {
+ close(sock[0]);
+ close(sock[1]);
+ return -ENODATA;
+ }
+
+ if (pid == 0) {
+ close(sock[0]);
+
+ if (nest && unshare(CLONE_NEWUSER | CLONE_NEWNS))
+ _exit(1);
+
+ fd = sys_fsopen(fsname, 0);
+ if (fd < 0)
+ _exit(1);
+ if (send_fd(sock[1], fd))
+ _exit(1);
+ _exit(0);
+ }
+
+ close(sock[1]);
+ fd = recv_fd(sock[0]);
+ close(sock[0]);
+ wait_for_pid(pid);
+ waitpid(pid, &status, WNOHANG);
+
+ if (fd < 0)
+ return -ENODATA;
+
+ errno = 0;
+ ret = sys_fsconfig(fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0);
+ status = errno;
+ close(fd);
+ errno = status;
+
+ return ret;
+}
+
+FIXTURE(fscontext_ns) {
+ bool warn_before;
+};
+
+FIXTURE_SETUP(fscontext_ns)
+{
+ self->warn_before = taint_warn_set();
+
+ if (setup_userns() != 0)
+ SKIP(return, "setup_userns failed");
+}
+
+FIXTURE_TEARDOWN(fscontext_ns)
+{
+}
+
+/*
+ * The condition the kernel used to WARN about. It has to be refused, and it
+ * has to be refused quietly: an unprivileged task reaches this.
+ */
+FIXTURE_VARIANT(fscontext_ns) {
+ const char *fsname;
+ int expected_errno;
+};
+
+FIXTURE_VARIANT_ADD(fscontext_ns, binfmt_misc) {
+ .fsname = "binfmt_misc",
+ .expected_errno = EINVAL,
+};
+
+FIXTURE_VARIANT_ADD(fscontext_ns, overlay) {
+ .fsname = "overlay",
+ .expected_errno = EIO,
+};
+
+TEST_F(fscontext_ns, create_from_descendant_userns)
+{
+ int ret;
+
+ ret = create_from_child(variant->fsname, true);
+ if (ret == -ENODATA)
+ SKIP(return, "%s unavailable", variant->fsname);
+
+ ASSERT_EQ(-1, ret);
+ ASSERT_EQ(variant->expected_errno, errno);
+
+ /*
+ * Only meaningful if nothing had warned before us. Note that an
+ * unrelated warning racing this test would look like a failure.
+ */
+ if (self->warn_before)
+ TH_LOG("TAINT_WARN already set, not checking for a new warning");
+ else
+ ASSERT_FALSE(taint_warn_set());
+}
+
+/*
+ * The same handover within one user namespace is a supported thing to do and
+ * has to keep working. binfmt_misc takes no options, so the create succeeds
+ * outright and this also shows the test really drives the create path.
+ */
+TEST(create_from_same_userns)
+{
+ int ret;
+
+ if (setup_userns() != 0)
+ SKIP(return, "setup_userns failed");
+
+ ret = create_from_child("binfmt_misc", false);
+ if (ret == -ENODATA)
+ SKIP(return, "binfmt_misc unavailable");
+
+ ASSERT_EQ(0, ret);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/fsmount_ns/.gitignore b/tools/testing/selftests/filesystems/fsmount_ns/.gitignore
new file mode 100644
index 000000000000..f1ecf6c6e37b
--- /dev/null
+++ b/tools/testing/selftests/filesystems/fsmount_ns/.gitignore
@@ -0,0 +1 @@
+fsmount_ns_test
diff --git a/tools/testing/selftests/filesystems/fsmount_ns/Makefile b/tools/testing/selftests/filesystems/fsmount_ns/Makefile
new file mode 100644
index 000000000000..d9647efc0739
--- /dev/null
+++ b/tools/testing/selftests/filesystems/fsmount_ns/Makefile
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: GPL-2.0
+TEST_GEN_PROGS := fsmount_ns_test
+
+CFLAGS += -Wall -O0 -g $(KHDR_INCLUDES) $(TOOLS_INCLUDES)
+LDLIBS := -lcap
+
+include ../../lib.mk
+
+$(OUTPUT)/fsmount_ns_test: fsmount_ns_test.c ../utils.c
+ $(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)
diff --git a/tools/testing/selftests/filesystems/fsmount_ns/fsmount_ns_test.c b/tools/testing/selftests/filesystems/fsmount_ns/fsmount_ns_test.c
new file mode 100644
index 000000000000..b70b3051eed4
--- /dev/null
+++ b/tools/testing/selftests/filesystems/fsmount_ns/fsmount_ns_test.c
@@ -0,0 +1,1135 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (C) 2026 Christian Brauner <brauner@kernel.org>
+ *
+ * Test for FSMOUNT_NAMESPACE flag.
+ *
+ * Test that fsmount() with FSMOUNT_NAMESPACE creates a new mount
+ * namespace containing the specified mount.
+ */
+#define _GNU_SOURCE
+
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <linux/nsfs.h>
+#include <sched.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/mount.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "../wrappers.h"
+#include "../statmount/statmount.h"
+#include "../utils.h"
+#include "../../kselftest_harness.h"
+
+#ifndef FSMOUNT_NAMESPACE
+#define FSMOUNT_NAMESPACE 0x00000002
+#endif
+
+#ifndef FSMOUNT_CLOEXEC
+#define FSMOUNT_CLOEXEC 0x00000001
+#endif
+
+#ifndef FSCONFIG_CMD_CREATE
+#define FSCONFIG_CMD_CREATE 6
+#endif
+
+static int get_mnt_ns_id(int fd, uint64_t *mnt_ns_id)
+{
+ if (ioctl(fd, NS_GET_MNTNS_ID, mnt_ns_id) < 0)
+ return -errno;
+ return 0;
+}
+
+static int get_mnt_ns_id_from_path(const char *path, uint64_t *mnt_ns_id)
+{
+ int fd, ret;
+
+ fd = open(path, O_RDONLY);
+ if (fd < 0)
+ return -errno;
+
+ ret = get_mnt_ns_id(fd, mnt_ns_id);
+ close(fd);
+ return ret;
+}
+
+static void log_mount(struct __test_metadata *_metadata, struct statmount *sm)
+{
+ const char *fs_type = "";
+ const char *mnt_root = "";
+ const char *mnt_point = "";
+
+ if (sm->mask & STATMOUNT_FS_TYPE)
+ fs_type = sm->str + sm->fs_type;
+ if (sm->mask & STATMOUNT_MNT_ROOT)
+ mnt_root = sm->str + sm->mnt_root;
+ if (sm->mask & STATMOUNT_MNT_POINT)
+ mnt_point = sm->str + sm->mnt_point;
+
+ TH_LOG(" mnt_id: %llu, parent_id: %llu, fs_type: %s, root: %s, point: %s",
+ (unsigned long long)sm->mnt_id,
+ (unsigned long long)sm->mnt_parent_id,
+ fs_type, mnt_root, mnt_point);
+}
+
+static void dump_mounts(struct __test_metadata *_metadata, uint64_t mnt_ns_id)
+{
+ uint64_t list[256];
+ ssize_t nr_mounts;
+
+ nr_mounts = listmount(LSMT_ROOT, mnt_ns_id, 0, list, 256, 0);
+ if (nr_mounts < 0) {
+ TH_LOG("listmount failed: %s", strerror(errno));
+ return;
+ }
+
+ TH_LOG("Mount namespace %llu contains %zd mount(s):",
+ (unsigned long long)mnt_ns_id, nr_mounts);
+
+ for (ssize_t i = 0; i < nr_mounts; i++) {
+ struct statmount *sm;
+
+ sm = statmount_alloc(list[i], mnt_ns_id,
+ STATMOUNT_MNT_BASIC |
+ STATMOUNT_FS_TYPE |
+ STATMOUNT_MNT_ROOT |
+ STATMOUNT_MNT_POINT, 0);
+ if (!sm) {
+ TH_LOG(" [%zd] mnt_id %llu: statmount failed: %s",
+ i, (unsigned long long)list[i], strerror(errno));
+ continue;
+ }
+
+ log_mount(_metadata, sm);
+ free(sm);
+ }
+}
+
+static int create_tmpfs_fd(void)
+{
+ int fs_fd, ret;
+
+ fs_fd = sys_fsopen("tmpfs", FSOPEN_CLOEXEC);
+ if (fs_fd < 0)
+ return -errno;
+
+ ret = sys_fsconfig(fs_fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0);
+ if (ret < 0) {
+ close(fs_fd);
+ return -errno;
+ }
+
+ return fs_fd;
+}
+
+FIXTURE(fsmount_ns)
+{
+ int fd;
+ int fs_fd;
+ uint64_t current_ns_id;
+};
+
+FIXTURE_VARIANT(fsmount_ns)
+{
+ const char *fstype;
+ unsigned int flags;
+ bool expect_success;
+ bool expect_different_ns;
+ int min_mounts;
+};
+
+FIXTURE_VARIANT_ADD(fsmount_ns, basic_tmpfs)
+{
+ .fstype = "tmpfs",
+ .flags = FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC,
+ .expect_success = true,
+ .expect_different_ns = true,
+ .min_mounts = 1,
+};
+
+FIXTURE_VARIANT_ADD(fsmount_ns, cloexec_only)
+{
+ .fstype = "tmpfs",
+ .flags = FSMOUNT_CLOEXEC,
+ .expect_success = true,
+ .expect_different_ns = false,
+ .min_mounts = 1,
+};
+
+FIXTURE_VARIANT_ADD(fsmount_ns, namespace_only)
+{
+ .fstype = "tmpfs",
+ .flags = FSMOUNT_NAMESPACE,
+ .expect_success = true,
+ .expect_different_ns = true,
+ .min_mounts = 1,
+};
+
+FIXTURE_SETUP(fsmount_ns)
+{
+ int ret;
+
+ self->fd = -1;
+ self->fs_fd = -1;
+
+ /* Check if fsopen syscall is supported */
+ ret = sys_fsopen("tmpfs", 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "fsopen() syscall not supported");
+ if (ret >= 0)
+ close(ret);
+
+ /* Check if statmount/listmount are supported */
+ ret = statmount(0, 0, 0, 0, NULL, 0, 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "statmount() syscall not supported");
+
+ /* Get current mount namespace ID for comparison */
+ ret = get_mnt_ns_id_from_path("/proc/self/ns/mnt", &self->current_ns_id);
+ if (ret < 0)
+ SKIP(return, "Failed to get current mount namespace ID");
+}
+
+FIXTURE_TEARDOWN(fsmount_ns)
+{
+ if (self->fd >= 0)
+ close(self->fd);
+ if (self->fs_fd >= 0)
+ close(self->fs_fd);
+}
+
+TEST_F(fsmount_ns, create_namespace)
+{
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int ret;
+
+ self->fs_fd = create_tmpfs_fd();
+ ASSERT_GE(self->fs_fd, 0);
+
+ self->fd = sys_fsmount(self->fs_fd, variant->flags, 0);
+
+ if (!variant->expect_success) {
+ ASSERT_LT(self->fd, 0);
+ return;
+ }
+
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ if (variant->expect_different_ns) {
+ /* Verify we can get the namespace ID from the fd */
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ /* Verify it's a different namespace */
+ ASSERT_NE(new_ns_id, self->current_ns_id);
+
+ /* List mounts in the new namespace */
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 0) {
+ TH_LOG("%m - listmount failed");
+ }
+
+ /* Verify minimum expected mounts */
+ ASSERT_GE(nr_mounts, variant->min_mounts);
+ TH_LOG("Namespace contains %zd mounts", nr_mounts);
+ }
+}
+
+TEST_F(fsmount_ns, setns_into_namespace)
+{
+ uint64_t new_ns_id;
+ pid_t pid;
+ int status;
+ int ret;
+
+ /* Only test with FSMOUNT_NAMESPACE flag */
+ if (!(variant->flags & FSMOUNT_NAMESPACE))
+ SKIP(return, "setns test only for FSMOUNT_NAMESPACE case");
+
+ self->fs_fd = create_tmpfs_fd();
+ ASSERT_GE(self->fs_fd, 0);
+
+ self->fd = sys_fsmount(self->fs_fd, variant->flags, 0);
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ /* Get namespace ID and dump all mounts */
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ dump_mounts(_metadata, new_ns_id);
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ /* Child: try to enter the namespace */
+ if (setns(self->fd, CLONE_NEWNS) < 0)
+ _exit(1);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+ ASSERT_EQ(WEXITSTATUS(status), 0);
+}
+
+TEST_F(fsmount_ns, verify_mount_properties)
+{
+ struct statmount sm;
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int ret;
+
+ /* Only test with basic FSMOUNT_NAMESPACE flags */
+ if (variant->flags != (FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC))
+ SKIP(return, "mount properties test only for basic case");
+
+ self->fs_fd = create_tmpfs_fd();
+ ASSERT_GE(self->fs_fd, 0);
+
+ self->fd = sys_fsmount(self->fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC, 0);
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 1);
+
+ /* Get info about the root mount */
+ ret = statmount(list[0], new_ns_id, 0, STATMOUNT_MNT_BASIC, &sm, sizeof(sm), 0);
+ ASSERT_EQ(ret, 0);
+
+ TH_LOG("Root mount id: %llu, parent: %llu",
+ (unsigned long long)sm.mnt_id,
+ (unsigned long long)sm.mnt_parent_id);
+}
+
+TEST_F(fsmount_ns, verify_tmpfs_type)
+{
+ struct statmount *sm;
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ const char *fs_type;
+ int ret;
+
+ /* Only test with basic FSMOUNT_NAMESPACE flags */
+ if (variant->flags != (FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC))
+ SKIP(return, "fs type test only for basic case");
+
+ self->fs_fd = create_tmpfs_fd();
+ ASSERT_GE(self->fs_fd, 0);
+
+ self->fd = sys_fsmount(self->fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC, 0);
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 1);
+
+ sm = statmount_alloc(list[0], new_ns_id, STATMOUNT_FS_TYPE, 0);
+ ASSERT_NE(sm, NULL);
+
+ fs_type = sm->str + sm->fs_type;
+ ASSERT_STREQ(fs_type, "tmpfs");
+
+ free(sm);
+}
+
+FIXTURE(fsmount_ns_caps)
+{
+ bool has_caps;
+};
+
+FIXTURE_SETUP(fsmount_ns_caps)
+{
+ int ret;
+
+ /* Check if fsopen syscall is supported */
+ ret = sys_fsopen("tmpfs", 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "fsopen() syscall not supported");
+ if (ret >= 0)
+ close(ret);
+
+ self->has_caps = (geteuid() == 0);
+}
+
+FIXTURE_TEARDOWN(fsmount_ns_caps)
+{
+}
+
+TEST_F(fsmount_ns_caps, requires_cap_sys_admin)
+{
+ pid_t pid;
+ int status;
+ int fs_fd;
+
+ /*
+ * Prepare the configured filesystem fd as root before forking.
+ * fsopen() requires CAP_SYS_ADMIN in the mount namespace's
+ * user_ns, which won't be available after enter_userns().
+ */
+ fs_fd = sys_fsopen("tmpfs", FSOPEN_CLOEXEC);
+ ASSERT_GE(fs_fd, 0);
+
+ ASSERT_EQ(sys_fsconfig(fs_fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0), 0);
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ int fd;
+
+ /* Child: drop privileges using utils.h helper */
+ if (enter_userns() != 0)
+ _exit(2);
+
+ /* Drop all caps using utils.h helper */
+ if (caps_down() == 0)
+ _exit(3);
+
+ fd = sys_fsmount(fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC, 0);
+ close(fs_fd);
+
+ if (fd >= 0) {
+ close(fd);
+ /* Should have failed without caps */
+ _exit(1);
+ }
+
+ if (errno == EPERM)
+ _exit(0);
+
+ /* EINVAL means FSMOUNT_NAMESPACE not supported */
+ if (errno == EINVAL)
+ _exit(6);
+
+ /* Unexpected error */
+ _exit(7);
+ }
+
+ close(fs_fd);
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ /* Expected: EPERM without caps */
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("FSMOUNT_NAMESPACE succeeded without caps");
+ break;
+ case 2:
+ SKIP(return, "enter_userns failed");
+ break;
+ case 3:
+ SKIP(return, "caps_down failed");
+ break;
+ case 6:
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+FIXTURE(fsmount_ns_userns)
+{
+ int fd;
+ int fs_fd;
+};
+
+FIXTURE_SETUP(fsmount_ns_userns)
+{
+ int ret;
+
+ self->fd = -1;
+ self->fs_fd = -1;
+
+ /* Check if fsopen syscall is supported */
+ ret = sys_fsopen("tmpfs", 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "fsopen() syscall not supported");
+ if (ret >= 0)
+ close(ret);
+
+ /* Check if statmount/listmount are supported */
+ ret = statmount(0, 0, 0, 0, NULL, 0, 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "statmount() syscall not supported");
+}
+
+FIXTURE_TEARDOWN(fsmount_ns_userns)
+{
+ if (self->fd >= 0)
+ close(self->fd);
+ if (self->fs_fd >= 0)
+ close(self->fs_fd);
+}
+
+TEST_F(fsmount_ns_userns, create_in_userns)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int fs_fd, fd;
+
+ /* Create new user namespace (also creates mount namespace) */
+ if (setup_userns() != 0)
+ _exit(2);
+
+ /* Now we have CAP_SYS_ADMIN in the user namespace */
+ fs_fd = sys_fsopen("tmpfs", FSOPEN_CLOEXEC);
+ if (fs_fd < 0)
+ _exit(3);
+
+ if (sys_fsconfig(fs_fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0) < 0) {
+ close(fs_fd);
+ _exit(4);
+ }
+
+ fd = sys_fsmount(fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC, 0);
+ close(fs_fd);
+
+ if (fd < 0) {
+ if (errno == EINVAL)
+ _exit(6); /* FSMOUNT_NAMESPACE not supported */
+ _exit(1);
+ }
+
+ /* Verify we can get the namespace ID */
+ if (get_mnt_ns_id(fd, &new_ns_id) != 0)
+ _exit(7);
+
+ /* Verify we can list mounts in the new namespace */
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ if (nr_mounts < 0)
+ _exit(8);
+
+ /* Should have at least 1 mount (the tmpfs) */
+ if (nr_mounts < 1)
+ _exit(9);
+
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ /* Success */
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("fsmount(FSMOUNT_NAMESPACE) failed in userns");
+ break;
+ case 2:
+ SKIP(return, "setup_userns failed");
+ break;
+ case 3:
+ SKIP(return, "fsopen failed in userns");
+ break;
+ case 4:
+ SKIP(return, "fsconfig CMD_CREATE failed in userns");
+ break;
+ case 6:
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+ break;
+ case 7:
+ ASSERT_FALSE(true) TH_LOG("Failed to get mount namespace ID");
+ break;
+ case 8:
+ ASSERT_FALSE(true) TH_LOG("listmount failed in new namespace");
+ break;
+ case 9:
+ ASSERT_FALSE(true) TH_LOG("New namespace has no mounts");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+TEST_F(fsmount_ns_userns, setns_in_userns)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t new_ns_id;
+ int fs_fd, fd;
+ pid_t inner_pid;
+ int inner_status;
+
+ /* Create new user namespace */
+ if (setup_userns() != 0)
+ _exit(2);
+
+ fs_fd = sys_fsopen("tmpfs", FSOPEN_CLOEXEC);
+ if (fs_fd < 0)
+ _exit(3);
+
+ if (sys_fsconfig(fs_fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0) < 0) {
+ close(fs_fd);
+ _exit(4);
+ }
+
+ fd = sys_fsmount(fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC, 0);
+ close(fs_fd);
+
+ if (fd < 0) {
+ if (errno == EINVAL)
+ _exit(6);
+ _exit(1);
+ }
+
+ if (get_mnt_ns_id(fd, &new_ns_id) != 0)
+ _exit(7);
+
+ /* Fork again to test setns into the new namespace */
+ inner_pid = fork();
+ if (inner_pid < 0)
+ _exit(10);
+
+ if (inner_pid == 0) {
+ /* Inner child: enter the new namespace */
+ if (setns(fd, CLONE_NEWNS) < 0)
+ _exit(1);
+ _exit(0);
+ }
+
+ if (waitpid(inner_pid, &inner_status, 0) != inner_pid)
+ _exit(11);
+
+ if (!WIFEXITED(inner_status) || WEXITSTATUS(inner_status) != 0)
+ _exit(12);
+
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ /* Success */
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("fsmount or setns failed in userns");
+ break;
+ case 2:
+ SKIP(return, "setup_userns failed");
+ break;
+ case 3:
+ SKIP(return, "fsopen failed in userns");
+ break;
+ case 4:
+ SKIP(return, "fsconfig CMD_CREATE failed in userns");
+ break;
+ case 6:
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+ break;
+ case 7:
+ ASSERT_FALSE(true) TH_LOG("Failed to get mount namespace ID");
+ break;
+ case 10:
+ ASSERT_FALSE(true) TH_LOG("Inner fork failed");
+ break;
+ case 11:
+ ASSERT_FALSE(true) TH_LOG("Inner waitpid failed");
+ break;
+ case 12:
+ ASSERT_FALSE(true) TH_LOG("setns into new namespace failed");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+TEST_F(fsmount_ns_userns, umount_fails_einval)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int fs_fd, fd;
+ ssize_t i;
+
+ /* Create new user namespace */
+ if (setup_userns() != 0)
+ _exit(2);
+
+ fs_fd = sys_fsopen("tmpfs", FSOPEN_CLOEXEC);
+ if (fs_fd < 0)
+ _exit(3);
+
+ if (sys_fsconfig(fs_fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0) < 0) {
+ close(fs_fd);
+ _exit(4);
+ }
+
+ fd = sys_fsmount(fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC, 0);
+ close(fs_fd);
+
+ if (fd < 0) {
+ if (errno == EINVAL)
+ _exit(6);
+ _exit(1);
+ }
+
+ if (get_mnt_ns_id(fd, &new_ns_id) != 0)
+ _exit(7);
+
+ /* Get all mounts in the new namespace */
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, LISTMOUNT_REVERSE);
+ if (nr_mounts < 0)
+ _exit(13);
+
+ if (nr_mounts < 1)
+ _exit(14);
+
+ /* Enter the new namespace */
+ if (setns(fd, CLONE_NEWNS) < 0)
+ _exit(8);
+
+ for (i = 0; i < nr_mounts; i++) {
+ struct statmount *sm;
+ const char *mnt_point;
+
+ sm = statmount_alloc(list[i], new_ns_id,
+ STATMOUNT_MNT_POINT, 0);
+ if (!sm)
+ _exit(15);
+
+ mnt_point = sm->str + sm->mnt_point;
+
+ if (umount2(mnt_point, MNT_DETACH) == 0) {
+ free(sm);
+ _exit(9);
+ }
+
+ if (errno != EINVAL) {
+ /* Wrong error */
+ free(sm);
+ _exit(10);
+ }
+
+ free(sm);
+ }
+
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("fsmount(FSMOUNT_NAMESPACE) failed");
+ break;
+ case 2:
+ SKIP(return, "setup_userns failed");
+ break;
+ case 3:
+ SKIP(return, "fsopen failed in userns");
+ break;
+ case 4:
+ SKIP(return, "fsconfig CMD_CREATE failed in userns");
+ break;
+ case 6:
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+ break;
+ case 7:
+ ASSERT_FALSE(true) TH_LOG("Failed to get mount namespace ID");
+ break;
+ case 8:
+ ASSERT_FALSE(true) TH_LOG("setns into new namespace failed");
+ break;
+ case 9:
+ ASSERT_FALSE(true) TH_LOG("umount succeeded but should have failed with EINVAL");
+ break;
+ case 10:
+ ASSERT_FALSE(true) TH_LOG("umount failed with wrong error (expected EINVAL)");
+ break;
+ case 13:
+ ASSERT_FALSE(true) TH_LOG("listmount failed");
+ break;
+ case 14:
+ ASSERT_FALSE(true) TH_LOG("No mounts in new namespace");
+ break;
+ case 15:
+ ASSERT_FALSE(true) TH_LOG("statmount_alloc failed");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+TEST_F(fsmount_ns_userns, umount_succeeds)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int fs_fd, fd;
+ ssize_t i;
+
+ if (unshare(CLONE_NEWNS))
+ _exit(1);
+
+ if (sys_mount(NULL, "/", NULL, MS_SLAVE | MS_REC, NULL) != 0)
+ _exit(1);
+
+ fs_fd = sys_fsopen("tmpfs", FSOPEN_CLOEXEC);
+ if (fs_fd < 0)
+ _exit(3);
+
+ if (sys_fsconfig(fs_fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0) < 0) {
+ close(fs_fd);
+ _exit(4);
+ }
+
+ fd = sys_fsmount(fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC, 0);
+ close(fs_fd);
+
+ if (fd < 0) {
+ if (errno == EINVAL)
+ _exit(6);
+ _exit(1);
+ }
+
+ if (get_mnt_ns_id(fd, &new_ns_id) != 0)
+ _exit(7);
+
+ /* Get all mounts in the new namespace */
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, LISTMOUNT_REVERSE);
+ if (nr_mounts < 0)
+ _exit(13);
+
+ if (nr_mounts < 1)
+ _exit(14);
+
+ /* Enter the new namespace */
+ if (setns(fd, CLONE_NEWNS) < 0)
+ _exit(8);
+
+ for (i = 0; i < nr_mounts; i++) {
+ struct statmount *sm;
+ const char *mnt_point;
+
+ sm = statmount_alloc(list[i], new_ns_id,
+ STATMOUNT_MNT_POINT, 0);
+ if (!sm)
+ _exit(15);
+
+ mnt_point = sm->str + sm->mnt_point;
+
+ if (umount2(mnt_point, MNT_DETACH) != 0) {
+ free(sm);
+ _exit(9);
+ }
+
+ free(sm);
+ }
+
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("fsmount(FSMOUNT_NAMESPACE) failed or unshare failed");
+ break;
+ case 3:
+ SKIP(return, "fsopen failed");
+ break;
+ case 4:
+ SKIP(return, "fsconfig CMD_CREATE failed");
+ break;
+ case 6:
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+ break;
+ case 7:
+ ASSERT_FALSE(true) TH_LOG("Failed to get mount namespace ID");
+ break;
+ case 8:
+ ASSERT_FALSE(true) TH_LOG("setns into new namespace failed");
+ break;
+ case 9:
+ ASSERT_FALSE(true) TH_LOG("umount failed but should have succeeded");
+ break;
+ case 13:
+ ASSERT_FALSE(true) TH_LOG("listmount failed");
+ break;
+ case 14:
+ ASSERT_FALSE(true) TH_LOG("No mounts in new namespace");
+ break;
+ case 15:
+ ASSERT_FALSE(true) TH_LOG("statmount_alloc failed");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+FIXTURE(fsmount_ns_mount_attrs)
+{
+ int fd;
+ int fs_fd;
+};
+
+FIXTURE_SETUP(fsmount_ns_mount_attrs)
+{
+ int ret;
+
+ self->fd = -1;
+ self->fs_fd = -1;
+
+ /* Check if fsopen syscall is supported */
+ ret = sys_fsopen("tmpfs", 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "fsopen() syscall not supported");
+ if (ret >= 0)
+ close(ret);
+
+ /* Check if statmount/listmount are supported */
+ ret = statmount(0, 0, 0, 0, NULL, 0, 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "statmount() syscall not supported");
+}
+
+FIXTURE_TEARDOWN(fsmount_ns_mount_attrs)
+{
+ if (self->fd >= 0)
+ close(self->fd);
+ if (self->fs_fd >= 0)
+ close(self->fs_fd);
+}
+
+TEST_F(fsmount_ns_mount_attrs, readonly)
+{
+ struct statmount sm;
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int ret;
+
+ self->fs_fd = create_tmpfs_fd();
+ ASSERT_GE(self->fs_fd, 0);
+
+ self->fd = sys_fsmount(self->fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC,
+ MOUNT_ATTR_RDONLY);
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 1);
+
+ ret = statmount(list[0], new_ns_id, 0, STATMOUNT_MNT_BASIC, &sm, sizeof(sm), 0);
+ ASSERT_EQ(ret, 0);
+
+ /* Verify the mount is read-only */
+ ASSERT_TRUE(sm.mnt_attr & MOUNT_ATTR_RDONLY);
+}
+
+TEST_F(fsmount_ns_mount_attrs, noexec)
+{
+ struct statmount sm;
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int ret;
+
+ self->fs_fd = create_tmpfs_fd();
+ ASSERT_GE(self->fs_fd, 0);
+
+ self->fd = sys_fsmount(self->fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC,
+ MOUNT_ATTR_NOEXEC);
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 1);
+
+ ret = statmount(list[0], new_ns_id, 0, STATMOUNT_MNT_BASIC, &sm, sizeof(sm), 0);
+ ASSERT_EQ(ret, 0);
+
+ /* Verify the mount is noexec */
+ ASSERT_TRUE(sm.mnt_attr & MOUNT_ATTR_NOEXEC);
+}
+
+TEST_F(fsmount_ns_mount_attrs, nosuid)
+{
+ struct statmount sm;
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int ret;
+
+ self->fs_fd = create_tmpfs_fd();
+ ASSERT_GE(self->fs_fd, 0);
+
+ self->fd = sys_fsmount(self->fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC,
+ MOUNT_ATTR_NOSUID);
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 1);
+
+ ret = statmount(list[0], new_ns_id, 0, STATMOUNT_MNT_BASIC, &sm, sizeof(sm), 0);
+ ASSERT_EQ(ret, 0);
+
+ /* Verify the mount is nosuid */
+ ASSERT_TRUE(sm.mnt_attr & MOUNT_ATTR_NOSUID);
+}
+
+TEST_F(fsmount_ns_mount_attrs, noatime)
+{
+ struct statmount sm;
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int ret;
+
+ self->fs_fd = create_tmpfs_fd();
+ ASSERT_GE(self->fs_fd, 0);
+
+ self->fd = sys_fsmount(self->fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC,
+ MOUNT_ATTR_NOATIME);
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 1);
+
+ ret = statmount(list[0], new_ns_id, 0, STATMOUNT_MNT_BASIC, &sm, sizeof(sm), 0);
+ ASSERT_EQ(ret, 0);
+
+ /* Verify the mount is noatime */
+ ASSERT_TRUE(sm.mnt_attr & MOUNT_ATTR_NOATIME);
+}
+
+TEST_F(fsmount_ns_mount_attrs, combined)
+{
+ struct statmount sm;
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int ret;
+
+ self->fs_fd = create_tmpfs_fd();
+ ASSERT_GE(self->fs_fd, 0);
+
+ self->fd = sys_fsmount(self->fs_fd, FSMOUNT_NAMESPACE | FSMOUNT_CLOEXEC,
+ MOUNT_ATTR_RDONLY | MOUNT_ATTR_NOEXEC |
+ MOUNT_ATTR_NOSUID | MOUNT_ATTR_NOATIME);
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "FSMOUNT_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 1);
+
+ ret = statmount(list[0], new_ns_id, 0, STATMOUNT_MNT_BASIC, &sm, sizeof(sm), 0);
+ ASSERT_EQ(ret, 0);
+
+ /* Verify all attributes are set */
+ ASSERT_TRUE(sm.mnt_attr & MOUNT_ATTR_RDONLY);
+ ASSERT_TRUE(sm.mnt_attr & MOUNT_ATTR_NOEXEC);
+ ASSERT_TRUE(sm.mnt_attr & MOUNT_ATTR_NOSUID);
+ ASSERT_TRUE(sm.mnt_attr & MOUNT_ATTR_NOATIME);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/fuse/.gitignore b/tools/testing/selftests/filesystems/fuse/.gitignore
index 3e72e742d08e..fb51603fe419 100644
--- a/tools/testing/selftests/filesystems/fuse/.gitignore
+++ b/tools/testing/selftests/filesystems/fuse/.gitignore
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-2.0-only
fuse_mnt
fusectl_test
+write_extend_eof_test
diff --git a/tools/testing/selftests/filesystems/fuse/Makefile b/tools/testing/selftests/filesystems/fuse/Makefile
index 612aad69a93a..95a1ee947ca7 100644
--- a/tools/testing/selftests/filesystems/fuse/Makefile
+++ b/tools/testing/selftests/filesystems/fuse/Makefile
@@ -3,10 +3,20 @@
CFLAGS += -Wall -O2 -g $(KHDR_INCLUDES)
TEST_GEN_PROGS := fusectl_test
+TEST_GEN_PROGS += write_extend_eof_test
TEST_GEN_FILES := fuse_mnt
+# fuse_acl_cache_test requires libfuse3; add it only when the library is present.
+ACL_CFLAGS := $(shell pkg-config fuse3 --cflags 2>/dev/null)
+ACL_LDLIBS := $(shell pkg-config fuse3 --libs 2>/dev/null)
+ifneq ($(ACL_CFLAGS),)
+TEST_GEN_PROGS += fuse_acl_cache_test
+endif
+
include ../../lib.mk
+$(OUTPUT)/write_extend_eof_test: LDLIBS += -lpthread
+
VAR_CFLAGS := $(shell pkg-config fuse --cflags 2>/dev/null)
ifeq ($(VAR_CFLAGS),)
VAR_CFLAGS := -D_FILE_OFFSET_BITS=64 -I/usr/include/fuse
@@ -19,3 +29,6 @@ endif
$(OUTPUT)/fuse_mnt: CFLAGS += $(VAR_CFLAGS)
$(OUTPUT)/fuse_mnt: LDLIBS += $(VAR_LDLIBS)
+
+$(OUTPUT)/fuse_acl_cache_test: CFLAGS += $(ACL_CFLAGS)
+$(OUTPUT)/fuse_acl_cache_test: LDLIBS += $(ACL_LDLIBS)
diff --git a/tools/testing/selftests/filesystems/fuse/fuse_acl_cache_test.c b/tools/testing/selftests/filesystems/fuse/fuse_acl_cache_test.c
new file mode 100644
index 000000000000..2411a6e285f1
--- /dev/null
+++ b/tools/testing/selftests/filesystems/fuse/fuse_acl_cache_test.c
@@ -0,0 +1,347 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Test: FUSE ACL caching bug triggered by AT_STATX_FORCE_SYNC
+ *
+ * A FUSE mount that does not negotiate FUSE_POSIX_ACL initialises every inode
+ * with i_acl = i_default_acl = ACL_DONT_CACHE. When a fresh stat is needed
+ * (e.g. AT_STATX_FORCE_SYNC), fuse_update_get_attr() calls
+ * forget_all_cached_acls() before issuing FUSE_GETATTR. On an unfixed kernel,
+ * __forget_cached_acl() replaces ACL_DONT_CACHE with ACL_NOT_CACHED,
+ * inadvertently enabling the kernel ACL cache for that inode. The next
+ * getxattr populates the cache. Because fuse_set_acl() skips
+ * forget_all_cached_acls() for !fc->posix_acl mounts, any subsequent change to
+ * the ACL leaves the stale kernel entry in place, and the next getxattr returns
+ * wrong data without ever reaching the FUSE daemon.
+ *
+ * Fix (fs/posix_acl.c): __forget_cached_acl() returns early when *p is
+ * ACL_DONT_CACHE, preserving the "never cache" invariant for the inode's
+ * lifetime.
+ *
+ * Test outline:
+ * 1. Mount a minimal FUSE fs (no FUSE_POSIX_ACL negotiated).
+ * 2. lgetxattr -> daemon called, ACL_A returned, NOT cached (ACL_DONT_CACHE).
+ * 3. statx(AT_STATX_FORCE_SYNC) -> forget_all_cached_acls() called.
+ * Buggy: ACL_DONT_CACHE -> ACL_NOT_CACHED (cache enabled).
+ * Fixed: ACL_DONT_CACHE preserved.
+ * 4. lgetxattr -> daemon called, ACL_A returned.
+ * Buggy: result now cached (ACL_NOT_CACHED -> cached).
+ * Fixed: result still not cached.
+ * 5. Daemon switches to ACL_B internally (different size).
+ * 6. lgetxattr -> should return ACL_B (44 bytes).
+ * Buggy: cache hit, returns stale ACL_A (28 bytes). FAIL.
+ * Fixed: no cache, daemon called, returns ACL_B (44 bytes). PASS.
+ */
+
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <linux/limits.h>
+#include <pthread.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/xattr.h>
+#include <unistd.h>
+
+#define FUSE_USE_VERSION 31
+#include <fuse_lowlevel.h>
+
+#include "kselftest_harness.h"
+
+/* ---- ACL binary encoding ------------------------------------------------ */
+/*
+ * POSIX ACL v2 xattr format (little-endian):
+ * header: u32 version (= 0x00000002)
+ * entry: u16 tag | u16 perm | u32 id
+ *
+ * Entries must appear in tag-ascending order; named USER/GROUP entries
+ * require a MASK entry. Both ACLs pass posix_acl_from_xattr() validation.
+ */
+
+/* ACL_A: 3 entries (USER_OBJ:rwx, GROUP_OBJ:r-x, OTHER:r-x) = 28 bytes */
+static const uint8_t acl_a[] = {
+ 0x02, 0x00, 0x00, 0x00, /* v2 header */
+ 0x01, 0x00, 0x07, 0x00, 0xff, 0xff, 0xff, 0xff, /* USER_OBJ rwx */
+ 0x04, 0x00, 0x05, 0x00, 0xff, 0xff, 0xff, 0xff, /* GROUP_OBJ r-x */
+ 0x20, 0x00, 0x05, 0x00, 0xff, 0xff, 0xff, 0xff, /* OTHER r-x */
+};
+
+/*
+ * ACL_B: 5 entries — adds USER uid=1 and MASK = 44 bytes.
+ * A named USER entry requires a MASK; all tags in ascending order.
+ */
+static const uint8_t acl_b[] = {
+ 0x02, 0x00, 0x00, 0x00, /* v2 header */
+ 0x01, 0x00, 0x07, 0x00, 0xff, 0xff, 0xff, 0xff, /* USER_OBJ rwx */
+ 0x02, 0x00, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, /* USER uid=1 rwx */
+ 0x04, 0x00, 0x05, 0x00, 0xff, 0xff, 0xff, 0xff, /* GROUP_OBJ r-x */
+ 0x10, 0x00, 0x07, 0x00, 0xff, 0xff, 0xff, 0xff, /* MASK rwx */
+ 0x20, 0x00, 0x05, 0x00, 0xff, 0xff, 0xff, 0xff, /* OTHER r-x */
+};
+
+/* ---- Shared state (daemon thread <-> test thread) ----------------------- */
+
+#define FILE_INO 2
+#define FILE_NAME "testfile"
+
+struct daemon_state {
+ pthread_mutex_t lock;
+ const uint8_t *acl;
+ size_t acl_size;
+ int getxattr_count;
+};
+
+/*
+ * Global: callbacks are stateless fns so we use a single global.
+ * Safe because only one test instance runs at a time.
+ */
+static struct daemon_state g_ds = {
+ .lock = PTHREAD_MUTEX_INITIALIZER,
+};
+
+/* ---- FUSE lowlevel callbacks -------------------------------------------- */
+
+static void fs_lookup(fuse_req_t req, fuse_ino_t parent, const char *name)
+{
+ if (parent != FUSE_ROOT_ID || strcmp(name, FILE_NAME)) {
+ fuse_reply_err(req, ENOENT);
+ return;
+ }
+ struct fuse_entry_param e = {};
+
+ /*
+ * Long attr/entry timeouts so that normal stat() calls do not
+ * expire and trigger forget_all_cached_acls() on their own;
+ * only the explicit AT_STATX_FORCE_SYNC should trigger it.
+ */
+ e.ino = FILE_INO;
+ e.generation = 1;
+ e.attr_timeout = 10.0;
+ e.entry_timeout = 10.0;
+ e.attr.st_ino = FILE_INO;
+ e.attr.st_mode = S_IFREG | 0644;
+ e.attr.st_nlink = 1;
+ fuse_reply_entry(req, &e);
+}
+
+static void fs_getattr(fuse_req_t req, fuse_ino_t ino,
+ struct fuse_file_info *fi)
+{
+ struct stat st = {};
+
+ (void)fi;
+ if (ino == FUSE_ROOT_ID) {
+ st.st_ino = FUSE_ROOT_ID;
+ st.st_mode = S_IFDIR | 0755;
+ st.st_nlink = 2;
+ } else if (ino == FILE_INO) {
+ st.st_ino = FILE_INO;
+ st.st_mode = S_IFREG | 0644;
+ st.st_nlink = 1;
+ } else {
+ fuse_reply_err(req, ENOENT);
+ return;
+ }
+ fuse_reply_attr(req, &st, 10);
+}
+
+static void fs_getxattr(fuse_req_t req, fuse_ino_t ino, const char *name,
+ size_t size)
+{
+ if (ino != FILE_INO ||
+ strcmp(name, "system.posix_acl_access") != 0) {
+ fuse_reply_err(req, ENODATA);
+ return;
+ }
+
+ pthread_mutex_lock(&g_ds.lock);
+ const uint8_t *acl = g_ds.acl;
+ size_t acl_size = g_ds.acl_size;
+ g_ds.getxattr_count++;
+ pthread_mutex_unlock(&g_ds.lock);
+
+ if (size == 0)
+ fuse_reply_xattr(req, acl_size);
+ else if (size < acl_size)
+ fuse_reply_err(req, ERANGE);
+ else
+ fuse_reply_buf(req, (const char *)acl, acl_size);
+}
+
+static const struct fuse_lowlevel_ops fs_ops = {
+ .lookup = fs_lookup,
+ .getattr = fs_getattr,
+ .getxattr = fs_getxattr,
+};
+
+/* ---- Daemon thread ------------------------------------------------------- */
+
+static void *run_daemon(void *arg)
+{
+ fuse_session_loop((struct fuse_session *)arg);
+ return NULL;
+}
+
+/* ---- kselftest harness --------------------------------------------------- */
+
+FIXTURE(acl_cache) {
+ struct fuse_session *se;
+ char mountpoint[PATH_MAX];
+ char file_path[PATH_MAX];
+ pthread_t thread;
+};
+
+FIXTURE_SETUP(acl_cache)
+{
+ char *fuse_argv[] = { "fuse_acl_cache_test", NULL };
+ struct fuse_args args = FUSE_ARGS_INIT(1, fuse_argv);
+
+ g_ds.acl = acl_a;
+ g_ds.acl_size = sizeof(acl_a);
+ g_ds.getxattr_count = 0;
+
+ strcpy(self->mountpoint, "/tmp/acl_cache_test_XXXXXX");
+ if (!mkdtemp(self->mountpoint))
+ SKIP(return, "mkdtemp: %s", strerror(errno));
+
+ snprintf(self->file_path, sizeof(self->file_path),
+ "%s/" FILE_NAME, self->mountpoint);
+
+ self->se = fuse_session_new(&args, &fs_ops, sizeof(fs_ops), NULL);
+ if (!self->se) {
+ rmdir(self->mountpoint);
+ SKIP(return, "fuse_session_new failed");
+ }
+
+ if (fuse_session_mount(self->se, self->mountpoint)) {
+ fuse_session_destroy(self->se);
+ rmdir(self->mountpoint);
+ SKIP(return, "fuse_session_mount failed "
+ "(missing fusermount3 or insufficient privileges)");
+ }
+
+ if (pthread_create(&self->thread, NULL, run_daemon, self->se)) {
+ fuse_session_unmount(self->se);
+ fuse_session_destroy(self->se);
+ rmdir(self->mountpoint);
+ SKIP(return, "pthread_create: %s", strerror(errno));
+ }
+
+ fuse_opt_free_args(&args);
+}
+
+FIXTURE_TEARDOWN(acl_cache)
+{
+ fuse_session_exit(self->se);
+ fuse_session_unmount(self->se);
+ pthread_join(self->thread, NULL);
+ fuse_session_destroy(self->se);
+ rmdir(self->mountpoint);
+}
+
+static int do_force_statx(const char *path)
+{
+ struct statx stx;
+
+ return statx(AT_FDCWD, path, AT_STATX_FORCE_SYNC, STATX_BASIC_STATS,
+ &stx);
+}
+
+TEST_F(acl_cache, stale_after_force_sync)
+{
+ char buf[512];
+ ssize_t sz;
+ int count;
+
+ /*
+ * Step 1: two getxattr calls before any statx(FORCE_SYNC).
+ * i_acl == ACL_DONT_CACHE. __get_acl's cmpxchg(p, ACL_NOT_CACHED,
+ * sentinel) finds *p != ACL_NOT_CACHED on every call, so the sentinel
+ * is never placed and the result is never cached. Both calls must
+ * reach the daemon, proving ACL_DONT_CACHE suppresses caching.
+ */
+ sz = lgetxattr(self->file_path, "system.posix_acl_access",
+ buf, sizeof(buf));
+ ASSERT_EQ(sz, (ssize_t)sizeof(acl_a));
+
+ sz = lgetxattr(self->file_path, "system.posix_acl_access",
+ buf, sizeof(buf));
+ ASSERT_EQ(sz, (ssize_t)sizeof(acl_a));
+
+ pthread_mutex_lock(&g_ds.lock);
+ count = g_ds.getxattr_count;
+ pthread_mutex_unlock(&g_ds.lock);
+
+ ASSERT_EQ(count, 2);
+ TH_LOG("step 1 OK: both pre-trigger getxattrs reached daemon (count=%d), "
+ "ACL_DONT_CACHE is working", count);
+
+ /*
+ * Step 2: statx(AT_STATX_FORCE_SYNC).
+ * fuse_update_get_attr() calls forget_all_cached_acls() before sending
+ * FUSE_GETATTR.
+ * Buggy kernel: ACL_DONT_CACHE -> ACL_NOT_CACHED (cache enabled)
+ * Fixed kernel: ACL_DONT_CACHE preserved (no effect)
+ */
+ ASSERT_EQ(do_force_statx(self->file_path), 0);
+ TH_LOG("step 2 OK: statx(AT_STATX_FORCE_SYNC) succeeded");
+
+ /*
+ * Step 3: getxattr — cache population attempt after the trigger.
+ * Buggy: *p == ACL_NOT_CACHED -> sentinel placed -> fuse_get_inode_acl
+ * called -> ACL_A parsed and stored in the kernel cache.
+ * Fixed: *p == ACL_DONT_CACHE -> sentinel placement skipped ->
+ * fuse_get_inode_acl called but result not cached.
+ * Either way the correct ACL_A is returned here.
+ */
+ sz = lgetxattr(self->file_path, "system.posix_acl_access",
+ buf, sizeof(buf));
+ ASSERT_EQ(sz, (ssize_t)sizeof(acl_a));
+
+ pthread_mutex_lock(&g_ds.lock);
+ count = g_ds.getxattr_count;
+ pthread_mutex_unlock(&g_ds.lock);
+
+ ASSERT_EQ(count, 3);
+ TH_LOG("step 3 OK: post-trigger getxattr reached daemon (count=%d), "
+ "returned correct ACL_A (%zd bytes)", count, sz);
+
+ /*
+ * Step 4: switch daemon to ACL_B (different size: 44 vs 28 bytes).
+ * Simulates an ACL change that fuse_set_acl() would NOT invalidate for
+ * !fc->posix_acl mounts (it skips forget_all_cached_acls in that case).
+ * On a fixed kernel the ACL was never cached, so this is moot.
+ */
+ pthread_mutex_lock(&g_ds.lock);
+ g_ds.acl = acl_b;
+ g_ds.acl_size = sizeof(acl_b);
+ pthread_mutex_unlock(&g_ds.lock);
+ TH_LOG("step 4: daemon switched to ACL_B (%zu bytes)", sizeof(acl_b));
+
+ /*
+ * Step 5: getxattr — the decisive check.
+ * Buggy kernel: cache hit -> stale ACL_A (28 bytes), count stays 3.
+ * Fixed kernel: no cache -> daemon called -> ACL_B (44 bytes), count 4.
+ */
+ sz = lgetxattr(self->file_path, "system.posix_acl_access",
+ buf, sizeof(buf));
+
+ pthread_mutex_lock(&g_ds.lock);
+ count = g_ds.getxattr_count;
+ pthread_mutex_unlock(&g_ds.lock);
+
+ if (sz == (ssize_t)sizeof(acl_a))
+ TH_LOG("step 5 BUG: stale ACL_A (%zd bytes) from kernel cache "
+ "(count=%d); ACL_DONT_CACHE corrupted by "
+ "forget_all_cached_acls()", sz, count);
+ else
+ TH_LOG("step 5 OK: daemon reached (count=%d), "
+ "fresh ACL_B (%zd bytes)", count, sz);
+
+ EXPECT_EQ(sz, (ssize_t)sizeof(acl_b));
+ EXPECT_EQ(count, 4);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/fuse/write_extend_eof_test.c b/tools/testing/selftests/filesystems/fuse/write_extend_eof_test.c
new file mode 100644
index 000000000000..ca6ce6eca382
--- /dev/null
+++ b/tools/testing/selftests/filesystems/fuse/write_extend_eof_test.c
@@ -0,0 +1,368 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Regression test for the fuse write-extend partial-EOF-page zeroing bug.
+ *
+ * A buffered write that extends i_size past a non-page-aligned EOF must zero
+ * the tail of the old last page. If an application has mmap'd that page and
+ * stored into the post-EOF region (undefined until the file grows), the
+ * now-in-bounds tail must read back as zero, not as the stale stored bytes.
+ *
+ * The bug is exposed on a non-writeback_cache server that keeps the page cache
+ * across the write (FOPEN_KEEP_CACHE without FOPEN_DIRECT_IO). This test is a
+ * raw /dev/fuse server in that mode; the backing data is always zero in the
+ * hole, so any non-zero byte a read sees is stale page-cache data.
+ *
+ * Requires root to mount fuse.
+ */
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <linux/falloc.h>
+#include <pthread.h>
+#include <stdint.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/mman.h>
+#include <sys/mount.h>
+#include <sys/stat.h>
+#include <sys/uio.h>
+#include <linux/fuse.h>
+
+#include "../../kselftest_harness.h"
+
+#define FUSE_ROOT_ID 1
+#define FILE_INO 2
+#define MAX_WRITE (128 * 1024)
+#define BACKING_SIZE (4 * 1024 * 1024)
+#define POLLUTE 0xee
+
+/* Server-side state, shared with the responder thread. */
+struct server {
+ int fd;
+ unsigned char backing[BACKING_SIZE]; /* authoritative bytes */
+ uint64_t size;
+};
+
+static void reply(int fd, uint64_t unique, int error, void *data, size_t len)
+{
+ struct fuse_out_header oh = {
+ .len = sizeof(oh) + (data ? len : 0),
+ .error = error,
+ .unique = unique,
+ };
+ struct iovec iov[2] = { { &oh, sizeof(oh) }, { data, len } };
+
+ /* Errors here are teardown races (device closed on unmount); ignore. */
+ if (writev(fd, iov, data ? 2 : 1) < 0)
+ return;
+}
+
+static void fill_attr(struct fuse_attr *a, uint64_t ino, uint32_t mode,
+ uint64_t size)
+{
+ memset(a, 0, sizeof(*a));
+ a->ino = ino;
+ a->mode = mode;
+ a->nlink = 1;
+ a->size = size;
+ a->blksize = sysconf(_SC_PAGESIZE);
+}
+
+static void *server_thread(void *arg)
+{
+ struct server *s = arg;
+ static char buf[MAX_WRITE + 4096];
+
+ for (;;) {
+ ssize_t n = read(s->fd, buf, sizeof(buf));
+ struct fuse_in_header *ih = (void *)buf;
+
+ if (n < 0) {
+ if (errno == EINTR || errno == EAGAIN)
+ continue;
+ return NULL; /* device closed on unmount */
+ }
+ if (n < (ssize_t)sizeof(*ih))
+ continue;
+
+ switch (ih->opcode) {
+ case FUSE_INIT: {
+ struct fuse_init_in *in = (void *)(ih + 1);
+ struct fuse_init_out out = {0};
+
+ /* No FUSE_WRITEBACK_CACHE: the exposed configuration. */
+ out.major = FUSE_KERNEL_VERSION;
+ out.minor = FUSE_KERNEL_MINOR_VERSION;
+ out.max_readahead = in->max_readahead;
+ out.max_write = MAX_WRITE;
+ out.max_background = 16;
+ out.congestion_threshold = 12;
+ out.flags = FUSE_MAX_PAGES;
+ out.max_pages = MAX_WRITE / sysconf(_SC_PAGESIZE);
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_GETATTR: {
+ struct fuse_attr_out out = {0};
+ int root = ih->nodeid == FUSE_ROOT_ID;
+
+ out.attr_valid = 3600;
+ fill_attr(&out.attr, ih->nodeid,
+ root ? (S_IFDIR | 0755) : (S_IFREG | 0644),
+ root ? 0 : s->size);
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_LOOKUP: {
+ struct fuse_entry_out out = {0};
+
+ out.nodeid = FILE_INO;
+ out.attr_valid = 3600;
+ out.entry_valid = 3600;
+ fill_attr(&out.attr, FILE_INO, S_IFREG | 0644, s->size);
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_OPEN:
+ case FUSE_OPENDIR: {
+ struct fuse_open_out out = {0};
+
+ /* Keep the cache across the write, but not direct I/O. */
+ out.open_flags = FOPEN_KEEP_CACHE;
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_READ: {
+ struct fuse_read_in *in = (void *)(ih + 1);
+ uint64_t off = in->offset;
+ uint32_t size = in->size;
+
+ if (off >= BACKING_SIZE)
+ size = 0;
+ else if (off + size > BACKING_SIZE)
+ size = BACKING_SIZE - off;
+ reply(s->fd, ih->unique, 0, s->backing + off, size);
+ break;
+ }
+ case FUSE_WRITE: {
+ struct fuse_write_in *in = (void *)(ih + 1);
+ struct fuse_write_out out = {0};
+ uint64_t off = in->offset;
+ uint32_t size = in->size;
+
+ if (off < BACKING_SIZE) {
+ uint32_t c = size;
+
+ if (off + c > BACKING_SIZE)
+ c = BACKING_SIZE - off;
+ memcpy(s->backing + off, in + 1, c);
+ if (off + c > s->size)
+ s->size = off + c;
+ }
+ out.size = size;
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_SETATTR: {
+ struct fuse_setattr_in *in = (void *)(ih + 1);
+ struct fuse_attr_out out = {0};
+
+ if ((in->valid & FATTR_SIZE) && in->size <= BACKING_SIZE) {
+ if (in->size > s->size)
+ memset(s->backing + s->size, 0,
+ in->size - s->size);
+ s->size = in->size;
+ }
+ out.attr_valid = 3600;
+ fill_attr(&out.attr, ih->nodeid, S_IFREG | 0644, s->size);
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_FALLOCATE: {
+ struct fuse_fallocate_in *in = (void *)(ih + 1);
+ uint64_t end = in->offset + in->length;
+
+ /* Only plain (size-extending) fallocate is used here. */
+ if (!(in->mode & FALLOC_FL_KEEP_SIZE) &&
+ end <= BACKING_SIZE && end > s->size) {
+ memset(s->backing + s->size, 0, end - s->size);
+ s->size = end;
+ }
+ reply(s->fd, ih->unique, 0, NULL, 0);
+ break;
+ }
+ case FUSE_FLUSH:
+ case FUSE_RELEASE:
+ case FUSE_RELEASEDIR:
+ case FUSE_FSYNC:
+ case FUSE_ACCESS:
+ reply(s->fd, ih->unique, 0, NULL, 0);
+ break;
+ case FUSE_FORGET:
+ break;
+ default:
+ reply(s->fd, ih->unique, -EOPNOTSUPP, NULL, 0);
+ break;
+ }
+ }
+}
+
+FIXTURE(fuse)
+{
+ struct server *srv;
+ pthread_t thread;
+ char dir[64];
+ long page; /* runtime page size */
+ off_t eof; /* mid-page EOF, page-relative */
+ int fd; /* open test file */
+ char *map; /* mmap of the EOF page */
+ int mounted;
+};
+
+FIXTURE_SETUP(fuse)
+{
+ char opts[128];
+ pthread_t t;
+
+ if (geteuid() != 0)
+ SKIP(return, "need root to mount fuse");
+
+ self->page = sysconf(_SC_PAGESIZE);
+ self->fd = -1;
+ self->map = MAP_FAILED;
+
+ self->srv = mmap(NULL, sizeof(*self->srv), PROT_READ | PROT_WRITE,
+ MAP_SHARED | MAP_ANONYMOUS, -1, 0);
+ ASSERT_NE(MAP_FAILED, self->srv);
+
+ self->srv->fd = open("/dev/fuse", O_RDWR);
+ ASSERT_GE(self->srv->fd, 0);
+
+ strcpy(self->dir, "/tmp/fuse_weof_XXXXXX");
+ ASSERT_NE(NULL, mkdtemp(self->dir));
+
+ snprintf(opts, sizeof(opts),
+ "fd=%d,rootmode=40000,user_id=0,group_id=0",
+ self->srv->fd);
+ ASSERT_EQ(0, mount("fuse", self->dir, "fuse", 0, opts));
+ self->mounted = 1;
+
+ ASSERT_EQ(0, pthread_create(&t, NULL, server_thread, self->srv));
+ self->thread = t;
+}
+
+FIXTURE_TEARDOWN(fuse)
+{
+ if (self->map != MAP_FAILED)
+ munmap(self->map, self->page);
+ if (self->fd >= 0)
+ close(self->fd);
+ if (self->mounted)
+ umount2(self->dir, MNT_DETACH);
+ if (self->srv && self->srv != MAP_FAILED) {
+ if (self->srv->fd > 0)
+ close(self->srv->fd);
+ munmap(self->srv, sizeof(*self->srv));
+ }
+ if (self->dir[0])
+ rmdir(self->dir);
+}
+
+/*
+ * Create the test file with a mid-page EOF and mmap-store POLLUTE into its
+ * post-EOF tail (a legal store, undefined until the file grows). Leaves the
+ * file open and the EOF page mapped in the fixture for the caller to extend.
+ */
+static void pollute_eof_tail(struct __test_metadata *_metadata,
+ FIXTURE_DATA(fuse) * self)
+{
+ off_t eof = 2 * self->page + self->page / 4;
+ char path[128];
+ char *buf;
+
+ snprintf(path, sizeof(path), "%s/file", self->dir);
+ self->fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0644);
+ ASSERT_GE(self->fd, 0);
+ self->eof = eof;
+
+ buf = malloc(eof);
+ ASSERT_NE(NULL, buf);
+ memset(buf, 'A', eof);
+ ASSERT_EQ(eof, pwrite(self->fd, buf, eof, 0));
+ free(buf);
+
+ self->map = mmap(NULL, self->page, PROT_READ | PROT_WRITE, MAP_SHARED,
+ self->fd, eof & ~(self->page - 1));
+ ASSERT_NE(MAP_FAILED, self->map);
+ memset(self->map + (eof & (self->page - 1)), POLLUTE,
+ self->page - (eof & (self->page - 1)));
+}
+
+/* Assert the old post-EOF tail [eof, end of its page) now reads back as zero. */
+static void assert_tail_zeroed(struct __test_metadata *_metadata,
+ FIXTURE_DATA(fuse) * self)
+{
+ off_t base = self->eof & ~(self->page - 1);
+ char *tail = malloc(self->page);
+ int i;
+
+ ASSERT_NE(NULL, tail);
+ ASSERT_EQ(self->page, pread(self->fd, tail, self->page, base));
+ for (i = self->eof & (self->page - 1); i < self->page; i++)
+ ASSERT_EQ(0, tail[i]);
+ free(tail);
+}
+
+/* Basic: pollute the post-EOF tail, extend past it by a later write. */
+TEST_F(fuse, write_extend)
+{
+ pollute_eof_tail(_metadata, self);
+ ASSERT_EQ(4, pwrite(self->fd, "data", 4, 5 * self->page + self->page / 3));
+ assert_tail_zeroed(_metadata, self);
+}
+
+/* Extend via ftruncate() rather than a write. */
+TEST_F(fuse, ftruncate_extend)
+{
+ pollute_eof_tail(_metadata, self);
+ ASSERT_EQ(0, ftruncate(self->fd, 8 * self->page));
+ assert_tail_zeroed(_metadata, self);
+}
+
+/* Extend via fallocate() starting at the old EOF. */
+TEST_F(fuse, fallocate_extend)
+{
+ pollute_eof_tail(_metadata, self);
+ ASSERT_EQ(0, fallocate(self->fd, 0, self->eof, 4 * self->page));
+ assert_tail_zeroed(_metadata, self);
+}
+
+/* A write landing inside the old EOF page must not clobber its own data. */
+TEST_F(fuse, extend_into_eof_page_preserves_data)
+{
+ off_t base, wr;
+ char *buf, *rd;
+ int i;
+
+ pollute_eof_tail(_metadata, self);
+ base = self->eof & ~(self->page - 1);
+ wr = base + 3 * self->page / 4; /* starts in the EOF page */
+
+ buf = malloc(2 * self->page);
+ ASSERT_NE(NULL, buf);
+ memset(buf, 'B', 2 * self->page);
+ ASSERT_EQ(2 * self->page, pwrite(self->fd, buf, 2 * self->page, wr));
+ free(buf);
+
+ rd = malloc(self->page);
+ ASSERT_NE(NULL, rd);
+ ASSERT_EQ(self->page, pread(self->fd, rd, self->page, base));
+ /* [eof, wr) is hole -> zero; [wr, page) is written data -> 'B'. */
+ for (i = self->eof & (self->page - 1); i < wr - base; i++)
+ ASSERT_EQ(0, rd[i]);
+ for (i = wr - base; i < self->page; i++)
+ ASSERT_EQ('B', rd[i]);
+ free(rd);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/idmapped_tmpfile.c b/tools/testing/selftests/filesystems/idmapped_tmpfile.c
new file mode 100644
index 000000000000..bc411ab8281e
--- /dev/null
+++ b/tools/testing/selftests/filesystems/idmapped_tmpfile.c
@@ -0,0 +1,168 @@
+// SPDX-License-Identifier: GPL-2.0
+#define _GNU_SOURCE
+
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <sched.h>
+#include <stdio.h>
+#include <unistd.h>
+#include <sys/fsuid.h>
+#include <sys/stat.h>
+#include <sys/syscall.h>
+
+#include <linux/mount.h>
+#include <linux/types.h>
+
+#include "kselftest_harness.h"
+#include "wrappers.h"
+#include "utils.h"
+
+/*
+ * The test mount maps caller-visible ids [0, MAP_RANGE) onto the on-disk range
+ * [MAP_HOST, MAP_HOST + MAP_RANGE). An id outside [0, MAP_RANGE) therefore has
+ * no mapping in the mount and is not representable in the filesystem.
+ */
+#define MAP_HOST 10000
+#define MAP_RANGE 10000
+#define UNMAPPED 50000
+
+#ifndef MOUNT_ATTR_IDMAP
+#define MOUNT_ATTR_IDMAP 0x00100000
+#endif
+
+#ifndef __NR_mount_setattr
+#define __NR_mount_setattr 442
+#endif
+
+static inline int sys_mount_setattr(int dfd, const char *path,
+ unsigned int flags,
+ struct mount_attr *attr, size_t size)
+{
+ return syscall(__NR_mount_setattr, dfd, path, flags, attr, size);
+}
+
+/*
+ * Clone @path into a detached mount idmapped so that caller-visible ids
+ * [0, MAP_RANGE) map onto the on-disk ids [MAP_HOST, MAP_HOST + MAP_RANGE).
+ * Returns the mount fd, or -1 if idmapped mounts are not available.
+ */
+static int idmapped_clone(const char *path)
+{
+ struct mount_attr attr = {
+ .attr_set = MOUNT_ATTR_IDMAP,
+ };
+ int fd_tree, userns_fd, ret;
+
+ fd_tree = sys_open_tree(AT_FDCWD, path,
+ OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ if (fd_tree < 0)
+ return -1;
+
+ userns_fd = get_userns_fd(MAP_HOST, 0, MAP_RANGE);
+ if (userns_fd < 0) {
+ close(fd_tree);
+ return -1;
+ }
+
+ attr.userns_fd = userns_fd;
+ ret = sys_mount_setattr(fd_tree, "", AT_EMPTY_PATH, &attr, sizeof(attr));
+ close(userns_fd);
+ if (ret) {
+ close(fd_tree);
+ return -1;
+ }
+
+ return fd_tree;
+}
+
+FIXTURE(idmapped_tmpfile) {
+ char dir[64]; /* non-idmapped path to the layer directory */
+};
+
+FIXTURE_SETUP(idmapped_tmpfile)
+{
+ /* Private mount namespace so test mounts need no cleanup. */
+ ASSERT_EQ(unshare(CLONE_NEWNS), 0);
+ ASSERT_EQ(sys_mount(NULL, "/", NULL, MS_SLAVE | MS_REC, NULL), 0);
+ ASSERT_EQ(sys_mount("tmpfs", "/tmp", "tmpfs", 0, NULL), 0);
+
+ snprintf(self->dir, sizeof(self->dir), "/tmp/d");
+ ASSERT_EQ(mkdir(self->dir, 0777), 0);
+ /* World-writable so an unmapped caller still passes permission(). */
+ ASSERT_EQ(chmod(self->dir, 0777), 0);
+}
+
+FIXTURE_TEARDOWN(idmapped_tmpfile)
+{
+}
+
+/*
+ * A caller whose fsuid/fsgid have no mapping in the idmapped mount must not be
+ * able to create an O_TMPFILE. Without the check in vfs_tmpfile() the inode
+ * would be created owned by (uid_t)-1 and could then be linked into the
+ * namespace.
+ */
+TEST_F(idmapped_tmpfile, unmapped_caller_is_refused)
+{
+ int mfd, fd;
+
+ mfd = idmapped_clone(self->dir);
+ if (mfd < 0)
+ SKIP(return, "idmapped mounts not supported");
+
+ /* Become a caller outside the mount's [0, MAP_RANGE) range. */
+ setfsgid(UNMAPPED);
+ setfsuid(UNMAPPED);
+ ASSERT_EQ(setfsuid(-1), UNMAPPED);
+
+ fd = openat(mfd, ".", O_TMPFILE | O_WRONLY, 0644);
+ ASSERT_LT(fd, 0);
+ EXPECT_EQ(errno, EOVERFLOW);
+ if (fd >= 0)
+ close(fd);
+
+ EXPECT_EQ(close(mfd), 0);
+}
+
+/*
+ * A mapped caller can create an O_TMPFILE and link it into the namespace; the
+ * ownership round-trips through the mount idmap. This is what makes refusing
+ * the unmapped case above necessary in the first place.
+ */
+TEST_F(idmapped_tmpfile, mapped_caller_creates_and_links)
+{
+ char path[PATH_MAX];
+ struct stat st;
+ int mfd, fd;
+
+ mfd = idmapped_clone(self->dir);
+ if (mfd < 0)
+ SKIP(return, "idmapped mounts not supported");
+
+ /* Caller is uid/gid 0, which maps to MAP_HOST through the mount. */
+ fd = openat(mfd, ".", O_TMPFILE | O_RDWR, 0600);
+ ASSERT_GE(fd, 0);
+
+ ASSERT_EQ(fstat(fd, &st), 0);
+ EXPECT_EQ(st.st_uid, 0);
+ EXPECT_EQ(st.st_gid, 0);
+
+ /* The tmpfile is linkable: splice it into the directory. */
+ ASSERT_EQ(linkat(fd, "", mfd, "linked", AT_EMPTY_PATH), 0);
+ EXPECT_EQ(close(fd), 0);
+
+ ASSERT_EQ(fstatat(mfd, "linked", &st, 0), 0);
+ EXPECT_EQ(st.st_uid, 0);
+ EXPECT_EQ(st.st_gid, 0);
+
+ /* On the underlying, non-idmapped tmpfs it is stored as MAP_HOST. */
+ snprintf(path, sizeof(path), "%s/linked", self->dir);
+ ASSERT_EQ(stat(path, &st), 0);
+ EXPECT_EQ(st.st_uid, MAP_HOST);
+ EXPECT_EQ(st.st_gid, MAP_HOST);
+
+ EXPECT_EQ(close(mfd), 0);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/move_mount/.gitignore b/tools/testing/selftests/filesystems/move_mount/.gitignore
new file mode 100644
index 000000000000..c7557db30671
--- /dev/null
+++ b/tools/testing/selftests/filesystems/move_mount/.gitignore
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: GPL-2.0-only
+move_mount_test
diff --git a/tools/testing/selftests/filesystems/move_mount/Makefile b/tools/testing/selftests/filesystems/move_mount/Makefile
new file mode 100644
index 000000000000..5c5b199b464b
--- /dev/null
+++ b/tools/testing/selftests/filesystems/move_mount/Makefile
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+CFLAGS += -Wall -O2 -g $(KHDR_INCLUDES) $(TOOLS_INCLUDES)
+LDLIBS += -lcap
+
+TEST_GEN_PROGS := move_mount_test
+
+include ../../lib.mk
+
+$(OUTPUT)/move_mount_test: ../utils.c
diff --git a/tools/testing/selftests/filesystems/move_mount/move_mount_test.c b/tools/testing/selftests/filesystems/move_mount/move_mount_test.c
new file mode 100644
index 000000000000..f08f94b1f0ec
--- /dev/null
+++ b/tools/testing/selftests/filesystems/move_mount/move_mount_test.c
@@ -0,0 +1,492 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+// Copyright (c) 2026 Christian Brauner <brauner@kernel.org>
+
+#define _GNU_SOURCE
+
+#include <errno.h>
+#include <fcntl.h>
+#include <sched.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/mount.h>
+#include <unistd.h>
+#include <sys/syscall.h>
+
+#include "../wrappers.h"
+#include "../utils.h"
+#include "../statmount/statmount.h"
+#include "../../kselftest_harness.h"
+
+#include <linux/stat.h>
+
+#ifndef MOVE_MOUNT_BENEATH
+#define MOVE_MOUNT_BENEATH 0x00000200
+#endif
+
+static uint64_t get_unique_mnt_id_fd(int fd)
+{
+ struct statx sx;
+ int ret;
+
+ ret = statx(fd, "", AT_EMPTY_PATH, STATX_MNT_ID_UNIQUE, &sx);
+ if (ret)
+ return 0;
+
+ if (!(sx.stx_mask & STATX_MNT_ID_UNIQUE))
+ return 0;
+
+ return sx.stx_mnt_id;
+}
+
+/*
+ * Create a locked overmount stack at /mnt_dir for testing MNT_LOCKED
+ * transfer on non-rootfs mounts.
+ *
+ * Mounts tmpfs A at /mnt_dir, overmounts with tmpfs B, then enters a
+ * new user+mount namespace where both become locked. Returns the exit
+ * code to use on failure, or 0 on success.
+ */
+static int setup_locked_overmount(void)
+{
+ /* Isolate so mounts don't leak. */
+ if (unshare(CLONE_NEWNS))
+ return 1;
+ if (mount("", "/", NULL, MS_REC | MS_PRIVATE, NULL))
+ return 2;
+
+ /*
+ * Create mounts while still in the initial user namespace so
+ * they become locked after the subsequent user namespace
+ * unshare.
+ */
+ rmdir("/mnt_dir");
+ if (mkdir("/mnt_dir", 0755))
+ return 3;
+
+ /* Mount tmpfs A */
+ if (mount("tmpfs", "/mnt_dir", "tmpfs", 0, NULL))
+ return 4;
+
+ /* Overmount with tmpfs B */
+ if (mount("tmpfs", "/mnt_dir", "tmpfs", 0, NULL))
+ return 5;
+
+ /*
+ * Create user+mount namespace. Mounts A and B become locked
+ * because they might be covering something that is not supposed
+ * to be revealed.
+ */
+ if (setup_userns())
+ return 6;
+
+ /* Sanity check: B must be locked */
+ if (!umount2("/mnt_dir", MNT_DETACH) || errno != EINVAL)
+ return 7;
+
+ return 0;
+}
+
+/*
+ * Create a detached tmpfs mount and return its fd, or -1 on failure.
+ */
+static int create_detached_tmpfs(void)
+{
+ int fs_fd, mnt_fd;
+
+ fs_fd = sys_fsopen("tmpfs", FSOPEN_CLOEXEC);
+ if (fs_fd < 0)
+ return -1;
+
+ if (sys_fsconfig(fs_fd, FSCONFIG_CMD_CREATE, NULL, NULL, 0)) {
+ close(fs_fd);
+ return -1;
+ }
+
+ mnt_fd = sys_fsmount(fs_fd, FSMOUNT_CLOEXEC, 0);
+ close(fs_fd);
+ return mnt_fd;
+}
+
+FIXTURE(move_mount) {
+ uint64_t orig_root_id;
+};
+
+FIXTURE_SETUP(move_mount)
+{
+ ASSERT_EQ(unshare(CLONE_NEWNS), 0);
+
+ ASSERT_EQ(mount("", "/", NULL, MS_REC | MS_PRIVATE, NULL), 0);
+
+ self->orig_root_id = get_unique_mnt_id("/");
+ ASSERT_NE(self->orig_root_id, 0);
+}
+
+FIXTURE_TEARDOWN(move_mount)
+{
+}
+
+/*
+ * Test successful MOVE_MOUNT_BENEATH on the rootfs.
+ * Mount a clone beneath /, fchdir to the clone, chroot to switch root,
+ * then detach the old root.
+ */
+TEST_F(move_mount, beneath_rootfs_success)
+{
+ int fd_tree, ret;
+ uint64_t clone_id, root_id;
+
+ fd_tree = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ ASSERT_GE(fd_tree, 0);
+
+ clone_id = get_unique_mnt_id_fd(fd_tree);
+ ASSERT_NE(clone_id, 0);
+ ASSERT_NE(clone_id, self->orig_root_id);
+
+ ASSERT_EQ(fchdir(fd_tree), 0);
+
+ ret = sys_move_mount(fd_tree, "", AT_FDCWD, "/",
+ MOVE_MOUNT_F_EMPTY_PATH | MOVE_MOUNT_BENEATH);
+ ASSERT_EQ(ret, 0);
+
+ close(fd_tree);
+
+ /* Switch root to the clone */
+ ASSERT_EQ(chroot("."), 0);
+
+ /* Verify "/" is now the clone */
+ root_id = get_unique_mnt_id("/");
+ ASSERT_NE(root_id, 0);
+ ASSERT_EQ(root_id, clone_id);
+
+ /* Detach old root */
+ ASSERT_EQ(umount2(".", MNT_DETACH), 0);
+}
+
+/*
+ * Test that after MOVE_MOUNT_BENEATH on the rootfs the old root is
+ * stacked on top of the clone. Verify via statmount that the old
+ * root's parent is the clone.
+ */
+TEST_F(move_mount, beneath_rootfs_old_root_stacked)
+{
+ int fd_tree, ret;
+ uint64_t clone_id;
+ struct statmount sm;
+
+ fd_tree = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ ASSERT_GE(fd_tree, 0);
+
+ clone_id = get_unique_mnt_id_fd(fd_tree);
+ ASSERT_NE(clone_id, 0);
+ ASSERT_NE(clone_id, self->orig_root_id);
+
+ ASSERT_EQ(fchdir(fd_tree), 0);
+
+ ret = sys_move_mount(fd_tree, "", AT_FDCWD, "/",
+ MOVE_MOUNT_F_EMPTY_PATH | MOVE_MOUNT_BENEATH);
+ ASSERT_EQ(ret, 0);
+
+ close(fd_tree);
+
+ ASSERT_EQ(chroot("."), 0);
+
+ /* Old root's parent should now be the clone */
+ ASSERT_EQ(statmount(self->orig_root_id, 0, 0,
+ STATMOUNT_MNT_BASIC, &sm, sizeof(sm), 0), 0);
+ ASSERT_EQ(sm.mnt_parent_id, clone_id);
+
+ ASSERT_EQ(umount2(".", MNT_DETACH), 0);
+}
+
+/*
+ * Test that MOVE_MOUNT_BENEATH on rootfs fails when chroot'd into a
+ * subdirectory of the same mount. The caller's fs->root.dentry doesn't
+ * match mnt->mnt_root so the kernel rejects it.
+ */
+TEST_F(move_mount, beneath_rootfs_in_chroot_fail)
+{
+ int fd_tree, ret;
+ uint64_t chroot_id, clone_id;
+
+ rmdir("/chroot_dir");
+ ASSERT_EQ(mkdir("/chroot_dir", 0755), 0);
+
+ chroot_id = get_unique_mnt_id("/chroot_dir");
+ ASSERT_NE(chroot_id, 0);
+ ASSERT_EQ(self->orig_root_id, chroot_id);
+
+ ASSERT_EQ(chdir("/chroot_dir"), 0);
+ ASSERT_EQ(chroot("."), 0);
+
+ fd_tree = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ ASSERT_GE(fd_tree, 0);
+
+ clone_id = get_unique_mnt_id_fd(fd_tree);
+ ASSERT_NE(clone_id, 0);
+ ASSERT_NE(clone_id, chroot_id);
+
+ ASSERT_EQ(fchdir(fd_tree), 0);
+
+ /*
+ * Should fail: fs->root.dentry (/chroot_dir) doesn't match
+ * the mount's mnt_root (/).
+ */
+ ret = sys_move_mount(fd_tree, "", AT_FDCWD, "/",
+ MOVE_MOUNT_F_EMPTY_PATH | MOVE_MOUNT_BENEATH);
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, EINVAL);
+
+ close(fd_tree);
+}
+
+/*
+ * Test that MOVE_MOUNT_BENEATH on rootfs succeeds when chroot'd into a
+ * separate tmpfs mount. The caller's root dentry matches the mount's
+ * mnt_root since it's a dedicated mount.
+ */
+TEST_F(move_mount, beneath_rootfs_in_chroot_success)
+{
+ int fd_tree, ret;
+ uint64_t chroot_id, clone_id, root_id;
+ struct statmount sm;
+
+ rmdir("/chroot_dir");
+ ASSERT_EQ(mkdir("/chroot_dir", 0755), 0);
+ ASSERT_EQ(mount("tmpfs", "/chroot_dir", "tmpfs", 0, NULL), 0);
+
+ chroot_id = get_unique_mnt_id("/chroot_dir");
+ ASSERT_NE(chroot_id, 0);
+
+ ASSERT_EQ(chdir("/chroot_dir"), 0);
+ ASSERT_EQ(chroot("."), 0);
+
+ ASSERT_EQ(get_unique_mnt_id("/"), chroot_id);
+
+ fd_tree = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ ASSERT_GE(fd_tree, 0);
+
+ clone_id = get_unique_mnt_id_fd(fd_tree);
+ ASSERT_NE(clone_id, 0);
+ ASSERT_NE(clone_id, chroot_id);
+
+ ASSERT_EQ(fchdir(fd_tree), 0);
+
+ ret = sys_move_mount(fd_tree, "", AT_FDCWD, "/",
+ MOVE_MOUNT_F_EMPTY_PATH | MOVE_MOUNT_BENEATH);
+ ASSERT_EQ(ret, 0);
+
+ close(fd_tree);
+
+ ASSERT_EQ(chroot("."), 0);
+
+ root_id = get_unique_mnt_id("/");
+ ASSERT_NE(root_id, 0);
+ ASSERT_EQ(root_id, clone_id);
+
+ ASSERT_EQ(statmount(chroot_id, 0, 0,
+ STATMOUNT_MNT_BASIC, &sm, sizeof(sm), 0), 0);
+ ASSERT_EQ(sm.mnt_parent_id, clone_id);
+
+ ASSERT_EQ(umount2(".", MNT_DETACH), 0);
+}
+
+/*
+ * Test MNT_LOCKED transfer when mounting beneath rootfs in a user+mount
+ * namespace. After mount-beneath the new root gets MNT_LOCKED and the
+ * old root has MNT_LOCKED cleared so it can be unmounted.
+ */
+TEST_F(move_mount, beneath_rootfs_locked_transfer)
+{
+ int fd_tree, ret;
+ uint64_t clone_id, root_id;
+
+ ASSERT_EQ(setup_userns(), 0);
+
+ ASSERT_EQ(mount("", "/", NULL, MS_REC | MS_PRIVATE, NULL), 0);
+
+ fd_tree = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC |
+ AT_RECURSIVE);
+ ASSERT_GE(fd_tree, 0);
+
+ clone_id = get_unique_mnt_id_fd(fd_tree);
+ ASSERT_NE(clone_id, 0);
+
+ ASSERT_EQ(fchdir(fd_tree), 0);
+
+ ret = sys_move_mount(fd_tree, "", AT_FDCWD, "/",
+ MOVE_MOUNT_F_EMPTY_PATH |
+ MOVE_MOUNT_BENEATH);
+ ASSERT_EQ(ret, 0);
+
+ close(fd_tree);
+
+ ASSERT_EQ(chroot("."), 0);
+
+ root_id = get_unique_mnt_id("/");
+ ASSERT_EQ(root_id, clone_id);
+
+ /*
+ * The old root should be unmountable (MNT_LOCKED was
+ * transferred to the clone). If MNT_LOCKED wasn't
+ * cleared, this would fail with EINVAL.
+ */
+ ASSERT_EQ(umount2(".", MNT_DETACH), 0);
+
+ /* Verify "/" is still the clone after detaching old root */
+ root_id = get_unique_mnt_id("/");
+ ASSERT_EQ(root_id, clone_id);
+}
+
+/*
+ * Test containment invariant: after mount-beneath rootfs in a user+mount
+ * namespace, the new root must be MNT_LOCKED. The lock transfer from the
+ * old root preserves containment -- the process cannot unmount the new root
+ * to escape the namespace.
+ */
+TEST_F(move_mount, beneath_rootfs_locked_containment)
+{
+ int fd_tree, ret;
+ uint64_t clone_id, root_id;
+
+ ASSERT_EQ(setup_userns(), 0);
+
+ ASSERT_EQ(mount("", "/", NULL, MS_REC | MS_PRIVATE, NULL), 0);
+
+ /* Sanity: rootfs must be locked in the new userns */
+ ASSERT_EQ(umount2("/", MNT_DETACH), -1);
+ ASSERT_EQ(errno, EINVAL);
+
+ fd_tree = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC |
+ AT_RECURSIVE);
+ ASSERT_GE(fd_tree, 0);
+
+ clone_id = get_unique_mnt_id_fd(fd_tree);
+ ASSERT_NE(clone_id, 0);
+
+ ASSERT_EQ(fchdir(fd_tree), 0);
+
+ ret = sys_move_mount(fd_tree, "", AT_FDCWD, "/",
+ MOVE_MOUNT_F_EMPTY_PATH |
+ MOVE_MOUNT_BENEATH);
+ ASSERT_EQ(ret, 0);
+
+ close(fd_tree);
+
+ ASSERT_EQ(chroot("."), 0);
+
+ root_id = get_unique_mnt_id("/");
+ ASSERT_EQ(root_id, clone_id);
+
+ /* Detach old root (MNT_LOCKED was cleared from it) */
+ ASSERT_EQ(umount2(".", MNT_DETACH), 0);
+
+ /* Verify "/" is still the clone after detaching old root */
+ root_id = get_unique_mnt_id("/");
+ ASSERT_EQ(root_id, clone_id);
+
+ /*
+ * The new root must be locked (MNT_LOCKED was transferred
+ * from the old root). Attempting to unmount it must fail
+ * with EINVAL, preserving the containment invariant.
+ */
+ ASSERT_EQ(umount2("/", MNT_DETACH), -1);
+ ASSERT_EQ(errno, EINVAL);
+}
+
+/*
+ * Test MNT_LOCKED transfer when mounting beneath a non-rootfs locked mount.
+ * Mounts created before unshare(CLONE_NEWUSER | CLONE_NEWNS) become locked
+ * in the new namespace. Mount-beneath transfers the lock from the displaced
+ * mount to the new mount, so the displaced mount can be unmounted.
+ */
+TEST_F(move_mount, beneath_non_rootfs_locked_transfer)
+{
+ int mnt_fd, ret;
+ uint64_t mnt_new_id, mnt_visible_id;
+
+ ASSERT_EQ(setup_locked_overmount(), 0);
+
+ mnt_fd = create_detached_tmpfs();
+ ASSERT_GE(mnt_fd, 0);
+
+ mnt_new_id = get_unique_mnt_id_fd(mnt_fd);
+ ASSERT_NE(mnt_new_id, 0);
+
+ /* Move mount beneath B (which is locked) */
+ ret = sys_move_mount(mnt_fd, "", AT_FDCWD, "/mnt_dir",
+ MOVE_MOUNT_F_EMPTY_PATH |
+ MOVE_MOUNT_BENEATH);
+ ASSERT_EQ(ret, 0);
+
+ close(mnt_fd);
+
+ /*
+ * B should now be unmountable (MNT_LOCKED was transferred
+ * to the new mount beneath it). If MNT_LOCKED wasn't
+ * cleared from B, this would fail with EINVAL.
+ */
+ ASSERT_EQ(umount2("/mnt_dir", MNT_DETACH), 0);
+
+ /* Verify the new mount is now visible */
+ mnt_visible_id = get_unique_mnt_id("/mnt_dir");
+ ASSERT_EQ(mnt_visible_id, mnt_new_id);
+}
+
+/*
+ * Test MNT_LOCKED containment when mounting beneath a non-rootfs mount
+ * that was locked during unshare(CLONE_NEWUSER | CLONE_NEWNS).
+ * Mounts created before unshare become locked in the new namespace.
+ * Mount-beneath transfers the lock, preserving containment: the new
+ * mount cannot be unmounted, but the displaced mount can.
+ */
+TEST_F(move_mount, beneath_non_rootfs_locked_containment)
+{
+ int mnt_fd, ret;
+ uint64_t mnt_new_id, mnt_visible_id;
+
+ ASSERT_EQ(setup_locked_overmount(), 0);
+
+ mnt_fd = create_detached_tmpfs();
+ ASSERT_GE(mnt_fd, 0);
+
+ mnt_new_id = get_unique_mnt_id_fd(mnt_fd);
+ ASSERT_NE(mnt_new_id, 0);
+
+ /*
+ * Move new tmpfs beneath B at /mnt_dir.
+ * Stack becomes: A -> new -> B
+ * Lock transfers from B to new.
+ */
+ ret = sys_move_mount(mnt_fd, "", AT_FDCWD, "/mnt_dir",
+ MOVE_MOUNT_F_EMPTY_PATH |
+ MOVE_MOUNT_BENEATH);
+ ASSERT_EQ(ret, 0);
+
+ close(mnt_fd);
+
+ /*
+ * B lost MNT_LOCKED -- unmounting it must succeed.
+ * This reveals the new mount at /mnt_dir.
+ */
+ ASSERT_EQ(umount2("/mnt_dir", MNT_DETACH), 0);
+
+ /* Verify the new mount is now visible */
+ mnt_visible_id = get_unique_mnt_id("/mnt_dir");
+ ASSERT_EQ(mnt_visible_id, mnt_new_id);
+
+ /*
+ * The new mount gained MNT_LOCKED -- unmounting it must
+ * fail with EINVAL, preserving the containment invariant.
+ */
+ ASSERT_EQ(umount2("/mnt_dir", MNT_DETACH), -1);
+ ASSERT_EQ(errno, EINVAL);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/nsfs/iterate_mntns.c b/tools/testing/selftests/filesystems/nsfs/iterate_mntns.c
index 61e55dfbf121..e19ff8168baf 100644
--- a/tools/testing/selftests/filesystems/nsfs/iterate_mntns.c
+++ b/tools/testing/selftests/filesystems/nsfs/iterate_mntns.c
@@ -37,17 +37,20 @@ FIXTURE(iterate_mount_namespaces) {
__u64 mnt_ns_id[MNT_NS_COUNT];
};
+static inline bool mntns_in_list(__u64 *mnt_ns_id, struct mnt_ns_info *info)
+{
+ for (int i = 0; i < MNT_NS_COUNT; i++) {
+ if (mnt_ns_id[i] == info->mnt_ns_id)
+ return true;
+ }
+ return false;
+}
+
FIXTURE_SETUP(iterate_mount_namespaces)
{
for (int i = 0; i < MNT_NS_COUNT; i++)
self->fd_mnt_ns[i] = -EBADF;
- /*
- * Creating a new user namespace let's us guarantee that we only see
- * mount namespaces that we did actually create.
- */
- ASSERT_EQ(unshare(CLONE_NEWUSER), 0);
-
for (int i = 0; i < MNT_NS_COUNT; i++) {
struct mnt_ns_info info = {};
@@ -75,13 +78,15 @@ TEST_F(iterate_mount_namespaces, iterate_all_forward)
fd_mnt_ns_cur = fcntl(self->fd_mnt_ns[0], F_DUPFD_CLOEXEC);
ASSERT_GE(fd_mnt_ns_cur, 0);
- for (;; count++) {
+ for (;;) {
struct mnt_ns_info info = {};
int fd_mnt_ns_next;
fd_mnt_ns_next = ioctl(fd_mnt_ns_cur, NS_MNT_GET_NEXT, &info);
if (fd_mnt_ns_next < 0 && errno == ENOENT)
break;
+ if (mntns_in_list(self->mnt_ns_id, &info))
+ count++;
ASSERT_GE(fd_mnt_ns_next, 0);
ASSERT_EQ(close(fd_mnt_ns_cur), 0);
fd_mnt_ns_cur = fd_mnt_ns_next;
@@ -96,13 +101,15 @@ TEST_F(iterate_mount_namespaces, iterate_all_backwards)
fd_mnt_ns_cur = fcntl(self->fd_mnt_ns[MNT_NS_LAST_INDEX], F_DUPFD_CLOEXEC);
ASSERT_GE(fd_mnt_ns_cur, 0);
- for (;; count++) {
+ for (;;) {
struct mnt_ns_info info = {};
int fd_mnt_ns_prev;
fd_mnt_ns_prev = ioctl(fd_mnt_ns_cur, NS_MNT_GET_PREV, &info);
if (fd_mnt_ns_prev < 0 && errno == ENOENT)
break;
+ if (mntns_in_list(self->mnt_ns_id, &info))
+ count++;
ASSERT_GE(fd_mnt_ns_prev, 0);
ASSERT_EQ(close(fd_mnt_ns_cur), 0);
fd_mnt_ns_cur = fd_mnt_ns_prev;
@@ -125,7 +132,6 @@ TEST_F(iterate_mount_namespaces, iterate_forward)
ASSERT_GE(fd_mnt_ns_next, 0);
ASSERT_EQ(close(fd_mnt_ns_cur), 0);
fd_mnt_ns_cur = fd_mnt_ns_next;
- ASSERT_EQ(info.mnt_ns_id, self->mnt_ns_id[i]);
}
}
@@ -144,7 +150,6 @@ TEST_F(iterate_mount_namespaces, iterate_backward)
ASSERT_GE(fd_mnt_ns_prev, 0);
ASSERT_EQ(close(fd_mnt_ns_cur), 0);
fd_mnt_ns_cur = fd_mnt_ns_prev;
- ASSERT_EQ(info.mnt_ns_id, self->mnt_ns_id[i]);
}
}
diff --git a/tools/testing/selftests/filesystems/open_tree_ns/.gitignore b/tools/testing/selftests/filesystems/open_tree_ns/.gitignore
new file mode 100644
index 000000000000..fb12b93fbcaa
--- /dev/null
+++ b/tools/testing/selftests/filesystems/open_tree_ns/.gitignore
@@ -0,0 +1 @@
+open_tree_ns_test
diff --git a/tools/testing/selftests/filesystems/open_tree_ns/Makefile b/tools/testing/selftests/filesystems/open_tree_ns/Makefile
new file mode 100644
index 000000000000..4976ed1d7d4a
--- /dev/null
+++ b/tools/testing/selftests/filesystems/open_tree_ns/Makefile
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: GPL-2.0
+TEST_GEN_PROGS := open_tree_ns_test
+
+CFLAGS += -Wall -O0 -g $(KHDR_INCLUDES) $(TOOLS_INCLUDES)
+LDLIBS := -lcap
+
+include ../../lib.mk
+
+$(OUTPUT)/open_tree_ns_test: open_tree_ns_test.c ../utils.c
+ $(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)
diff --git a/tools/testing/selftests/filesystems/open_tree_ns/open_tree_ns_test.c b/tools/testing/selftests/filesystems/open_tree_ns/open_tree_ns_test.c
new file mode 100644
index 000000000000..82f3c8c02c9a
--- /dev/null
+++ b/tools/testing/selftests/filesystems/open_tree_ns/open_tree_ns_test.c
@@ -0,0 +1,1007 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2026 Christian Brauner <brauner@kernel.org>
+ *
+ * Test for OPEN_TREE_NAMESPACE flag.
+ *
+ * Test that open_tree() with OPEN_TREE_NAMESPACE creates a new mount
+ * namespace containing the specified mount tree.
+ */
+#define _GNU_SOURCE
+
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <linux/nsfs.h>
+#include <sched.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/mount.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+#include "../wrappers.h"
+#include "../statmount/statmount.h"
+#include "../utils.h"
+#include "../../kselftest_harness.h"
+
+#ifndef OPEN_TREE_NAMESPACE
+#define OPEN_TREE_NAMESPACE (1 << 1)
+#endif
+
+static int get_mnt_ns_id(int fd, uint64_t *mnt_ns_id)
+{
+ if (ioctl(fd, NS_GET_MNTNS_ID, mnt_ns_id) < 0)
+ return -errno;
+ return 0;
+}
+
+static int get_mnt_ns_id_from_path(const char *path, uint64_t *mnt_ns_id)
+{
+ int fd, ret;
+
+ fd = open(path, O_RDONLY);
+ if (fd < 0)
+ return -errno;
+
+ ret = get_mnt_ns_id(fd, mnt_ns_id);
+ close(fd);
+ return ret;
+}
+
+static void log_mount(struct __test_metadata *_metadata, struct statmount *sm)
+{
+ const char *fs_type = "";
+ const char *mnt_root = "";
+ const char *mnt_point = "";
+
+ if (sm->mask & STATMOUNT_FS_TYPE)
+ fs_type = sm->str + sm->fs_type;
+ if (sm->mask & STATMOUNT_MNT_ROOT)
+ mnt_root = sm->str + sm->mnt_root;
+ if (sm->mask & STATMOUNT_MNT_POINT)
+ mnt_point = sm->str + sm->mnt_point;
+
+ TH_LOG(" mnt_id: %llu, parent_id: %llu, fs_type: %s, root: %s, point: %s",
+ (unsigned long long)sm->mnt_id,
+ (unsigned long long)sm->mnt_parent_id,
+ fs_type, mnt_root, mnt_point);
+}
+
+static void dump_mounts(struct __test_metadata *_metadata, uint64_t mnt_ns_id)
+{
+ uint64_t list[256];
+ ssize_t nr_mounts;
+
+ nr_mounts = listmount(LSMT_ROOT, mnt_ns_id, 0, list, 256, 0);
+ if (nr_mounts < 0) {
+ TH_LOG("listmount failed: %s", strerror(errno));
+ return;
+ }
+
+ TH_LOG("Mount namespace %llu contains %zd mount(s):",
+ (unsigned long long)mnt_ns_id, nr_mounts);
+
+ for (ssize_t i = 0; i < nr_mounts; i++) {
+ struct statmount *sm;
+
+ sm = statmount_alloc(list[i], mnt_ns_id,
+ STATMOUNT_MNT_BASIC |
+ STATMOUNT_FS_TYPE |
+ STATMOUNT_MNT_ROOT |
+ STATMOUNT_MNT_POINT, 0);
+ if (!sm) {
+ TH_LOG(" [%zd] mnt_id %llu: statmount failed: %s",
+ i, (unsigned long long)list[i], strerror(errno));
+ continue;
+ }
+
+ log_mount(_metadata, sm);
+ free(sm);
+ }
+}
+
+FIXTURE(open_tree_ns)
+{
+ int fd;
+ uint64_t current_ns_id;
+};
+
+FIXTURE_VARIANT(open_tree_ns)
+{
+ const char *path;
+ unsigned int flags;
+ bool expect_success;
+ bool expect_different_ns;
+ int min_mounts;
+};
+
+FIXTURE_VARIANT_ADD(open_tree_ns, basic_root)
+{
+ .path = "/",
+ .flags = OPEN_TREE_NAMESPACE | OPEN_TREE_CLOEXEC,
+ .expect_success = true,
+ .expect_different_ns = true,
+ /*
+ * The empty rootfs is hidden from listmount()/mountinfo,
+ * so we only see the bind mount on top of it.
+ */
+ .min_mounts = 1,
+};
+
+FIXTURE_VARIANT_ADD(open_tree_ns, recursive_root)
+{
+ .path = "/",
+ .flags = OPEN_TREE_NAMESPACE | AT_RECURSIVE | OPEN_TREE_CLOEXEC,
+ .expect_success = true,
+ .expect_different_ns = true,
+ .min_mounts = 1,
+};
+
+FIXTURE_VARIANT_ADD(open_tree_ns, subdir_tmp)
+{
+ .path = "/tmp",
+ .flags = OPEN_TREE_NAMESPACE | OPEN_TREE_CLOEXEC,
+ .expect_success = true,
+ .expect_different_ns = true,
+ .min_mounts = 1,
+};
+
+FIXTURE_VARIANT_ADD(open_tree_ns, subdir_proc)
+{
+ .path = "/proc",
+ .flags = OPEN_TREE_NAMESPACE | OPEN_TREE_CLOEXEC,
+ .expect_success = true,
+ .expect_different_ns = true,
+ .min_mounts = 1,
+};
+
+FIXTURE_VARIANT_ADD(open_tree_ns, recursive_tmp)
+{
+ .path = "/tmp",
+ .flags = OPEN_TREE_NAMESPACE | AT_RECURSIVE | OPEN_TREE_CLOEXEC,
+ .expect_success = true,
+ .expect_different_ns = true,
+ .min_mounts = 1,
+};
+
+FIXTURE_VARIANT_ADD(open_tree_ns, recursive_run)
+{
+ .path = "/run",
+ .flags = OPEN_TREE_NAMESPACE | AT_RECURSIVE | OPEN_TREE_CLOEXEC,
+ .expect_success = true,
+ .expect_different_ns = true,
+ .min_mounts = 1,
+};
+
+FIXTURE_VARIANT_ADD(open_tree_ns, invalid_recursive_alone)
+{
+ .path = "/",
+ .flags = AT_RECURSIVE | OPEN_TREE_CLOEXEC,
+ .expect_success = false,
+ .expect_different_ns = false,
+ .min_mounts = 0,
+};
+
+FIXTURE_SETUP(open_tree_ns)
+{
+ int ret;
+
+ self->fd = -1;
+
+ /* Check if open_tree syscall is supported */
+ ret = sys_open_tree(-1, NULL, 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "open_tree() syscall not supported");
+
+ /* Check if statmount/listmount are supported */
+ ret = statmount(0, 0, 0, 0, NULL, 0, 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "statmount() syscall not supported");
+
+ /* Get current mount namespace ID for comparison */
+ ret = get_mnt_ns_id_from_path("/proc/self/ns/mnt", &self->current_ns_id);
+ if (ret < 0)
+ SKIP(return, "Failed to get current mount namespace ID");
+}
+
+FIXTURE_TEARDOWN(open_tree_ns)
+{
+ if (self->fd >= 0)
+ close(self->fd);
+}
+
+TEST_F(open_tree_ns, create_namespace)
+{
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int ret;
+
+ self->fd = sys_open_tree(AT_FDCWD, variant->path, variant->flags);
+
+ if (!variant->expect_success) {
+ ASSERT_LT(self->fd, 0);
+ ASSERT_EQ(errno, EINVAL);
+ return;
+ }
+
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "OPEN_TREE_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ /* Verify we can get the namespace ID */
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ /* Verify it's a different namespace */
+ if (variant->expect_different_ns)
+ ASSERT_NE(new_ns_id, self->current_ns_id);
+
+ /* List mounts in the new namespace */
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 0) {
+ TH_LOG("%m - listmount failed");
+ }
+
+ /* Verify minimum expected mounts */
+ ASSERT_GE(nr_mounts, variant->min_mounts);
+ TH_LOG("Namespace contains %zd mounts", nr_mounts);
+}
+
+TEST_F(open_tree_ns, setns_into_namespace)
+{
+ uint64_t new_ns_id;
+ pid_t pid;
+ int status;
+ int ret;
+
+ /* Only test with basic flags */
+ if (!(variant->flags & OPEN_TREE_NAMESPACE))
+ SKIP(return, "setns test only for basic / case");
+
+ self->fd = sys_open_tree(AT_FDCWD, variant->path, variant->flags);
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "OPEN_TREE_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ /* Get namespace ID and dump all mounts */
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ dump_mounts(_metadata, new_ns_id);
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ /* Child: try to enter the namespace */
+ if (setns(self->fd, CLONE_NEWNS) < 0)
+ _exit(1);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+ ASSERT_EQ(WEXITSTATUS(status), 0);
+}
+
+TEST_F(open_tree_ns, verify_mount_properties)
+{
+ struct statmount sm;
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int ret;
+
+ /* Only test with basic flags on root */
+ if (variant->flags != (OPEN_TREE_NAMESPACE | OPEN_TREE_CLOEXEC) ||
+ strcmp(variant->path, "/") != 0)
+ SKIP(return, "mount properties test only for basic / case");
+
+ self->fd = sys_open_tree(AT_FDCWD, "/", OPEN_TREE_NAMESPACE | OPEN_TREE_CLOEXEC);
+ if (self->fd < 0 && errno == EINVAL)
+ SKIP(return, "OPEN_TREE_NAMESPACE not supported");
+
+ ASSERT_GE(self->fd, 0);
+
+ ret = get_mnt_ns_id(self->fd, &new_ns_id);
+ ASSERT_EQ(ret, 0);
+
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 1);
+
+ /* Get info about the root mount (the bind mount, rootfs is hidden) */
+ ret = statmount(list[0], new_ns_id, 0, STATMOUNT_MNT_BASIC, &sm, sizeof(sm), 0);
+ ASSERT_EQ(ret, 0);
+
+ ASSERT_NE(sm.mnt_id, sm.mnt_parent_id);
+
+ TH_LOG("Root mount id: %llu, parent: %llu",
+ (unsigned long long)sm.mnt_id,
+ (unsigned long long)sm.mnt_parent_id);
+}
+
+FIXTURE(open_tree_ns_caps)
+{
+ bool has_caps;
+};
+
+FIXTURE_SETUP(open_tree_ns_caps)
+{
+ int ret;
+
+ /* Check if open_tree syscall is supported */
+ ret = sys_open_tree(-1, NULL, 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "open_tree() syscall not supported");
+
+ self->has_caps = (geteuid() == 0);
+}
+
+FIXTURE_TEARDOWN(open_tree_ns_caps)
+{
+}
+
+TEST_F(open_tree_ns_caps, requires_cap_sys_admin)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ int fd;
+
+ /* Child: drop privileges using utils.h helper */
+ if (enter_userns() != 0)
+ _exit(2);
+
+ /* Drop all caps using utils.h helper */
+ if (caps_down() == 0)
+ _exit(3);
+
+ fd = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_NAMESPACE | OPEN_TREE_CLOEXEC);
+ if (fd >= 0) {
+ close(fd);
+ /* Should have failed without caps */
+ _exit(1);
+ }
+
+ if (errno == EPERM)
+ _exit(0);
+
+ /* EINVAL means OPEN_TREE_NAMESPACE not supported */
+ if (errno == EINVAL)
+ _exit(4);
+
+ /* Unexpected error */
+ _exit(5);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ /* Expected: EPERM without caps */
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("OPEN_TREE_NAMESPACE succeeded without caps");
+ break;
+ case 2:
+ SKIP(return, "setup_userns failed");
+ break;
+ case 3:
+ SKIP(return, "caps_down failed");
+ break;
+ case 4:
+ SKIP(return, "OPEN_TREE_NAMESPACE not supported");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+FIXTURE(open_tree_ns_userns)
+{
+ int fd;
+};
+
+FIXTURE_SETUP(open_tree_ns_userns)
+{
+ int ret;
+
+ self->fd = -1;
+
+ /* Check if open_tree syscall is supported */
+ ret = sys_open_tree(-1, NULL, 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "open_tree() syscall not supported");
+
+ /* Check if statmount/listmount are supported */
+ ret = statmount(0, 0, 0, 0, NULL, 0, 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "statmount() syscall not supported");
+}
+
+FIXTURE_TEARDOWN(open_tree_ns_userns)
+{
+ if (self->fd >= 0)
+ close(self->fd);
+}
+
+TEST_F(open_tree_ns_userns, create_in_userns)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int fd;
+
+ /* Create new user namespace (also creates mount namespace) */
+ if (enter_userns() != 0)
+ _exit(2);
+
+ /* Now we have CAP_SYS_ADMIN in the user namespace */
+ fd = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_NAMESPACE | OPEN_TREE_CLOEXEC);
+ if (fd < 0) {
+ if (errno == EINVAL)
+ _exit(4); /* OPEN_TREE_NAMESPACE not supported */
+ _exit(1);
+ }
+
+ /* Verify we can get the namespace ID */
+ if (get_mnt_ns_id(fd, &new_ns_id) != 0)
+ _exit(5);
+
+ /* Verify we can list mounts in the new namespace */
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ if (nr_mounts < 0)
+ _exit(6);
+
+ /* Should have at least 1 mount */
+ if (nr_mounts < 1)
+ _exit(7);
+
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ /* Success */
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("open_tree(OPEN_TREE_NAMESPACE) failed in userns");
+ break;
+ case 2:
+ SKIP(return, "setup_userns failed");
+ break;
+ case 4:
+ SKIP(return, "OPEN_TREE_NAMESPACE not supported");
+ break;
+ case 5:
+ ASSERT_FALSE(true) TH_LOG("Failed to get mount namespace ID");
+ break;
+ case 6:
+ ASSERT_FALSE(true) TH_LOG("listmount failed in new namespace");
+ break;
+ case 7:
+ ASSERT_FALSE(true) TH_LOG("New namespace has no mounts");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+TEST_F(open_tree_ns_userns, setns_in_userns)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t new_ns_id;
+ int fd;
+ pid_t inner_pid;
+ int inner_status;
+
+ /* Create new user namespace */
+ if (enter_userns() != 0)
+ _exit(2);
+
+ fd = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_NAMESPACE | OPEN_TREE_CLOEXEC);
+ if (fd < 0) {
+ if (errno == EINVAL)
+ _exit(4);
+ _exit(1);
+ }
+
+ if (get_mnt_ns_id(fd, &new_ns_id) != 0)
+ _exit(5);
+
+ /* Fork again to test setns into the new namespace */
+ inner_pid = fork();
+ if (inner_pid < 0)
+ _exit(8);
+
+ if (inner_pid == 0) {
+ /* Inner child: enter the new namespace */
+ if (setns(fd, CLONE_NEWNS) < 0)
+ _exit(1);
+ _exit(0);
+ }
+
+ if (waitpid(inner_pid, &inner_status, 0) != inner_pid)
+ _exit(9);
+
+ if (!WIFEXITED(inner_status) || WEXITSTATUS(inner_status) != 0)
+ _exit(10);
+
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ /* Success */
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("open_tree or setns failed in userns");
+ break;
+ case 2:
+ SKIP(return, "setup_userns failed");
+ break;
+ case 4:
+ SKIP(return, "OPEN_TREE_NAMESPACE not supported");
+ break;
+ case 5:
+ ASSERT_FALSE(true) TH_LOG("Failed to get mount namespace ID");
+ break;
+ case 8:
+ ASSERT_FALSE(true) TH_LOG("Inner fork failed");
+ break;
+ case 9:
+ ASSERT_FALSE(true) TH_LOG("Inner waitpid failed");
+ break;
+ case 10:
+ ASSERT_FALSE(true) TH_LOG("setns into new namespace failed");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+TEST_F(open_tree_ns_userns, recursive_in_userns)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int fd;
+
+ /* Create new user namespace */
+ if (enter_userns() != 0)
+ _exit(2);
+
+ /* Test recursive flag in userns */
+ fd = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_NAMESPACE | AT_RECURSIVE | OPEN_TREE_CLOEXEC);
+ if (fd < 0) {
+ if (errno == EINVAL)
+ _exit(4);
+ _exit(1);
+ }
+
+ if (get_mnt_ns_id(fd, &new_ns_id) != 0)
+ _exit(5);
+
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ if (nr_mounts < 0)
+ _exit(6);
+
+ /* Recursive should copy submounts too */
+ if (nr_mounts < 1)
+ _exit(7);
+
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ /* Success */
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("open_tree(OPEN_TREE_NAMESPACE|AT_RECURSIVE) failed in userns");
+ break;
+ case 2:
+ SKIP(return, "setup_userns failed");
+ break;
+ case 4:
+ SKIP(return, "OPEN_TREE_NAMESPACE not supported");
+ break;
+ case 5:
+ ASSERT_FALSE(true) TH_LOG("Failed to get mount namespace ID");
+ break;
+ case 6:
+ ASSERT_FALSE(true) TH_LOG("listmount failed in new namespace");
+ break;
+ case 7:
+ ASSERT_FALSE(true) TH_LOG("New namespace has no mounts");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+TEST_F(open_tree_ns_userns, umount_fails_einval)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int fd;
+ ssize_t i;
+
+ /* Create new user namespace */
+ if (enter_userns() != 0)
+ _exit(2);
+
+ fd = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_NAMESPACE | AT_RECURSIVE | OPEN_TREE_CLOEXEC);
+ if (fd < 0) {
+ if (errno == EINVAL)
+ _exit(4);
+ _exit(1);
+ }
+
+ if (get_mnt_ns_id(fd, &new_ns_id) != 0)
+ _exit(5);
+
+ /* Get all mounts in the new namespace */
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, LISTMOUNT_REVERSE);
+ if (nr_mounts < 0)
+ _exit(9);
+
+ if (nr_mounts < 1)
+ _exit(10);
+
+ /* Enter the new namespace */
+ if (setns(fd, CLONE_NEWNS) < 0)
+ _exit(6);
+
+ for (i = 0; i < nr_mounts; i++) {
+ struct statmount *sm;
+ const char *mnt_point;
+
+ sm = statmount_alloc(list[i], new_ns_id,
+ STATMOUNT_MNT_POINT, 0);
+ if (!sm)
+ _exit(11);
+
+ mnt_point = sm->str + sm->mnt_point;
+
+ TH_LOG("Trying to umount %s", mnt_point);
+ if (umount2(mnt_point, MNT_DETACH) == 0) {
+ free(sm);
+ _exit(7);
+ }
+
+ if (errno != EINVAL) {
+ /* Wrong error */
+ free(sm);
+ _exit(8);
+ }
+
+ free(sm);
+ }
+
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("open_tree(OPEN_TREE_NAMESPACE) failed");
+ break;
+ case 2:
+ SKIP(return, "setup_userns failed");
+ break;
+ case 4:
+ SKIP(return, "OPEN_TREE_NAMESPACE not supported");
+ break;
+ case 5:
+ ASSERT_FALSE(true) TH_LOG("Failed to get mount namespace ID");
+ break;
+ case 6:
+ ASSERT_FALSE(true) TH_LOG("setns into new namespace failed");
+ break;
+ case 7:
+ ASSERT_FALSE(true) TH_LOG("umount succeeded but should have failed with EINVAL");
+ break;
+ case 8:
+ ASSERT_FALSE(true) TH_LOG("umount failed with wrong error (expected EINVAL)");
+ break;
+ case 9:
+ ASSERT_FALSE(true) TH_LOG("listmount failed");
+ break;
+ case 10:
+ ASSERT_FALSE(true) TH_LOG("No mounts in new namespace");
+ break;
+ case 11:
+ ASSERT_FALSE(true) TH_LOG("statmount_alloc failed");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+TEST_F(open_tree_ns_userns, umount_succeeds)
+{
+ pid_t pid;
+ int status;
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+
+ if (pid == 0) {
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int fd;
+ ssize_t i;
+
+ if (unshare(CLONE_NEWNS))
+ _exit(1);
+
+ if (sys_mount(NULL, "/", NULL, MS_SLAVE | MS_REC, NULL) != 0)
+ _exit(1);
+
+ fd = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_NAMESPACE | AT_RECURSIVE | OPEN_TREE_CLOEXEC);
+ if (fd < 0) {
+ if (errno == EINVAL)
+ _exit(4);
+ _exit(1);
+ }
+
+ if (get_mnt_ns_id(fd, &new_ns_id) != 0)
+ _exit(5);
+
+ /* Get all mounts in the new namespace */
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, LISTMOUNT_REVERSE);
+ if (nr_mounts < 0)
+ _exit(9);
+
+ if (nr_mounts < 1)
+ _exit(10);
+
+ /* Enter the new namespace */
+ if (setns(fd, CLONE_NEWNS) < 0)
+ _exit(6);
+
+ for (i = 0; i < nr_mounts; i++) {
+ struct statmount *sm;
+ const char *mnt_point;
+
+ sm = statmount_alloc(list[i], new_ns_id,
+ STATMOUNT_MNT_POINT, 0);
+ if (!sm)
+ _exit(11);
+
+ mnt_point = sm->str + sm->mnt_point;
+
+ TH_LOG("Trying to umount %s", mnt_point);
+ if (umount2(mnt_point, MNT_DETACH) != 0) {
+ free(sm);
+ _exit(7);
+ }
+
+ free(sm);
+ }
+
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(waitpid(pid, &status, 0), pid);
+ ASSERT_TRUE(WIFEXITED(status));
+
+ switch (WEXITSTATUS(status)) {
+ case 0:
+ break;
+ case 1:
+ ASSERT_FALSE(true) TH_LOG("open_tree(OPEN_TREE_NAMESPACE) failed");
+ break;
+ case 2:
+ SKIP(return, "setup_userns failed");
+ break;
+ case 4:
+ SKIP(return, "OPEN_TREE_NAMESPACE not supported");
+ break;
+ case 5:
+ ASSERT_FALSE(true) TH_LOG("Failed to get mount namespace ID");
+ break;
+ case 6:
+ ASSERT_FALSE(true) TH_LOG("setns into new namespace failed");
+ break;
+ case 7:
+ ASSERT_FALSE(true) TH_LOG("umount failed but should have succeeded");
+ break;
+ case 9:
+ ASSERT_FALSE(true) TH_LOG("listmount failed");
+ break;
+ case 10:
+ ASSERT_FALSE(true) TH_LOG("No mounts in new namespace");
+ break;
+ case 11:
+ ASSERT_FALSE(true) TH_LOG("statmount_alloc failed");
+ break;
+ default:
+ ASSERT_FALSE(true) TH_LOG("Unexpected error in child (exit %d)",
+ WEXITSTATUS(status));
+ break;
+ }
+}
+
+FIXTURE(open_tree_ns_unbindable)
+{
+ char tmpdir[PATH_MAX];
+ bool mounted;
+};
+
+FIXTURE_SETUP(open_tree_ns_unbindable)
+{
+ int ret;
+
+ self->mounted = false;
+
+ /* Check if open_tree syscall is supported */
+ ret = sys_open_tree(-1, NULL, 0);
+ if (ret == -1 && errno == ENOSYS)
+ SKIP(return, "open_tree() syscall not supported");
+
+ /* Create a temporary directory for the test mount */
+ snprintf(self->tmpdir, sizeof(self->tmpdir),
+ "/tmp/open_tree_ns_test.XXXXXX");
+ ASSERT_NE(mkdtemp(self->tmpdir), NULL);
+
+ /* Mount tmpfs there */
+ ret = mount("tmpfs", self->tmpdir, "tmpfs", 0, NULL);
+ if (ret < 0) {
+ rmdir(self->tmpdir);
+ SKIP(return, "Failed to mount tmpfs");
+ }
+ self->mounted = true;
+
+ ret = mount(NULL, self->tmpdir, NULL, MS_UNBINDABLE, NULL);
+ if (ret < 0) {
+ rmdir(self->tmpdir);
+ SKIP(return, "Failed to make tmpfs unbindable");
+ }
+}
+
+FIXTURE_TEARDOWN(open_tree_ns_unbindable)
+{
+ if (self->mounted)
+ umount2(self->tmpdir, MNT_DETACH);
+ rmdir(self->tmpdir);
+}
+
+TEST_F(open_tree_ns_unbindable, fails_on_unbindable)
+{
+ int fd;
+
+ fd = sys_open_tree(AT_FDCWD, self->tmpdir,
+ OPEN_TREE_NAMESPACE | OPEN_TREE_CLOEXEC);
+ ASSERT_LT(fd, 0);
+}
+
+TEST_F(open_tree_ns_unbindable, recursive_skips_on_unbindable)
+{
+ uint64_t new_ns_id;
+ uint64_t list[256];
+ ssize_t nr_mounts;
+ int fd;
+ ssize_t i;
+ bool found_unbindable = false;
+
+ fd = sys_open_tree(AT_FDCWD, "/",
+ OPEN_TREE_NAMESPACE | AT_RECURSIVE | OPEN_TREE_CLOEXEC);
+ ASSERT_GT(fd, 0);
+
+ ASSERT_EQ(get_mnt_ns_id(fd, &new_ns_id), 0);
+
+ nr_mounts = listmount(LSMT_ROOT, new_ns_id, 0, list, 256, 0);
+ ASSERT_GE(nr_mounts, 0) {
+ TH_LOG("listmount failed: %m");
+ }
+
+ /*
+ * Iterate through all mounts in the new namespace and verify
+ * the unbindable tmpfs mount was silently dropped.
+ */
+ for (i = 0; i < nr_mounts; i++) {
+ struct statmount *sm;
+ const char *mnt_point;
+
+ sm = statmount_alloc(list[i], new_ns_id, STATMOUNT_MNT_POINT, 0);
+ ASSERT_NE(sm, NULL) {
+ TH_LOG("statmount_alloc failed for mnt_id %llu",
+ (unsigned long long)list[i]);
+ }
+
+ mnt_point = sm->str + sm->mnt_point;
+
+ if (strcmp(mnt_point, self->tmpdir) == 0) {
+ TH_LOG("Found unbindable mount at %s (should have been dropped)",
+ mnt_point);
+ found_unbindable = true;
+ }
+
+ free(sm);
+ }
+
+ ASSERT_FALSE(found_unbindable) {
+ TH_LOG("Unbindable mount at %s was not dropped", self->tmpdir);
+ }
+
+ close(fd);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/openat2/.gitignore b/tools/testing/selftests/filesystems/openat2/.gitignore
new file mode 100644
index 000000000000..82a4846cbc4b
--- /dev/null
+++ b/tools/testing/selftests/filesystems/openat2/.gitignore
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: GPL-2.0-only
+/*_test
diff --git a/tools/testing/selftests/filesystems/openat2/Makefile b/tools/testing/selftests/filesystems/openat2/Makefile
new file mode 100644
index 000000000000..d848aac96bde
--- /dev/null
+++ b/tools/testing/selftests/filesystems/openat2/Makefile
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+CFLAGS += $(KHDR_INCLUDES)
+CFLAGS += -Wall -O2 -g -fsanitize=address -fsanitize=undefined $(TOOLS_INCLUDES)
+TEST_GEN_PROGS := openat2_test resolve_test rename_attack_test emptypath_test
+
+# gcc requires -static-libasan in order to ensure that Address Sanitizer's
+# library is the first one loaded. However, clang already statically links the
+# Address Sanitizer if -fsanitize is specified. Therefore, simply omit
+# -static-libasan for clang builds.
+ifeq ($(LLVM),)
+ CFLAGS += -static-libasan
+endif
+
+LOCAL_HDRS += helpers.h
+
+include ../../lib.mk
diff --git a/tools/testing/selftests/filesystems/openat2/emptypath_test.c b/tools/testing/selftests/filesystems/openat2/emptypath_test.c
new file mode 100644
index 000000000000..be37ccba57ec
--- /dev/null
+++ b/tools/testing/selftests/filesystems/openat2/emptypath_test.c
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#define _GNU_SOURCE
+#define __SANE_USERSPACE_TYPES__
+#include <fcntl.h>
+#include <unistd.h>
+#include <errno.h>
+#include <string.h>
+#include <sys/stat.h>
+
+#include "kselftest_harness.h"
+
+#ifndef O_EMPTYPATH
+#define O_EMPTYPATH (1 << 26)
+#endif
+
+#define EMPTYPATH_TEST_FILE "/tmp/emptypath_test"
+
+FIXTURE(emptypath) {
+ int opath_fd;
+};
+
+FIXTURE_SETUP(emptypath)
+{
+ int fd;
+
+ self->opath_fd = -1;
+
+ fd = open(EMPTYPATH_TEST_FILE, O_CREAT | O_WRONLY, S_IRWXU);
+ ASSERT_GE(fd, 0) {
+ TH_LOG("create %s: %s", EMPTYPATH_TEST_FILE, strerror(errno));
+ }
+ close(fd);
+
+ self->opath_fd = open(EMPTYPATH_TEST_FILE, O_PATH);
+ ASSERT_GE(self->opath_fd, 0) {
+ TH_LOG("open %s O_PATH: %s", EMPTYPATH_TEST_FILE, strerror(errno));
+ }
+}
+
+FIXTURE_TEARDOWN(emptypath)
+{
+ if (self->opath_fd >= 0)
+ close(self->opath_fd);
+ unlink(EMPTYPATH_TEST_FILE);
+}
+
+/* An empty path is rejected with ENOENT unless O_EMPTYPATH is set. */
+TEST_F(emptypath, without_flag_returns_enoent)
+{
+ int fd = openat(self->opath_fd, "", O_RDONLY);
+
+ if (fd >= 0)
+ close(fd);
+ ASSERT_LT(fd, 0) {
+ TH_LOG("empty path without O_EMPTYPATH unexpectedly succeeded");
+ }
+ EXPECT_EQ(errno, ENOENT) {
+ TH_LOG("expected ENOENT, got %s", strerror(errno));
+ }
+}
+
+/* O_EMPTYPATH reopens the O_PATH fd through an empty path. */
+TEST_F(emptypath, reopens_opath_fd)
+{
+ int fd = openat(self->opath_fd, "", O_RDONLY | O_EMPTYPATH);
+
+ if (fd < 0 && errno == EINVAL)
+ SKIP(return, "O_EMPTYPATH not supported");
+
+ ASSERT_GE(fd, 0) {
+ TH_LOG("O_EMPTYPATH failed: %s", strerror(errno));
+ }
+ close(fd);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/openat2/helpers.h b/tools/testing/selftests/filesystems/openat2/helpers.h
new file mode 100644
index 000000000000..3f01fb68c5a6
--- /dev/null
+++ b/tools/testing/selftests/filesystems/openat2/helpers.h
@@ -0,0 +1,135 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Author: Aleksa Sarai <cyphar@cyphar.com>
+ * Copyright (C) 2018-2019 SUSE LLC.
+ * Copyright (C) 2026 Amutable GmbH
+ */
+
+#ifndef __RESOLVEAT_H__
+#define __RESOLVEAT_H__
+
+#define _GNU_SOURCE
+#include <stdint.h>
+#include <stdbool.h>
+#include <errno.h>
+#include <limits.h>
+#include <linux/types.h>
+#include <linux/unistd.h>
+#include <linux/openat2.h>
+#include "kselftest_harness.h"
+
+#define BUILD_BUG_ON(e) ((void)(sizeof(struct { int:(-!!(e)); })))
+
+#define OPEN_HOW_SIZE_VER0 24 /* sizeof first published struct */
+#define OPEN_HOW_SIZE_LATEST OPEN_HOW_SIZE_VER0
+
+__maybe_unused
+static bool needs_openat2(const struct open_how *how)
+{
+ return how->resolve != 0;
+}
+
+__maybe_unused
+static int raw_openat2(int dfd, const char *path, void *how, size_t size)
+{
+ int ret = syscall(__NR_openat2, dfd, path, how, size);
+
+ return ret >= 0 ? ret : -errno;
+}
+
+__maybe_unused
+static int sys_openat2(int dfd, const char *path, struct open_how *how)
+{
+ return raw_openat2(dfd, path, how, sizeof(*how));
+}
+
+__maybe_unused
+static int sys_openat(int dfd, const char *path, struct open_how *how)
+{
+ int ret = openat(dfd, path, how->flags, how->mode);
+
+ return ret >= 0 ? ret : -errno;
+}
+
+__maybe_unused
+static int sys_renameat2(int olddirfd, const char *oldpath,
+ int newdirfd, const char *newpath, unsigned int flags)
+{
+ int ret = syscall(__NR_renameat2, olddirfd, oldpath,
+ newdirfd, newpath, flags);
+
+ return ret >= 0 ? ret : -errno;
+}
+
+__maybe_unused
+static int touchat(int dfd, const char *path)
+{
+ int fd = openat(dfd, path, O_CREAT, 0700);
+
+ if (fd >= 0)
+ close(fd);
+ return fd;
+}
+
+__maybe_unused
+static char *fdreadlink(struct __test_metadata *_metadata, int fd)
+{
+ char *target, *tmp;
+
+ ASSERT_GT(asprintf(&tmp, "/proc/self/fd/%d", fd), 0);
+
+ target = malloc(PATH_MAX);
+ ASSERT_NE(target, NULL);
+ memset(target, 0, PATH_MAX);
+
+ ASSERT_GT(readlink(tmp, target, PATH_MAX), 0);
+
+ free(tmp);
+ return target;
+}
+
+__maybe_unused
+static bool fdequal(struct __test_metadata *_metadata, int fd,
+ int dfd, const char *path)
+{
+ char *fdpath, *dfdpath, *other;
+ bool cmp;
+
+ fdpath = fdreadlink(_metadata, fd);
+ dfdpath = fdreadlink(_metadata, dfd);
+
+ if (!path) {
+ ASSERT_GT(asprintf(&other, "%s", dfdpath), 0);
+ } else if (*path == '/') {
+ ASSERT_GT(asprintf(&other, "%s", path), 0);
+ } else {
+ ASSERT_GT(asprintf(&other, "%s/%s", dfdpath, path), 0);
+ }
+
+ cmp = !strcmp(fdpath, other);
+
+ free(fdpath);
+ free(dfdpath);
+ free(other);
+ return cmp;
+}
+
+static bool openat2_supported = false;
+
+__attribute__((constructor))
+static void __detect_openat2_supported(void)
+{
+ struct open_how how = {};
+ int fd;
+
+ BUILD_BUG_ON(sizeof(struct open_how) != OPEN_HOW_SIZE_VER0);
+
+ /* Check openat2(2) support. */
+ fd = sys_openat2(AT_FDCWD, ".", &how);
+ openat2_supported = (fd >= 0);
+
+ if (fd >= 0)
+ close(fd);
+}
+
+#endif /* __RESOLVEAT_H__ */
diff --git a/tools/testing/selftests/filesystems/openat2/openat2_test.c b/tools/testing/selftests/filesystems/openat2/openat2_test.c
new file mode 100644
index 000000000000..6f5afbe2d8d3
--- /dev/null
+++ b/tools/testing/selftests/filesystems/openat2/openat2_test.c
@@ -0,0 +1,362 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Author: Aleksa Sarai <cyphar@cyphar.com>
+ * Copyright (C) 2018-2019 SUSE LLC.
+ */
+
+#define _GNU_SOURCE
+#define __SANE_USERSPACE_TYPES__ // Use ll64
+#include <fcntl.h>
+#include <sched.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/mount.h>
+#include <stdlib.h>
+#include <stdbool.h>
+#include <string.h>
+
+#include "helpers.h"
+#include "kselftest_harness.h"
+
+/*
+ * O_LARGEFILE is set to 0 by glibc.
+ * XXX: This is wrong on {mips, parisc, powerpc, sparc}.
+ */
+#undef O_LARGEFILE
+#ifdef __aarch64__
+#define O_LARGEFILE 0x20000
+#else
+#define O_LARGEFILE 0x8000
+#endif
+
+struct open_how_ext {
+ struct open_how inner;
+ uint32_t extra1;
+ char pad1[128];
+ uint32_t extra2;
+ char pad2[128];
+ uint32_t extra3;
+};
+
+struct struct_test {
+ const char *name;
+ struct open_how_ext arg;
+ size_t size;
+ int err;
+};
+
+struct flag_test {
+ const char *name;
+ struct open_how how;
+ int err;
+};
+
+FIXTURE(openat2) {};
+
+FIXTURE_SETUP(openat2)
+{
+ if (!openat2_supported)
+ SKIP(return, "openat2(2) not supported");
+}
+
+FIXTURE_TEARDOWN(openat2) {}
+
+/*
+ * Verify that the struct size and misalignment handling for openat2(2) is
+ * correct, including that is_zeroed_user() works.
+ */
+TEST_F(openat2, struct_argument_sizes)
+{
+ int misalignments[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 17, 87 };
+ struct struct_test tests[] = {
+ /* Normal struct. */
+ { .name = "normal struct",
+ .arg.inner.flags = O_RDONLY,
+ .size = sizeof(struct open_how) },
+ /* Bigger struct, with zeroed out end. */
+ { .name = "bigger struct (zeroed out)",
+ .arg.inner.flags = O_RDONLY,
+ .size = sizeof(struct open_how_ext) },
+
+ /* TODO: Once expanded, check zero-padding. */
+
+ /* Smaller than version-0 struct. */
+ { .name = "zero-sized 'struct'",
+ .arg.inner.flags = O_RDONLY, .size = 0, .err = -EINVAL },
+ { .name = "smaller-than-v0 struct",
+ .arg.inner.flags = O_RDONLY,
+ .size = OPEN_HOW_SIZE_VER0 - 1, .err = -EINVAL },
+
+ /* Bigger struct, with non-zero trailing bytes. */
+ { .name = "bigger struct (non-zero data in first 'future field')",
+ .arg.inner.flags = O_RDONLY, .arg.extra1 = 0xdeadbeef,
+ .size = sizeof(struct open_how_ext), .err = -E2BIG },
+ { .name = "bigger struct (non-zero data in middle of 'future fields')",
+ .arg.inner.flags = O_RDONLY, .arg.extra2 = 0xfeedcafe,
+ .size = sizeof(struct open_how_ext), .err = -E2BIG },
+ { .name = "bigger struct (non-zero data at end of 'future fields')",
+ .arg.inner.flags = O_RDONLY, .arg.extra3 = 0xabad1dea,
+ .size = sizeof(struct open_how_ext), .err = -E2BIG },
+ };
+
+ for (int i = 0; i < ARRAY_SIZE(tests); i++) {
+ struct struct_test *test = &tests[i];
+ struct open_how_ext how_ext = test->arg;
+
+ for (int j = 0; j < ARRAY_SIZE(misalignments); j++) {
+ int fd, misalign = misalignments[j];
+ void *copy = NULL, *how_copy = &how_ext;
+ char *fdpath = NULL;
+
+ if (misalign) {
+ /*
+ * Explicitly misalign the structure copying it with the given
+ * (mis)alignment offset. The other data is set to be non-zero to
+ * make sure that non-zero bytes outside the struct aren't checked
+ *
+ * This is effectively to check that is_zeroed_user() works.
+ */
+ copy = malloc(misalign + sizeof(how_ext));
+ how_copy = copy + misalign;
+ memset(copy, 0xff, misalign);
+ memcpy(how_copy, &how_ext, sizeof(how_ext));
+ }
+
+ fd = raw_openat2(AT_FDCWD, ".", how_copy, test->size);
+ if (fd >= 0) {
+ fdpath = fdreadlink(_metadata, fd);
+ close(fd);
+ }
+
+ if (test->err >= 0) {
+ EXPECT_GE(fd, 0) {
+ TH_LOG("openat2 with %s [misalign=%d] should succeed, got %d (%s)",
+ test->name, misalign,
+ fd, strerror(-fd));
+ }
+ } else {
+ EXPECT_EQ(test->err, fd) {
+ if (fdpath)
+ TH_LOG("openat2 with %s [misalign=%d] should fail with %d (%s), got %d['%s']",
+ test->name, misalign,
+ test->err,
+ strerror(-test->err),
+ fd, fdpath);
+ else
+ TH_LOG("openat2 with %s [misalign=%d] should fail with %d (%s), got %d (%s)",
+ test->name, misalign,
+ test->err,
+ strerror(-test->err),
+ fd, strerror(-fd));
+ }
+ }
+
+ free(copy);
+ free(fdpath);
+ }
+ }
+}
+
+/* Verify openat2(2) flag and mode validation. */
+TEST_F(openat2, flag_validation)
+{
+ struct flag_test tests[] = {
+ /* O_TMPFILE is incompatible with O_PATH and O_CREAT. */
+ { .name = "incompatible flags (O_TMPFILE | O_PATH)",
+ .how.flags = O_TMPFILE | O_PATH | O_RDWR, .err = -EINVAL },
+ { .name = "incompatible flags (O_TMPFILE | O_CREAT)",
+ .how.flags = O_TMPFILE | O_CREAT | O_RDWR, .err = -EINVAL },
+
+ /* O_PATH only permits certain other flags to be set ... */
+ { .name = "compatible flags (O_PATH | O_CLOEXEC)",
+ .how.flags = O_PATH | O_CLOEXEC },
+ { .name = "compatible flags (O_PATH | O_DIRECTORY)",
+ .how.flags = O_PATH | O_DIRECTORY },
+ { .name = "compatible flags (O_PATH | O_NOFOLLOW)",
+ .how.flags = O_PATH | O_NOFOLLOW },
+ /* ... and others are absolutely not permitted. */
+ { .name = "incompatible flags (O_PATH | O_RDWR)",
+ .how.flags = O_PATH | O_RDWR, .err = -EINVAL },
+ { .name = "incompatible flags (O_PATH | O_CREAT)",
+ .how.flags = O_PATH | O_CREAT, .err = -EINVAL },
+ { .name = "incompatible flags (O_PATH | O_EXCL)",
+ .how.flags = O_PATH | O_EXCL, .err = -EINVAL },
+ { .name = "incompatible flags (O_PATH | O_NOCTTY)",
+ .how.flags = O_PATH | O_NOCTTY, .err = -EINVAL },
+ { .name = "incompatible flags (O_PATH | O_DIRECT)",
+ .how.flags = O_PATH | O_DIRECT, .err = -EINVAL },
+ { .name = "incompatible flags (O_PATH | O_LARGEFILE)",
+ .how.flags = O_PATH | O_LARGEFILE, .err = -EINVAL },
+
+ /* ->mode must only be set with O_{CREAT,TMPFILE}. */
+ { .name = "non-zero how.mode and O_RDONLY",
+ .how.flags = O_RDONLY, .how.mode = 0600, .err = -EINVAL },
+ { .name = "non-zero how.mode and O_PATH",
+ .how.flags = O_PATH, .how.mode = 0600, .err = -EINVAL },
+ { .name = "valid how.mode and O_CREAT",
+ .how.flags = O_CREAT, .how.mode = 0600 },
+ { .name = "valid how.mode and O_TMPFILE",
+ .how.flags = O_TMPFILE | O_RDWR, .how.mode = 0600 },
+ /* ->mode must only contain 0777 bits. */
+ { .name = "invalid how.mode and O_CREAT",
+ .how.flags = O_CREAT,
+ .how.mode = 0xFFFF, .err = -EINVAL },
+ { .name = "invalid (very large) how.mode and O_CREAT",
+ .how.flags = O_CREAT,
+ .how.mode = 0xC000000000000000ULL, .err = -EINVAL },
+ { .name = "invalid how.mode and O_TMPFILE",
+ .how.flags = O_TMPFILE | O_RDWR,
+ .how.mode = 0x1337, .err = -EINVAL },
+ { .name = "invalid (very large) how.mode and O_TMPFILE",
+ .how.flags = O_TMPFILE | O_RDWR,
+ .how.mode = 0x0000A00000000000ULL, .err = -EINVAL },
+
+ /* ->resolve flags must not conflict. */
+ { .name = "incompatible resolve flags (BENEATH | IN_ROOT)",
+ .how.flags = O_RDONLY,
+ .how.resolve = RESOLVE_BENEATH | RESOLVE_IN_ROOT,
+ .err = -EINVAL },
+
+ /* ->resolve must only contain RESOLVE_* flags. */
+ { .name = "invalid how.resolve and O_RDONLY",
+ .how.flags = O_RDONLY,
+ .how.resolve = 0x1337, .err = -EINVAL },
+ { .name = "invalid how.resolve and O_CREAT",
+ .how.flags = O_CREAT,
+ .how.resolve = 0x1337, .err = -EINVAL },
+ { .name = "invalid how.resolve and O_TMPFILE",
+ .how.flags = O_TMPFILE | O_RDWR,
+ .how.resolve = 0x1337, .err = -EINVAL },
+ { .name = "invalid how.resolve and O_PATH",
+ .how.flags = O_PATH,
+ .how.resolve = 0x1337, .err = -EINVAL },
+
+ /* currently unknown upper 32 bit rejected. */
+ { .name = "currently unknown bit (1 << 63)",
+ .how.flags = O_RDONLY | (1ULL << 63),
+ .how.resolve = 0, .err = -EINVAL },
+ };
+
+ for (int i = 0; i < ARRAY_SIZE(tests); i++) {
+ int fd, fdflags = -1;
+ char *path, *fdpath = NULL;
+ struct flag_test *test = &tests[i];
+
+ path = (test->how.flags & O_CREAT) ? "/tmp/ksft.openat2_tmpfile" : ".";
+ unlink(path);
+
+ fd = sys_openat2(AT_FDCWD, path, &test->how);
+ if (fd < 0 && fd == -EOPNOTSUPP) {
+ /*
+ * Skip the testcase if it failed because not supported
+ * by FS. (e.g. a valid O_TMPFILE combination on NFS)
+ */
+ TH_LOG("openat2 with %s not supported by FS -- skipping",
+ test->name);
+ continue;
+ }
+
+ if (test->err >= 0) {
+ EXPECT_GE(fd, 0) {
+ TH_LOG("openat2 with %s should succeed, got %d (%s)",
+ test->name, fd, strerror(-fd));
+ }
+ if (fd >= 0) {
+ int otherflags;
+
+ fdpath = fdreadlink(_metadata, fd);
+ fdflags = fcntl(fd, F_GETFL);
+ otherflags = fcntl(fd, F_GETFD);
+ close(fd);
+
+ ASSERT_GE(fdflags, 0);
+ ASSERT_GE(otherflags, 0);
+
+ /* O_CLOEXEC isn't shown in F_GETFL. */
+ if (otherflags & FD_CLOEXEC)
+ fdflags |= O_CLOEXEC;
+ /* O_CREAT is hidden from F_GETFL. */
+ if (test->how.flags & O_CREAT)
+ fdflags |= O_CREAT;
+ if (!(test->how.flags & O_LARGEFILE))
+ fdflags &= ~O_LARGEFILE;
+
+ EXPECT_EQ(fdflags, (int)test->how.flags) {
+ TH_LOG("openat2 with %s: flags mismatch %X != %llX",
+ test->name, fdflags,
+ (unsigned long long)test->how.flags);
+ }
+ }
+ } else {
+ EXPECT_EQ(test->err, fd) {
+ if (fd >= 0) {
+ fdpath = fdreadlink(_metadata, fd);
+ TH_LOG("openat2 with %s should fail with %d (%s), got %d['%s']",
+ test->name, test->err,
+ strerror(-test->err),
+ fd, fdpath);
+ } else {
+ TH_LOG("openat2 with %s should fail with %d (%s), got %d (%s)",
+ test->name, test->err,
+ strerror(-test->err),
+ fd, strerror(-fd));
+ }
+ }
+ if (fd >= 0)
+ close(fd);
+ }
+
+ free(fdpath);
+ }
+}
+
+#ifndef OPENAT2_REGULAR
+#define OPENAT2_REGULAR ((__u64)1 << 32)
+#endif
+
+#ifndef EFTYPE
+#define EFTYPE 134
+#endif
+
+/* Kernel-internal carrier for OPENAT2_REGULAR (see __O_REGULAR in fcntl.h). */
+#ifndef __O_REGULAR
+#define __O_REGULAR (1 << 30)
+#endif
+
+/* Verify that OPENAT2_REGULAR rejects non-regular files with EFTYPE. */
+TEST_F(openat2, regular_flag)
+{
+ struct open_how how = {
+ .flags = OPENAT2_REGULAR | O_RDONLY,
+ };
+ int fd;
+
+ fd = sys_openat2(AT_FDCWD, "/dev/null", &how);
+ if (fd == -ENOENT)
+ SKIP(return, "/dev/null does not exist");
+
+ EXPECT_EQ(-EFTYPE, fd) {
+ TH_LOG("openat2 with OPENAT2_REGULAR should fail with %d (%s), got %d (%s)",
+ -EFTYPE, strerror(EFTYPE), fd, strerror(-fd));
+ }
+ if (fd >= 0)
+ close(fd);
+}
+
+/* open()/openat() must keep ignoring the internal __O_REGULAR bit. */
+TEST(legacy_openat_ignores_o_regular)
+{
+ int fd;
+
+ fd = openat(AT_FDCWD, "/dev/null", O_RDONLY | __O_REGULAR);
+ if (fd < 0 && errno == ENOENT)
+ SKIP(return, "/dev/null does not exist");
+
+ ASSERT_GE(fd, 0) {
+ TH_LOG("legacy openat() must ignore the __O_REGULAR carrier bit, got errno %d (%s)",
+ errno, strerror(errno));
+ }
+ close(fd);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/openat2/rename_attack_test.c b/tools/testing/selftests/filesystems/openat2/rename_attack_test.c
new file mode 100644
index 000000000000..1f33c34f56be
--- /dev/null
+++ b/tools/testing/selftests/filesystems/openat2/rename_attack_test.c
@@ -0,0 +1,159 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Author: Aleksa Sarai <cyphar@cyphar.com>
+ * Copyright (C) 2018-2019 SUSE LLC.
+ */
+
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <sched.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/mount.h>
+#include <sys/mman.h>
+#include <sys/prctl.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdbool.h>
+#include <string.h>
+#include <syscall.h>
+#include <limits.h>
+#include <unistd.h>
+
+#include "helpers.h"
+#include "kselftest_harness.h"
+
+#define ROUNDS 400000
+
+/* Swap @dirfd/@a and @dirfd/@b constantly. Parent must kill this process. */
+pid_t spawn_attack(struct __test_metadata *_metadata,
+ int dirfd, char *a, char *b)
+{
+ pid_t child = fork();
+ if (child != 0)
+ return child;
+
+ /* If the parent (the test process) dies, kill ourselves too. */
+ ASSERT_EQ(prctl(PR_SET_PDEATHSIG, SIGKILL), 0);
+
+ /* Swap @a and @b. */
+ for (;;)
+ renameat2(dirfd, a, dirfd, b, RENAME_EXCHANGE);
+ exit(1);
+}
+
+/*
+ * Construct a test directory with the following structure:
+ *
+ * root/
+ * |-- a/
+ * | `-- c/
+ * `-- b/
+ */
+FIXTURE(rename_attack) {
+ int dfd;
+ int afd;
+ pid_t child;
+};
+
+FIXTURE_SETUP(rename_attack)
+{
+ char dirname[] = "/tmp/ksft-openat2-rename-attack.XXXXXX";
+
+ self->dfd = -1;
+ self->afd = -1;
+ self->child = 0;
+
+ /* Make the top-level directory. */
+ ASSERT_NE(mkdtemp(dirname), NULL);
+ self->dfd = open(dirname, O_PATH | O_DIRECTORY);
+ ASSERT_GE(self->dfd, 0);
+
+ ASSERT_EQ(mkdirat(self->dfd, "a", 0755), 0);
+ ASSERT_EQ(mkdirat(self->dfd, "b", 0755), 0);
+ ASSERT_EQ(mkdirat(self->dfd, "a/c", 0755), 0);
+
+ self->afd = openat(self->dfd, "a", O_PATH);
+ ASSERT_GE(self->afd, 0);
+
+ self->child = spawn_attack(_metadata, self->dfd, "a/c", "b");
+ ASSERT_GT(self->child, 0);
+}
+
+FIXTURE_TEARDOWN(rename_attack)
+{
+ if (self->child > 0)
+ kill(self->child, SIGKILL);
+ if (self->afd >= 0)
+ close(self->afd);
+ if (self->dfd >= 0)
+ close(self->dfd);
+}
+
+FIXTURE_VARIANT(rename_attack) {
+ int resolve;
+ const char *name;
+};
+
+FIXTURE_VARIANT_ADD(rename_attack, resolve_beneath) {
+ .resolve = RESOLVE_BENEATH,
+ .name = "RESOLVE_BENEATH",
+};
+
+FIXTURE_VARIANT_ADD(rename_attack, resolve_in_root) {
+ .resolve = RESOLVE_IN_ROOT,
+ .name = "RESOLVE_IN_ROOT",
+};
+
+TEST_F_TIMEOUT(rename_attack, test, 120)
+{
+ int escapes = 0, successes = 0, other_errs = 0, exdevs = 0, eagains = 0;
+ char *victim_path = "c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../../c/../..";
+ struct open_how how = {
+ .flags = O_PATH,
+ .resolve = variant->resolve,
+ };
+
+ if (!openat2_supported) {
+ how.resolve = 0;
+ TH_LOG("openat2(2) unsupported -- using openat(2) instead");
+ }
+
+ for (int i = 0; i < ROUNDS; i++) {
+ int fd;
+
+ if (openat2_supported)
+ fd = sys_openat2(self->afd, victim_path, &how);
+ else
+ fd = sys_openat(self->afd, victim_path, &how);
+
+ if (fd < 0) {
+ if (fd == -EAGAIN)
+ eagains++;
+ else if (fd == -EXDEV)
+ exdevs++;
+ else if (fd == -ENOENT)
+ escapes++; /* escaped outside and got ENOENT... */
+ else
+ other_errs++; /* unexpected error */
+ } else {
+ if (fdequal(_metadata, fd, self->afd, NULL))
+ successes++;
+ else
+ escapes++; /* we got an unexpected fd */
+ }
+ if (fd >= 0)
+ close(fd);
+ }
+
+ TH_LOG("non-escapes: EAGAIN=%d EXDEV=%d E<other>=%d success=%d",
+ eagains, exdevs, other_errs, successes);
+ ASSERT_EQ(escapes, 0) {
+ TH_LOG("rename attack with %s (%d runs, got %d escapes)",
+ variant->name, ROUNDS, escapes);
+ }
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/openat2/resolve_test.c b/tools/testing/selftests/filesystems/openat2/resolve_test.c
new file mode 100644
index 000000000000..eacde59ce158
--- /dev/null
+++ b/tools/testing/selftests/filesystems/openat2/resolve_test.c
@@ -0,0 +1,561 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ * Author: Aleksa Sarai <cyphar@cyphar.com>
+ * Copyright (C) 2018-2019 SUSE LLC.
+ */
+
+#define _GNU_SOURCE
+#include <fcntl.h>
+#include <sched.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/mount.h>
+#include <stdlib.h>
+#include <stdbool.h>
+#include <string.h>
+
+#include "helpers.h"
+#include "kselftest_harness.h"
+
+struct resolve_test {
+ const char *name;
+ const char *dir;
+ const char *path;
+ struct open_how how;
+ bool pass;
+ union {
+ int err;
+ const char *path;
+ } out;
+};
+
+/*
+ * Verify a single resolve test case. This must be called from within a TEST_F
+ * function with _metadata in scope.
+ */
+static void verify_resolve_test(struct __test_metadata *_metadata,
+ int rootfd, int hardcoded_fd,
+ const struct resolve_test *test)
+{
+ struct open_how how = test->how;
+ int dfd, fd;
+ char *fdpath = NULL;
+
+ /* Auto-set O_PATH. */
+ if (!(how.flags & O_CREAT))
+ how.flags |= O_PATH;
+
+ if (test->dir)
+ dfd = openat(rootfd, test->dir, O_PATH | O_DIRECTORY);
+ else
+ dfd = dup(rootfd);
+ ASSERT_GE(dfd, 0) TH_LOG("failed to open dir '%s': %m", test->dir ?: ".");
+ ASSERT_EQ(dup2(dfd, hardcoded_fd), hardcoded_fd);
+
+ fd = sys_openat2(dfd, test->path, &how);
+
+ if (test->pass) {
+ EXPECT_GE(fd, 0) {
+ TH_LOG("%s: expected success, got %d (%s)",
+ test->name, fd, strerror(-fd));
+ }
+ if (fd >= 0) {
+ EXPECT_TRUE(fdequal(_metadata, fd, rootfd, test->out.path)) {
+ fdpath = fdreadlink(_metadata, fd);
+ TH_LOG("%s: wrong path '%s', expected '%s'",
+ test->name, fdpath,
+ test->out.path ?: ".");
+ free(fdpath);
+ }
+ }
+ } else {
+ EXPECT_EQ(test->out.err, fd) {
+ if (fd >= 0) {
+ fdpath = fdreadlink(_metadata, fd);
+ TH_LOG("%s: expected %d (%s), got %d['%s']",
+ test->name, test->out.err,
+ strerror(-test->out.err), fd, fdpath);
+ free(fdpath);
+ } else {
+ TH_LOG("%s: expected %d (%s), got %d (%s)",
+ test->name, test->out.err,
+ strerror(-test->out.err),
+ fd, strerror(-fd));
+ }
+ }
+ }
+
+ if (fd >= 0)
+ close(fd);
+ close(dfd);
+}
+
+/*
+ * Construct a test directory with the following structure:
+ *
+ * root/
+ * |-- procexe -> /proc/self/exe
+ * |-- procroot -> /proc/self/root
+ * |-- root/
+ * |-- mnt/ [mountpoint]
+ * | |-- self -> ../mnt/
+ * | `-- absself -> /mnt/
+ * |-- etc/
+ * | `-- passwd
+ * |-- creatlink -> /newfile3
+ * |-- reletc -> etc/
+ * |-- relsym -> etc/passwd
+ * |-- absetc -> /etc/
+ * |-- abssym -> /etc/passwd
+ * |-- abscheeky -> /cheeky
+ * `-- cheeky/
+ * |-- absself -> /
+ * |-- self -> ../../root/
+ * |-- garbageself -> /../../root/
+ * |-- passwd -> ../cheeky/../etc/../etc/passwd
+ * |-- abspasswd -> /../cheeky/../etc/../etc/passwd
+ * |-- dotdotlink -> ../../../../../../../../../../../../../../etc/passwd
+ * `-- garbagelink -> /../../../../../../../../../../../../../../etc/passwd
+ */
+FIXTURE(openat2_resolve) {
+ int rootfd;
+ int hardcoded_fd;
+ char *hardcoded_fdpath;
+ char *procselfexe;
+};
+
+FIXTURE_SETUP(openat2_resolve)
+{
+ char dirname[] = "/tmp/ksft-openat2-testdir.XXXXXX";
+ int dfd, tmpfd;
+
+ self->rootfd = -1;
+ self->hardcoded_fd = -1;
+ self->hardcoded_fdpath = NULL;
+ self->procselfexe = NULL;
+
+ /* NOTE: We should be checking for CAP_SYS_ADMIN here... */
+ if (geteuid() != 0)
+ SKIP(return, "all tests require euid == 0");
+ if (!openat2_supported)
+ SKIP(return, "openat2(2) not supported");
+
+ /* Unshare and make /tmp a new directory. */
+ ASSERT_EQ(unshare(CLONE_NEWNS), 0);
+ ASSERT_EQ(mount("", "/tmp", "", MS_PRIVATE, ""), 0);
+
+ /* Make the top-level directory. */
+ ASSERT_NE(mkdtemp(dirname), NULL);
+ dfd = open(dirname, O_PATH | O_DIRECTORY);
+ ASSERT_GE(dfd, 0);
+
+ /* A sub-directory which is actually used for tests. */
+ ASSERT_EQ(mkdirat(dfd, "root", 0755), 0);
+ tmpfd = openat(dfd, "root", O_PATH | O_DIRECTORY);
+ ASSERT_GE(tmpfd, 0);
+ close(dfd);
+ dfd = tmpfd;
+
+ ASSERT_EQ(symlinkat("/proc/self/exe", dfd, "procexe"), 0);
+ ASSERT_EQ(symlinkat("/proc/self/root", dfd, "procroot"), 0);
+ ASSERT_EQ(mkdirat(dfd, "root", 0755), 0);
+
+ /* There is no mountat(2), so use chdir. */
+ ASSERT_EQ(mkdirat(dfd, "mnt", 0755), 0);
+ ASSERT_EQ(fchdir(dfd), 0);
+ ASSERT_EQ(mount("tmpfs", "./mnt", "tmpfs", MS_NOSUID | MS_NODEV, ""), 0);
+ ASSERT_EQ(symlinkat("../mnt/", dfd, "mnt/self"), 0);
+ ASSERT_EQ(symlinkat("/mnt/", dfd, "mnt/absself"), 0);
+
+ ASSERT_EQ(mkdirat(dfd, "etc", 0755), 0);
+ ASSERT_GE(touchat(dfd, "etc/passwd"), 0);
+
+ ASSERT_EQ(symlinkat("/newfile3", dfd, "creatlink"), 0);
+ ASSERT_EQ(symlinkat("etc/", dfd, "reletc"), 0);
+ ASSERT_EQ(symlinkat("etc/passwd", dfd, "relsym"), 0);
+ ASSERT_EQ(symlinkat("/etc/", dfd, "absetc"), 0);
+ ASSERT_EQ(symlinkat("/etc/passwd", dfd, "abssym"), 0);
+ ASSERT_EQ(symlinkat("/cheeky", dfd, "abscheeky"), 0);
+
+ ASSERT_EQ(mkdirat(dfd, "cheeky", 0755), 0);
+
+ ASSERT_EQ(symlinkat("/", dfd, "cheeky/absself"), 0);
+ ASSERT_EQ(symlinkat("../../root/", dfd, "cheeky/self"), 0);
+ ASSERT_EQ(symlinkat("/../../root/", dfd, "cheeky/garbageself"), 0);
+
+ ASSERT_EQ(symlinkat("../cheeky/../etc/../etc/passwd",
+ dfd, "cheeky/passwd"), 0);
+ ASSERT_EQ(symlinkat("/../cheeky/../etc/../etc/passwd",
+ dfd, "cheeky/abspasswd"), 0);
+
+ ASSERT_EQ(symlinkat("../../../../../../../../../../../../../../etc/passwd",
+ dfd, "cheeky/dotdotlink"), 0);
+ ASSERT_EQ(symlinkat("/../../../../../../../../../../../../../../etc/passwd",
+ dfd, "cheeky/garbagelink"), 0);
+
+ self->rootfd = dfd;
+
+ self->hardcoded_fd = open("/dev/null", O_RDONLY);
+ ASSERT_GE(self->hardcoded_fd, 0);
+ ASSERT_GE(asprintf(&self->hardcoded_fdpath, "self/fd/%d",
+ self->hardcoded_fd), 0);
+ ASSERT_GE(asprintf(&self->procselfexe, "/proc/%d/exe", getpid()), 0);
+}
+
+FIXTURE_TEARDOWN(openat2_resolve)
+{
+ free(self->procselfexe);
+ free(self->hardcoded_fdpath);
+ if (self->hardcoded_fd >= 0)
+ close(self->hardcoded_fd);
+ if (self->rootfd >= 0)
+ close(self->rootfd);
+}
+
+/* Attempts to cross the dirfd should be blocked with -EXDEV. */
+TEST_F(openat2_resolve, resolve_beneath)
+{
+ struct resolve_test tests[] = {
+ /* Attempts to cross dirfd should be blocked. */
+ { .name = "[beneath] jump to /",
+ .path = "/", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] absolute link to $root",
+ .path = "cheeky/absself", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] chained absolute links to $root",
+ .path = "abscheeky/absself", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] jump outside $root",
+ .path = "..", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] temporary jump outside $root",
+ .path = "../root/", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] symlink temporary jump outside $root",
+ .path = "cheeky/self", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] chained symlink temporary jump outside $root",
+ .path = "abscheeky/self", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] garbage links to $root",
+ .path = "cheeky/garbageself", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] chained garbage links to $root",
+ .path = "abscheeky/garbageself", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ /* Only relative paths that stay inside dirfd should work. */
+ { .name = "[beneath] ordinary path to 'root'",
+ .path = "root", .how.resolve = RESOLVE_BENEATH,
+ .out.path = "root", .pass = true },
+ { .name = "[beneath] ordinary path to 'etc'",
+ .path = "etc", .how.resolve = RESOLVE_BENEATH,
+ .out.path = "etc", .pass = true },
+ { .name = "[beneath] ordinary path to 'etc/passwd'",
+ .path = "etc/passwd", .how.resolve = RESOLVE_BENEATH,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[beneath] relative symlink inside $root",
+ .path = "relsym", .how.resolve = RESOLVE_BENEATH,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[beneath] chained-'..' relative symlink inside $root",
+ .path = "cheeky/passwd", .how.resolve = RESOLVE_BENEATH,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[beneath] absolute symlink component outside $root",
+ .path = "abscheeky/passwd", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] absolute symlink target outside $root",
+ .path = "abssym", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] absolute path outside $root",
+ .path = "/etc/passwd", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] cheeky absolute path outside $root",
+ .path = "cheeky/abspasswd", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] chained cheeky absolute path outside $root",
+ .path = "abscheeky/abspasswd", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ /* Tricky paths should fail. */
+ { .name = "[beneath] tricky '..'-chained symlink outside $root",
+ .path = "cheeky/dotdotlink", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] tricky absolute + '..'-chained symlink outside $root",
+ .path = "abscheeky/dotdotlink", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] tricky garbage link outside $root",
+ .path = "cheeky/garbagelink", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[beneath] tricky absolute + garbage link outside $root",
+ .path = "abscheeky/garbagelink", .how.resolve = RESOLVE_BENEATH,
+ .out.err = -EXDEV, .pass = false },
+ };
+
+ for (int i = 0; i < ARRAY_SIZE(tests); i++)
+ verify_resolve_test(_metadata, self->rootfd,
+ self->hardcoded_fd, &tests[i]);
+}
+
+/* All attempts to cross the dirfd will be scoped-to-root. */
+TEST_F(openat2_resolve, resolve_in_root)
+{
+ struct resolve_test tests[] = {
+ { .name = "[in_root] jump to /",
+ .path = "/", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = NULL, .pass = true },
+ { .name = "[in_root] absolute symlink to /root",
+ .path = "cheeky/absself", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = NULL, .pass = true },
+ { .name = "[in_root] chained absolute symlinks to /root",
+ .path = "abscheeky/absself", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = NULL, .pass = true },
+ { .name = "[in_root] '..' at root",
+ .path = "..", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = NULL, .pass = true },
+ { .name = "[in_root] '../root' at root",
+ .path = "../root/", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "root", .pass = true },
+ { .name = "[in_root] relative symlink containing '..' above root",
+ .path = "cheeky/self", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "root", .pass = true },
+ { .name = "[in_root] garbage link to /root",
+ .path = "cheeky/garbageself", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "root", .pass = true },
+ { .name = "[in_root] chained garbage links to /root",
+ .path = "abscheeky/garbageself", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "root", .pass = true },
+ { .name = "[in_root] relative path to 'root'",
+ .path = "root", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "root", .pass = true },
+ { .name = "[in_root] relative path to 'etc'",
+ .path = "etc", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc", .pass = true },
+ { .name = "[in_root] relative path to 'etc/passwd'",
+ .path = "etc/passwd", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] relative symlink to 'etc/passwd'",
+ .path = "relsym", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] chained-'..' relative symlink to 'etc/passwd'",
+ .path = "cheeky/passwd", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] chained-'..' absolute + relative symlink to 'etc/passwd'",
+ .path = "abscheeky/passwd", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] absolute symlink to 'etc/passwd'",
+ .path = "abssym", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] absolute path 'etc/passwd'",
+ .path = "/etc/passwd", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] cheeky absolute path 'etc/passwd'",
+ .path = "cheeky/abspasswd", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] chained cheeky absolute path 'etc/passwd'",
+ .path = "abscheeky/abspasswd", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] tricky '..'-chained symlink outside $root",
+ .path = "cheeky/dotdotlink", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] tricky absolute + '..'-chained symlink outside $root",
+ .path = "abscheeky/dotdotlink", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] tricky absolute path + absolute + '..'-chained symlink outside $root",
+ .path = "/../../../../abscheeky/dotdotlink", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] tricky garbage link outside $root",
+ .path = "cheeky/garbagelink", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] tricky absolute + garbage link outside $root",
+ .path = "abscheeky/garbagelink", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ { .name = "[in_root] tricky absolute path + absolute + garbage link outside $root",
+ .path = "/../../../../abscheeky/garbagelink", .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "etc/passwd", .pass = true },
+ /* O_CREAT should handle trailing symlinks correctly. */
+ { .name = "[in_root] O_CREAT of relative path inside $root",
+ .path = "newfile1", .how.flags = O_CREAT,
+ .how.mode = 0700,
+ .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "newfile1", .pass = true },
+ { .name = "[in_root] O_CREAT of absolute path",
+ .path = "/newfile2", .how.flags = O_CREAT,
+ .how.mode = 0700,
+ .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "newfile2", .pass = true },
+ { .name = "[in_root] O_CREAT of tricky symlink outside root",
+ .path = "/creatlink", .how.flags = O_CREAT,
+ .how.mode = 0700,
+ .how.resolve = RESOLVE_IN_ROOT,
+ .out.path = "newfile3", .pass = true },
+ };
+
+ for (int i = 0; i < ARRAY_SIZE(tests); i++)
+ verify_resolve_test(_metadata, self->rootfd,
+ self->hardcoded_fd, &tests[i]);
+}
+
+/* Crossing mount boundaries should be blocked. */
+TEST_F(openat2_resolve, resolve_no_xdev)
+{
+ struct resolve_test tests[] = {
+ /* Crossing *down* into a mountpoint is disallowed. */
+ { .name = "[no_xdev] cross into $mnt",
+ .path = "mnt", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[no_xdev] cross into $mnt/",
+ .path = "mnt/", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[no_xdev] cross into $mnt/.",
+ .path = "mnt/.", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ /* Crossing *up* out of a mountpoint is disallowed. */
+ { .name = "[no_xdev] goto mountpoint root",
+ .dir = "mnt", .path = ".", .how.resolve = RESOLVE_NO_XDEV,
+ .out.path = "mnt", .pass = true },
+ { .name = "[no_xdev] cross up through '..'",
+ .dir = "mnt", .path = "..", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[no_xdev] temporary cross up through '..'",
+ .dir = "mnt", .path = "../mnt", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[no_xdev] temporary relative symlink cross up",
+ .dir = "mnt", .path = "self", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[no_xdev] temporary absolute symlink cross up",
+ .dir = "mnt", .path = "absself", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ /* Jumping to "/" is ok, but later components cannot cross. */
+ { .name = "[no_xdev] jump to / directly",
+ .dir = "mnt", .path = "/", .how.resolve = RESOLVE_NO_XDEV,
+ .out.path = "/", .pass = true },
+ { .name = "[no_xdev] jump to / (from /) directly",
+ .dir = "/", .path = "/", .how.resolve = RESOLVE_NO_XDEV,
+ .out.path = "/", .pass = true },
+ { .name = "[no_xdev] jump to / then proc",
+ .path = "/proc/1", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[no_xdev] jump to / then tmp",
+ .path = "/tmp", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ /* Magic-links are blocked since they can switch vfsmounts. */
+ { .name = "[no_xdev] cross through magic-link to self/root",
+ .dir = "/proc", .path = "self/root", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ { .name = "[no_xdev] cross through magic-link to self/cwd",
+ .dir = "/proc", .path = "self/cwd", .how.resolve = RESOLVE_NO_XDEV,
+ .out.err = -EXDEV, .pass = false },
+ /* Except magic-link jumps inside the same vfsmount. */
+ { .name = "[no_xdev] jump through magic-link to same procfs",
+ .dir = "/proc", .path = self->hardcoded_fdpath, .how.resolve = RESOLVE_NO_XDEV,
+ .out.path = "/proc", .pass = true, },
+ };
+
+ for (int i = 0; i < ARRAY_SIZE(tests); i++)
+ verify_resolve_test(_metadata, self->rootfd,
+ self->hardcoded_fd, &tests[i]);
+}
+
+/* Procfs-style magic-link resolution should be blocked. */
+TEST_F(openat2_resolve, resolve_no_magiclinks)
+{
+ struct resolve_test tests[] = {
+ /* Regular symlinks should work. */
+ { .name = "[no_magiclinks] ordinary relative symlink",
+ .path = "relsym", .how.resolve = RESOLVE_NO_MAGICLINKS,
+ .out.path = "etc/passwd", .pass = true },
+ /* Magic-links should not work. */
+ { .name = "[no_magiclinks] symlink to magic-link",
+ .path = "procexe", .how.resolve = RESOLVE_NO_MAGICLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_magiclinks] normal path to magic-link",
+ .path = "/proc/self/exe", .how.resolve = RESOLVE_NO_MAGICLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_magiclinks] normal path to magic-link with O_NOFOLLOW",
+ .path = "/proc/self/exe", .how.flags = O_NOFOLLOW,
+ .how.resolve = RESOLVE_NO_MAGICLINKS,
+ .out.path = self->procselfexe, .pass = true },
+ { .name = "[no_magiclinks] symlink to magic-link path component",
+ .path = "procroot/etc", .how.resolve = RESOLVE_NO_MAGICLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_magiclinks] magic-link path component",
+ .path = "/proc/self/root/etc", .how.resolve = RESOLVE_NO_MAGICLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_magiclinks] magic-link path component with O_NOFOLLOW",
+ .path = "/proc/self/root/etc", .how.flags = O_NOFOLLOW,
+ .how.resolve = RESOLVE_NO_MAGICLINKS,
+ .out.err = -ELOOP, .pass = false },
+ };
+
+ for (int i = 0; i < ARRAY_SIZE(tests); i++)
+ verify_resolve_test(_metadata, self->rootfd,
+ self->hardcoded_fd, &tests[i]);
+}
+
+/* All symlink resolution should be blocked. */
+TEST_F(openat2_resolve, resolve_no_symlinks)
+{
+ struct resolve_test tests[] = {
+ /* Normal paths should work. */
+ { .name = "[no_symlinks] ordinary path to '.'",
+ .path = ".", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.path = NULL, .pass = true },
+ { .name = "[no_symlinks] ordinary path to 'root'",
+ .path = "root", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.path = "root", .pass = true },
+ { .name = "[no_symlinks] ordinary path to 'etc'",
+ .path = "etc", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.path = "etc", .pass = true },
+ { .name = "[no_symlinks] ordinary path to 'etc/passwd'",
+ .path = "etc/passwd", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.path = "etc/passwd", .pass = true },
+ /* Regular symlinks are blocked. */
+ { .name = "[no_symlinks] relative symlink target",
+ .path = "relsym", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_symlinks] relative symlink component",
+ .path = "reletc/passwd", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_symlinks] absolute symlink target",
+ .path = "abssym", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_symlinks] absolute symlink component",
+ .path = "absetc/passwd", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_symlinks] cheeky garbage link",
+ .path = "cheeky/garbagelink", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_symlinks] cheeky absolute + garbage link",
+ .path = "abscheeky/garbagelink", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_symlinks] cheeky absolute + absolute symlink",
+ .path = "abscheeky/absself", .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.err = -ELOOP, .pass = false },
+ /* Trailing symlinks with NO_FOLLOW. */
+ { .name = "[no_symlinks] relative symlink with O_NOFOLLOW",
+ .path = "relsym", .how.flags = O_NOFOLLOW,
+ .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.path = "relsym", .pass = true },
+ { .name = "[no_symlinks] absolute symlink with O_NOFOLLOW",
+ .path = "abssym", .how.flags = O_NOFOLLOW,
+ .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.path = "abssym", .pass = true },
+ { .name = "[no_symlinks] trailing symlink with O_NOFOLLOW",
+ .path = "cheeky/garbagelink", .how.flags = O_NOFOLLOW,
+ .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.path = "cheeky/garbagelink", .pass = true },
+ { .name = "[no_symlinks] multiple symlink components with O_NOFOLLOW",
+ .path = "abscheeky/absself", .how.flags = O_NOFOLLOW,
+ .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.err = -ELOOP, .pass = false },
+ { .name = "[no_symlinks] multiple symlink (and garbage link) components with O_NOFOLLOW",
+ .path = "abscheeky/garbagelink", .how.flags = O_NOFOLLOW,
+ .how.resolve = RESOLVE_NO_SYMLINKS,
+ .out.err = -ELOOP, .pass = false },
+ };
+
+ for (int i = 0; i < ARRAY_SIZE(tests); i++)
+ verify_resolve_test(_metadata, self->rootfd,
+ self->hardcoded_fd, &tests[i]);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/overlayfs/.gitignore b/tools/testing/selftests/filesystems/overlayfs/.gitignore
index e23a18c8b37f..077f7a128168 100644
--- a/tools/testing/selftests/filesystems/overlayfs/.gitignore
+++ b/tools/testing/selftests/filesystems/overlayfs/.gitignore
@@ -1,3 +1,4 @@
# SPDX-License-Identifier: GPL-2.0-only
dev_in_maps
set_layers_via_fds
+idmapped_mounts
diff --git a/tools/testing/selftests/filesystems/overlayfs/Makefile b/tools/testing/selftests/filesystems/overlayfs/Makefile
index d3ad4a77db9b..b3185f684add 100644
--- a/tools/testing/selftests/filesystems/overlayfs/Makefile
+++ b/tools/testing/selftests/filesystems/overlayfs/Makefile
@@ -8,7 +8,9 @@ LOCAL_HDRS += ../wrappers.h log.h
TEST_GEN_PROGS := dev_in_maps
TEST_GEN_PROGS += set_layers_via_fds
+TEST_GEN_PROGS += idmapped_mounts
include ../../lib.mk
$(OUTPUT)/set_layers_via_fds: ../utils.c
+$(OUTPUT)/idmapped_mounts: ../utils.c
diff --git a/tools/testing/selftests/filesystems/overlayfs/idmapped_mounts.c b/tools/testing/selftests/filesystems/overlayfs/idmapped_mounts.c
new file mode 100644
index 000000000000..44a75839f4ed
--- /dev/null
+++ b/tools/testing/selftests/filesystems/overlayfs/idmapped_mounts.c
@@ -0,0 +1,501 @@
+// SPDX-License-Identifier: GPL-2.0
+#define _GNU_SOURCE
+
+#include <fcntl.h>
+#include <limits.h>
+#include <sched.h>
+#include <stdio.h>
+#include <unistd.h>
+#include <sys/stat.h>
+#include <sys/syscall.h>
+
+#include <linux/mount.h>
+#include <linux/types.h>
+
+#include "kselftest_harness.h"
+#include "../wrappers.h"
+#include "../utils.h"
+
+/*
+ * An idmapping that maps the mount-visible id range [0, ID_RANGE) onto the
+ * host/overlay-final id range [ID_HOST, ID_HOST + ID_RANGE). Through such an
+ * idmapped overlay mount, an overlay-final id of ID_HOST + n is reported as n,
+ * and an id of n requested through the mount is stored as ID_HOST + n.
+ */
+#define ID_NS 0
+#define ID_HOST 10000
+#define ID_RANGE 10000
+
+/*
+ * For the composition test the lower layer's on-disk ids live in a
+ * separate range and are mapped by an idmapped lower layer onto the
+ * overlay-final range [ID_HOST, ID_HOST + ID_RANGE).
+ */
+#define LAYER_HOST 20000
+
+#ifndef MOUNT_ATTR_IDMAP
+#define MOUNT_ATTR_IDMAP 0x00100000
+#endif
+
+#ifndef __NR_mount_setattr
+#define __NR_mount_setattr 442
+#endif
+
+static inline int sys_mount_setattr(int dfd, const char *path,
+ unsigned int flags,
+ struct mount_attr *attr, size_t size)
+{
+ return syscall(__NR_mount_setattr, dfd, path, flags, attr, size);
+}
+
+static bool ovl_supported(void)
+{
+ int fd = sys_fsopen("overlay", 0);
+
+ if (fd < 0)
+ return false;
+ close(fd);
+ return true;
+}
+
+/* base/{l,u,w} owned by ID_HOST so they map to ID_NS through the idmap. */
+static int setup_layers(const char *base)
+{
+ static const char *sub[] = { "", "/l", "/u", "/w" };
+ char path[PATH_MAX];
+
+ for (size_t i = 0; i < ARRAY_SIZE(sub); i++) {
+ snprintf(path, sizeof(path), "%s%s", base, sub[i]);
+ if (mkdir(path, 0755) && errno != EEXIST)
+ return -1;
+ if (i && chown(path, ID_HOST, ID_HOST))
+ return -1;
+ }
+ return 0;
+}
+
+static int ovl_mount(const char *base, bool nfs_export)
+{
+ char lower[PATH_MAX], upper[PATH_MAX], work[PATH_MAX];
+ int fsfd, ovl;
+
+ snprintf(lower, sizeof(lower), "%s/l", base);
+ snprintf(upper, sizeof(upper), "%s/u", base);
+ snprintf(work, sizeof(work), "%s/w", base);
+
+ fsfd = sys_fsopen("overlay", 0);
+ if (fsfd < 0)
+ return -1;
+
+ if (sys_fsconfig(fsfd, FSCONFIG_SET_STRING, "source", "test", 0) ||
+ sys_fsconfig(fsfd, FSCONFIG_SET_STRING, "lowerdir", lower, 0) ||
+ sys_fsconfig(fsfd, FSCONFIG_SET_STRING, "upperdir", upper, 0) ||
+ sys_fsconfig(fsfd, FSCONFIG_SET_STRING, "workdir", work, 0))
+ goto err;
+ if (nfs_export &&
+ (sys_fsconfig(fsfd, FSCONFIG_SET_STRING, "index", "on", 0) ||
+ sys_fsconfig(fsfd, FSCONFIG_SET_STRING, "nfs_export", "on", 0)))
+ goto err;
+ if (sys_fsconfig(fsfd, FSCONFIG_CMD_CREATE, NULL, NULL, 0))
+ goto err;
+
+ ovl = sys_fsmount(fsfd, 0, 0);
+ close(fsfd);
+ return ovl;
+err:
+ close(fsfd);
+ return -1;
+}
+
+/* Idmap the (still detached, not yet visible) overlay mount @mfd. */
+static int ovl_idmap(int mfd)
+{
+ struct mount_attr attr = {
+ .attr_set = MOUNT_ATTR_IDMAP,
+ };
+ int ret, userns_fd;
+
+ /*
+ * get_userns_fd(fs_id, mount_id, range): a file whose filesystem id
+ * is fs_id + n is shown through the idmapped mount as mount_id + n.
+ * Here the overlay-final (fs side) range is [ID_HOST, ..) and the
+ * caller-visible (mount side) range is [ID_NS, ..).
+ */
+ userns_fd = get_userns_fd(ID_HOST, ID_NS, ID_RANGE);
+ if (userns_fd < 0)
+ return -1;
+
+ attr.userns_fd = userns_fd;
+ ret = sys_mount_setattr(mfd, "", AT_EMPTY_PATH, &attr, sizeof(attr));
+ close(userns_fd);
+ return ret;
+}
+
+/* Clone @path into a detached, idmapped mount usable as an overlay layer. */
+static int idmapped_layer_fd(const char *path, int nsid, int hostid, int range)
+{
+ struct mount_attr attr = {
+ .attr_set = MOUNT_ATTR_IDMAP,
+ };
+ int fd_tree, userns_fd;
+
+ fd_tree = sys_open_tree(AT_FDCWD, path,
+ OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ if (fd_tree < 0)
+ return -1;
+ userns_fd = get_userns_fd(nsid, hostid, range);
+ if (userns_fd < 0) {
+ close(fd_tree);
+ return -1;
+ }
+ attr.userns_fd = userns_fd;
+ if (sys_mount_setattr(fd_tree, "", AT_EMPTY_PATH, &attr,
+ sizeof(attr))) {
+ close(userns_fd);
+ close(fd_tree);
+ return -1;
+ }
+ close(userns_fd);
+ return fd_tree;
+}
+
+/* Overlay with a layer passed by fd (idmapped) plus a plain upper/work. */
+static int ovl_mount_lower_fd(const char *upper, const char *work, int fd_lower)
+{
+ int fsfd, ovl;
+
+ fsfd = sys_fsopen("overlay", 0);
+ if (fsfd < 0)
+ return -1;
+
+ if (sys_fsconfig(fsfd, FSCONFIG_SET_STRING, "source", "test", 0) ||
+ sys_fsconfig(fsfd, FSCONFIG_SET_STRING, "upperdir", upper, 0) ||
+ sys_fsconfig(fsfd, FSCONFIG_SET_STRING, "workdir", work, 0) ||
+ sys_fsconfig(fsfd, FSCONFIG_SET_FD, "lowerdir+", NULL, fd_lower) ||
+ sys_fsconfig(fsfd, FSCONFIG_CMD_CREATE, NULL, NULL, 0))
+ goto err;
+
+ ovl = sys_fsmount(fsfd, 0, 0);
+ close(fsfd);
+ return ovl;
+err:
+ close(fsfd);
+ return -1;
+}
+
+/*
+ * Mount an overlay inside user namespace @u1 (so the overlay sb's s_user_ns is
+ * not the initial namespace) and idmap that overlay mount with @u2. Runs in a
+ * child that joins @u1; returns 0 on success.
+ */
+static int userns_overlay_child(int u1)
+{
+ struct mount_attr attr = {
+ .attr_set = MOUNT_ATTR_IDMAP,
+ };
+ struct stat st;
+ int ovl, u2;
+
+ /* Become root in the overlay sb's user namespace u1. */
+ if (!switch_userns(u1, 0, 0, false))
+ return fprintf(stderr, "userns: switch_userns: %m\n"), -1;
+ if (unshare(CLONE_NEWNS) ||
+ sys_mount(NULL, "/", NULL, MS_SLAVE | MS_REC, NULL))
+ return fprintf(stderr, "userns: unshare/slave: %m\n"), -1;
+ if (sys_mount("tmpfs", "/tmp", "tmpfs", 0, NULL))
+ return fprintf(stderr, "userns: mount tmpfs: %m\n"), -1;
+ if (setup_layers("/tmp/ovl"))
+ return fprintf(stderr, "userns: setup_layers: %m\n"), -1;
+ if (mknod("/tmp/ovl/l/file", S_IFREG | 0644, 0) ||
+ chown("/tmp/ovl/l/file", ID_HOST + 5, ID_HOST + 5))
+ return fprintf(stderr, "userns: lower file: %m\n"), -1;
+
+ ovl = ovl_mount("/tmp/ovl", false);
+ if (ovl < 0)
+ return fprintf(stderr, "userns: ovl_mount: %m\n"), -1;
+
+ /*
+ * mount_setattr() requires CAP_SYS_ADMIN over the idmap user
+ * namespace, so it must be a child of u1. Create it now, from
+ * inside u1.
+ */
+ u2 = get_userns_fd(ID_HOST, ID_NS, ID_RANGE);
+ if (u2 < 0)
+ return fprintf(stderr, "userns: get_userns_fd: %m\n"), -1;
+ attr.userns_fd = u2;
+ if (sys_mount_setattr(ovl, "", AT_EMPTY_PATH, &attr, sizeof(attr)))
+ return fprintf(stderr, "userns: mount_setattr: %m\n"), -1;
+ close(u2);
+
+ if (fstatat(ovl, "file", &st, 0))
+ return fprintf(stderr, "userns: fstatat: %m\n"), -1;
+ if (st.st_uid != ID_NS + 5 || st.st_gid != ID_NS + 5) {
+ fprintf(stderr, "userns: got %u:%u expected %u:%u\n",
+ st.st_uid, st.st_gid, ID_NS + 5, ID_NS + 5);
+ return -1;
+ }
+ return 0;
+}
+
+FIXTURE(idmapped_overlay) {
+ char base[64];
+};
+
+FIXTURE_SETUP(idmapped_overlay)
+{
+ /* Private mount namespace so test mounts need no cleanup. */
+ ASSERT_EQ(unshare(CLONE_NEWNS), 0);
+ ASSERT_EQ(sys_mount(NULL, "/", NULL, MS_SLAVE | MS_REC, NULL), 0);
+
+ /* tmpfs for the layers so we can chown them to arbitrary ids. */
+ ASSERT_EQ(sys_mount("tmpfs", "/tmp", "tmpfs", 0, NULL), 0);
+
+ snprintf(self->base, sizeof(self->base), "/tmp/ovl");
+ ASSERT_EQ(setup_layers(self->base), 0);
+}
+
+FIXTURE_TEARDOWN(idmapped_overlay)
+{
+}
+
+/* A file owned by ID_HOST + 5 is reported as ID_NS + 5 through the idmap. */
+TEST_F(idmapped_overlay, getattr)
+{
+ char path[PATH_MAX];
+ struct stat st;
+ int ovl;
+
+ if (!ovl_supported())
+ SKIP(return, "overlayfs not supported");
+
+ snprintf(path, sizeof(path), "%s/l/file", self->base);
+ ASSERT_EQ(mknod(path, S_IFREG | 0644, 0), 0);
+ ASSERT_EQ(chown(path, ID_HOST + 5, ID_HOST + 5), 0);
+
+ ovl = ovl_mount(self->base, false);
+ ASSERT_GE(ovl, 0);
+ ASSERT_EQ(ovl_idmap(ovl), 0);
+
+ ASSERT_EQ(fstatat(ovl, "file", &st, 0), 0);
+ EXPECT_EQ(st.st_uid, ID_NS + 5);
+ EXPECT_EQ(st.st_gid, ID_NS + 5);
+
+ EXPECT_EQ(close(ovl), 0);
+}
+
+/*
+ * Every creation path initializes the new owner through the mount idmap:
+ * created as caller id ID_NS, stored on the upper layer as overlay-final
+ * ID_HOST. Covers ovl_create() (regular file), ovl_mkdir(), ovl_mknod()
+ * and ovl_symlink() (which share ovl_create_object()), plus the separate
+ * ovl_tmpfile() path.
+ */
+TEST_F(idmapped_overlay, create)
+{
+ static const char *names[] = { "reg", "dir", "fifo", "lnk" };
+ char path[PATH_MAX];
+ struct stat st;
+ int ovl, fd;
+
+ if (!ovl_supported())
+ SKIP(return, "overlayfs not supported");
+
+ ovl = ovl_mount(self->base, false);
+ ASSERT_GE(ovl, 0);
+ ASSERT_EQ(ovl_idmap(ovl), 0);
+
+ /* One object per creation operation, all as caller id ID_NS. */
+ fd = openat(ovl, "reg", O_CREAT | O_WRONLY | O_EXCL, 0644);
+ ASSERT_GE(fd, 0);
+ EXPECT_EQ(close(fd), 0);
+ ASSERT_EQ(mkdirat(ovl, "dir", 0755), 0);
+ ASSERT_EQ(mknodat(ovl, "fifo", S_IFIFO | 0644, 0), 0);
+ ASSERT_EQ(symlinkat("target", ovl, "lnk"), 0);
+
+ for (size_t i = 0; i < ARRAY_SIZE(names); i++) {
+ /* Reported as ID_NS through the idmapped mount ... */
+ ASSERT_EQ(fstatat(ovl, names[i], &st, AT_SYMLINK_NOFOLLOW), 0);
+ EXPECT_EQ(st.st_uid, ID_NS);
+ EXPECT_EQ(st.st_gid, ID_NS);
+ /* ... and stored as ID_HOST on the upper layer. */
+ snprintf(path, sizeof(path), "%s/u/%s", self->base, names[i]);
+ ASSERT_EQ(lstat(path, &st), 0);
+ EXPECT_EQ(st.st_uid, ID_HOST);
+ EXPECT_EQ(st.st_gid, ID_HOST);
+ }
+
+ /* O_TMPFILE goes through the separate ovl_tmpfile() path. */
+ fd = openat(ovl, ".", O_TMPFILE | O_WRONLY, 0644);
+ ASSERT_GE(fd, 0);
+ /* Inside the mount: caller id ID_NS. */
+ ASSERT_EQ(fstat(fd, &st), 0);
+ EXPECT_EQ(st.st_uid, ID_NS);
+ EXPECT_EQ(st.st_gid, ID_NS);
+ /* Link it in so the upper backing file can be inspected too. */
+ ASSERT_EQ(linkat(fd, "", ovl, "tmp", AT_EMPTY_PATH), 0);
+ EXPECT_EQ(close(fd), 0);
+ snprintf(path, sizeof(path), "%s/u/tmp", self->base);
+ ASSERT_EQ(lstat(path, &st), 0);
+ EXPECT_EQ(st.st_uid, ID_HOST);
+ EXPECT_EQ(st.st_gid, ID_HOST);
+
+ EXPECT_EQ(close(ovl), 0);
+}
+
+/* chown through the idmapped mount round-trips: ID_NS + 5 <-> ID_HOST + 5. */
+TEST_F(idmapped_overlay, chown)
+{
+ char path[PATH_MAX];
+ struct stat st;
+ int ovl, fd;
+
+ if (!ovl_supported())
+ SKIP(return, "overlayfs not supported");
+
+ ovl = ovl_mount(self->base, false);
+ ASSERT_GE(ovl, 0);
+ ASSERT_EQ(ovl_idmap(ovl), 0);
+
+ fd = openat(ovl, "f", O_CREAT | O_WRONLY | O_EXCL, 0644);
+ ASSERT_GE(fd, 0);
+ EXPECT_EQ(close(fd), 0);
+
+ ASSERT_EQ(fchownat(ovl, "f", ID_NS + 5, ID_NS + 5, 0), 0);
+
+ ASSERT_EQ(fstatat(ovl, "f", &st, 0), 0);
+ EXPECT_EQ(st.st_uid, ID_NS + 5);
+ EXPECT_EQ(st.st_gid, ID_NS + 5);
+
+ snprintf(path, sizeof(path), "%s/u/f", self->base);
+ ASSERT_EQ(stat(path, &st), 0);
+ EXPECT_EQ(st.st_uid, ID_HOST + 5);
+ EXPECT_EQ(st.st_gid, ID_HOST + 5);
+
+ EXPECT_EQ(close(ovl), 0);
+}
+
+/*
+ * Composition: an idmapped lower layer underneath an idmapped overlay mount.
+ * An on-disk id is mapped by the layer idmap into the overlay-final range and
+ * then by the mount idmap into the caller's range:
+ *
+ * on-disk LAYER_HOST+7 --layer--> ID_HOST+7 --mount--> ID_NS+7
+ */
+TEST_F(idmapped_overlay, composition)
+{
+ char lower[PATH_MAX], upper[PATH_MAX], work[PATH_MAX], path[PATH_MAX];
+ struct stat st;
+ int ovl, fd_lower;
+
+ if (!ovl_supported())
+ SKIP(return, "overlayfs not supported");
+
+ snprintf(lower, sizeof(lower), "%s/l", self->base);
+ snprintf(upper, sizeof(upper), "%s/u", self->base);
+ snprintf(work, sizeof(work), "%s/w", self->base);
+
+ /* Put the lower layer's ids in the on-disk [LAYER_HOST, ..) range. */
+ ASSERT_EQ(chown(lower, LAYER_HOST, LAYER_HOST), 0);
+ snprintf(path, sizeof(path), "%s/l/file", self->base);
+ ASSERT_EQ(mknod(path, S_IFREG | 0644, 0), 0);
+ ASSERT_EQ(chown(path, LAYER_HOST + 7, LAYER_HOST + 7), 0);
+
+ /* Idmapped lower: on-disk LAYER_HOST <-> overlay-final ID_HOST. */
+ fd_lower = idmapped_layer_fd(lower, LAYER_HOST, ID_HOST, ID_RANGE);
+ ASSERT_GE(fd_lower, 0);
+
+ ovl = ovl_mount_lower_fd(upper, work, fd_lower);
+ ASSERT_GE(ovl, 0);
+ EXPECT_EQ(close(fd_lower), 0);
+
+ /* Idmap the overlay mount: overlay-final ID_HOST <-> caller ID_NS. */
+ ASSERT_EQ(ovl_idmap(ovl), 0);
+
+ ASSERT_EQ(fstatat(ovl, "file", &st, 0), 0);
+ EXPECT_EQ(st.st_uid, ID_NS + 7);
+ EXPECT_EQ(st.st_gid, ID_NS + 7);
+
+ EXPECT_EQ(close(ovl), 0);
+}
+
+/* An idmapped overlay mount whose sb lives inside a user namespace. */
+TEST_F(idmapped_overlay, userns)
+{
+ int u1;
+ pid_t pid;
+
+ if (!ovl_supported())
+ SKIP(return, "overlayfs not supported");
+
+ /* u1 backs the overlay sb: identity-mapped, but not the init ns. */
+ u1 = get_userns_fd(0, 0, 65536);
+ if (u1 < 0)
+ SKIP(return, "user namespaces not available");
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+ if (pid == 0) {
+ int ret = userns_overlay_child(u1);
+
+ _exit(ret ? EXIT_FAILURE : EXIT_SUCCESS);
+ }
+ EXPECT_EQ(wait_for_pid(pid), 0);
+
+ EXPECT_EQ(close(u1), 0);
+}
+
+/*
+ * An nfs_export overlay can be idmapped, and decodable file handles round-trip
+ * through the idmapped mount with correctly mapped ownership. Overlay file
+ * handles encode object identity, not ownership, so the mount idmap does not
+ * affect them; it only maps the owner reported once a handle is reopened.
+ */
+TEST_F(idmapped_overlay, nfs_export_handles)
+{
+ char path[PATH_MAX], mnt[128];
+ union {
+ struct file_handle fh;
+ char buf[sizeof(struct file_handle) + MAX_HANDLE_SZ];
+ } fhu;
+ struct file_handle *fh = &fhu.fh;
+ struct stat st;
+ int ovl, mfd, fd, mount_id;
+
+ if (!ovl_supported())
+ SKIP(return, "overlayfs not supported");
+
+ snprintf(path, sizeof(path), "%s/l/file", self->base);
+ ASSERT_EQ(mknod(path, S_IFREG | 0644, 0), 0);
+ ASSERT_EQ(chown(path, ID_HOST + 7, ID_HOST + 7), 0);
+
+ /* nfs_export=on gives decodable overlay file handles. */
+ ovl = ovl_mount(self->base, true);
+ if (ovl < 0)
+ SKIP(return, "overlayfs nfs_export not supported");
+ ASSERT_EQ(ovl_idmap(ovl), 0);
+
+ /* Attach the idmapped mount so handles can be resolved against it. */
+ snprintf(mnt, sizeof(mnt), "%s/mnt", self->base);
+ ASSERT_EQ(mkdir(mnt, 0755), 0);
+ ASSERT_EQ(sys_move_mount(ovl, "", AT_FDCWD, mnt,
+ MOVE_MOUNT_F_EMPTY_PATH), 0);
+
+ snprintf(path, sizeof(path), "%s/file", mnt);
+ fh->handle_bytes = MAX_HANDLE_SZ;
+ ASSERT_EQ(name_to_handle_at(AT_FDCWD, path, fh, &mount_id, 0), 0);
+
+ mfd = open(mnt, O_RDONLY | O_DIRECTORY);
+ ASSERT_GE(mfd, 0);
+ fd = open_by_handle_at(mfd, fh, O_RDONLY);
+ EXPECT_EQ(close(mfd), 0);
+ ASSERT_GE(fd, 0);
+
+ ASSERT_EQ(fstat(fd, &st), 0);
+ EXPECT_EQ(st.st_uid, ID_NS + 7);
+ EXPECT_EQ(st.st_gid, ID_NS + 7);
+
+ EXPECT_EQ(close(fd), 0);
+ EXPECT_EQ(close(ovl), 0);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/overlayfs/set_layers_via_fds.c b/tools/testing/selftests/filesystems/overlayfs/set_layers_via_fds.c
index 3c0b93183348..7a293544233d 100644
--- a/tools/testing/selftests/filesystems/overlayfs/set_layers_via_fds.c
+++ b/tools/testing/selftests/filesystems/overlayfs/set_layers_via_fds.c
@@ -624,7 +624,7 @@ TEST_F(set_layers_via_fds, set_layers_via_detached_mount_fds)
ASSERT_EQ(sys_move_mount(fd_tmpfs, "", -EBADF, "/set_layers_via_fds_tmpfs", MOVE_MOUNT_F_EMPTY_PATH), 0);
- fd_tmp = open_tree(fd_tmpfs, "u", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ fd_tmp = sys_open_tree(fd_tmpfs, "u", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
ASSERT_GE(fd_tmp, 0);
layer_fds[0] = openat(fd_tmp, "upper", O_CLOEXEC | O_DIRECTORY | O_PATH);
@@ -633,25 +633,25 @@ TEST_F(set_layers_via_fds, set_layers_via_detached_mount_fds)
layer_fds[1] = openat(fd_tmp, "work", O_CLOEXEC | O_DIRECTORY | O_PATH);
ASSERT_GE(layer_fds[1], 0);
- layer_fds[2] = open_tree(fd_tmpfs, "l1", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ layer_fds[2] = sys_open_tree(fd_tmpfs, "l1", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
ASSERT_GE(layer_fds[2], 0);
- layer_fds[3] = open_tree(fd_tmpfs, "l2", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ layer_fds[3] = sys_open_tree(fd_tmpfs, "l2", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
ASSERT_GE(layer_fds[3], 0);
- layer_fds[4] = open_tree(fd_tmpfs, "l3", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ layer_fds[4] = sys_open_tree(fd_tmpfs, "l3", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
ASSERT_GE(layer_fds[4], 0);
- layer_fds[5] = open_tree(fd_tmpfs, "l4", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ layer_fds[5] = sys_open_tree(fd_tmpfs, "l4", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
ASSERT_GE(layer_fds[5], 0);
- layer_fds[6] = open_tree(fd_tmpfs, "d1", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ layer_fds[6] = sys_open_tree(fd_tmpfs, "d1", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
ASSERT_GE(layer_fds[6], 0);
- layer_fds[7] = open_tree(fd_tmpfs, "d2", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ layer_fds[7] = sys_open_tree(fd_tmpfs, "d2", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
ASSERT_GE(layer_fds[7], 0);
- layer_fds[8] = open_tree(fd_tmpfs, "d3", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
+ layer_fds[8] = sys_open_tree(fd_tmpfs, "d3", OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC);
ASSERT_GE(layer_fds[8], 0);
ASSERT_EQ(close(fd_tmpfs), 0);
diff --git a/tools/testing/selftests/filesystems/statmount/statmount.h b/tools/testing/selftests/filesystems/statmount/statmount.h
index 99e5ad082fb1..675f7cc00076 100644
--- a/tools/testing/selftests/filesystems/statmount/statmount.h
+++ b/tools/testing/selftests/filesystems/statmount/statmount.h
@@ -3,10 +3,14 @@
#ifndef __STATMOUNT_H
#define __STATMOUNT_H
+#include <errno.h>
#include <stdint.h>
+#include <stdlib.h>
#include <linux/mount.h>
#include <asm/unistd.h>
+#define STATMOUNT_BUFSIZE (1 << 15)
+
#ifndef __NR_statmount
#if defined __alpha__
#define __NR_statmount 567
@@ -43,19 +47,24 @@
#endif
#endif
-static inline int statmount(uint64_t mnt_id, uint64_t mnt_ns_id, uint64_t mask,
- struct statmount *buf, size_t bufsize,
+static inline int statmount(uint64_t mnt_id, uint64_t mnt_ns_id, uint32_t fd,
+ uint64_t mask, struct statmount *buf, size_t bufsize,
unsigned int flags)
{
struct mnt_id_req req = {
.size = MNT_ID_REQ_SIZE_VER0,
- .mnt_id = mnt_id,
.param = mask,
};
- if (mnt_ns_id) {
+ if (flags & STATMOUNT_BY_FD) {
req.size = MNT_ID_REQ_SIZE_VER1;
- req.mnt_ns_id = mnt_ns_id;
+ req.mnt_fd = fd;
+ } else {
+ req.mnt_id = mnt_id;
+ if (mnt_ns_id) {
+ req.size = MNT_ID_REQ_SIZE_VER1;
+ req.mnt_ns_id = mnt_ns_id;
+ }
}
return syscall(__NR_statmount, &req, buf, bufsize, flags);
@@ -79,4 +88,51 @@ static inline ssize_t listmount(uint64_t mnt_id, uint64_t mnt_ns_id,
return syscall(__NR_listmount, &req, list, num, flags);
}
+static inline struct statmount *statmount_alloc(uint64_t mnt_id, uint64_t mnt_ns_id,
+ uint64_t mask, unsigned int flags)
+{
+ struct statmount *buf;
+ size_t bufsize = STATMOUNT_BUFSIZE;
+ int ret;
+
+ for (;;) {
+ buf = malloc(bufsize);
+ if (!buf)
+ return NULL;
+
+ ret = statmount(mnt_id, mnt_ns_id, 0, mask, buf, bufsize, flags);
+ if (ret == 0)
+ return buf;
+
+ free(buf);
+ if (errno != EOVERFLOW)
+ return NULL;
+
+ bufsize <<= 1;
+ }
+}
+
+static inline struct statmount *statmount_alloc_by_fd(int fd, uint64_t mask)
+{
+ struct statmount *buf;
+ size_t bufsize = STATMOUNT_BUFSIZE;
+ int ret;
+
+ for (;;) {
+ buf = malloc(bufsize);
+ if (!buf)
+ return NULL;
+
+ ret = statmount(0, 0, fd, mask, buf, bufsize, STATMOUNT_BY_FD);
+ if (ret == 0)
+ return buf;
+
+ free(buf);
+ if (errno != EOVERFLOW)
+ return NULL;
+
+ bufsize <<= 1;
+ }
+}
+
#endif /* __STATMOUNT_H */
diff --git a/tools/testing/selftests/filesystems/statmount/statmount_test.c b/tools/testing/selftests/filesystems/statmount/statmount_test.c
index 6e53430423d2..60c2c544db6a 100644
--- a/tools/testing/selftests/filesystems/statmount/statmount_test.c
+++ b/tools/testing/selftests/filesystems/statmount/statmount_test.c
@@ -33,36 +33,6 @@ static const char *const known_fs[] = {
"sysv", "tmpfs", "tracefs", "ubifs", "udf", "ufs", "v7", "vboxsf",
"vfat", "virtiofs", "vxfs", "xenfs", "xfs", "zonefs", NULL };
-static struct statmount *statmount_alloc(uint64_t mnt_id, uint64_t mask, unsigned int flags)
-{
- size_t bufsize = 1 << 15;
- struct statmount *buf = NULL, *tmp = alloca(bufsize);
- int tofree = 0;
- int ret;
-
- for (;;) {
- ret = statmount(mnt_id, 0, mask, tmp, bufsize, flags);
- if (ret != -1)
- break;
- if (tofree)
- free(tmp);
- if (errno != EOVERFLOW)
- return NULL;
- bufsize <<= 1;
- tofree = 1;
- tmp = malloc(bufsize);
- if (!tmp)
- return NULL;
- }
- buf = malloc(tmp->size);
- if (buf)
- memcpy(buf, tmp, tmp->size);
- if (tofree)
- free(tmp);
-
- return buf;
-}
-
static void write_file(const char *path, const char *val)
{
int fd = open(path, O_WRONLY);
@@ -112,6 +82,9 @@ static void cleanup_namespace(void)
{
int ret;
+ if (f_mountinfo)
+ fclose(f_mountinfo);
+
ret = fchdir(orig_root);
if (ret == -1)
ksft_perror("fchdir to original root");
@@ -237,7 +210,7 @@ static void test_statmount_zero_mask(void)
struct statmount sm;
int ret;
- ret = statmount(root_id, 0, 0, &sm, sizeof(sm), 0);
+ ret = statmount(root_id, 0, 0, 0, &sm, sizeof(sm), 0);
if (ret == -1) {
ksft_test_result_fail("statmount zero mask: %s\n",
strerror(errno));
@@ -263,7 +236,7 @@ static void test_statmount_mnt_basic(void)
int ret;
uint64_t mask = STATMOUNT_MNT_BASIC;
- ret = statmount(root_id, 0, mask, &sm, sizeof(sm), 0);
+ ret = statmount(root_id, 0, 0, mask, &sm, sizeof(sm), 0);
if (ret == -1) {
ksft_test_result_fail("statmount mnt basic: %s\n",
strerror(errno));
@@ -323,7 +296,7 @@ static void test_statmount_sb_basic(void)
struct statx sx;
struct statfs sf;
- ret = statmount(root_id, 0, mask, &sm, sizeof(sm), 0);
+ ret = statmount(root_id, 0, 0, mask, &sm, sizeof(sm), 0);
if (ret == -1) {
ksft_test_result_fail("statmount sb basic: %s\n",
strerror(errno));
@@ -375,7 +348,7 @@ static void test_statmount_mnt_point(void)
{
struct statmount *sm;
- sm = statmount_alloc(root_id, STATMOUNT_MNT_POINT, 0);
+ sm = statmount_alloc(root_id, 0, STATMOUNT_MNT_POINT, 0);
if (!sm) {
ksft_test_result_fail("statmount mount point: %s\n",
strerror(errno));
@@ -405,7 +378,7 @@ static void test_statmount_mnt_root(void)
assert(last_dir);
last_dir++;
- sm = statmount_alloc(root_id, STATMOUNT_MNT_ROOT, 0);
+ sm = statmount_alloc(root_id, 0, STATMOUNT_MNT_ROOT, 0);
if (!sm) {
ksft_test_result_fail("statmount mount root: %s\n",
strerror(errno));
@@ -438,7 +411,7 @@ static void test_statmount_fs_type(void)
const char *fs_type;
const char *const *s;
- sm = statmount_alloc(root_id, STATMOUNT_FS_TYPE, 0);
+ sm = statmount_alloc(root_id, 0, STATMOUNT_FS_TYPE, 0);
if (!sm) {
ksft_test_result_fail("statmount fs type: %s\n",
strerror(errno));
@@ -467,7 +440,7 @@ static void test_statmount_mnt_opts(void)
char *line = NULL;
size_t len = 0;
- sm = statmount_alloc(root_id, STATMOUNT_MNT_BASIC | STATMOUNT_MNT_OPTS,
+ sm = statmount_alloc(root_id, 0, STATMOUNT_MNT_BASIC | STATMOUNT_MNT_OPTS,
0);
if (!sm) {
ksft_test_result_fail("statmount mnt opts: %s\n",
@@ -545,7 +518,7 @@ static void test_statmount_mnt_opts(void)
return;
}
- ksft_test_result_fail("didnt't find mount entry\n");
+ ksft_test_result_fail("didn't find mount entry\n");
free(sm);
free(line);
}
@@ -557,7 +530,7 @@ static void test_statmount_string(uint64_t mask, size_t off, const char *name)
uint32_t start, i;
int ret;
- sm = statmount_alloc(root_id, mask, 0);
+ sm = statmount_alloc(root_id, 0, mask, 0);
if (!sm) {
ksft_test_result_fail("statmount %s: %s\n", name,
strerror(errno));
@@ -586,14 +559,14 @@ static void test_statmount_string(uint64_t mask, size_t off, const char *name)
exactsize = sm->size;
shortsize = sizeof(*sm) + i;
- ret = statmount(root_id, 0, mask, sm, exactsize, 0);
+ ret = statmount(root_id, 0, 0, mask, sm, exactsize, 0);
if (ret == -1) {
ksft_test_result_fail("statmount exact size: %s\n",
strerror(errno));
goto out;
}
errno = 0;
- ret = statmount(root_id, 0, mask, sm, shortsize, 0);
+ ret = statmount(root_id, 0, 0, mask, sm, shortsize, 0);
if (ret != -1 || errno != EOVERFLOW) {
ksft_test_result_fail("should have failed with EOVERFLOW: %s\n",
strerror(errno));
@@ -658,6 +631,226 @@ static void test_listmount_tree(void)
ksft_test_result_pass("listmount tree\n");
}
+static void test_statmount_by_fd(void)
+{
+ struct statmount *sm = NULL;
+ char tmpdir[] = "/statmount.fd.XXXXXX";
+ const char root[] = "/test";
+ char subdir[PATH_MAX], tmproot[PATH_MAX];
+ int fd;
+
+ if (!mkdtemp(tmpdir)) {
+ ksft_perror("mkdtemp");
+ return;
+ }
+
+ if (mount("statmount.test", tmpdir, "tmpfs", 0, NULL)) {
+ ksft_perror("mount");
+ rmdir(tmpdir);
+ return;
+ }
+
+ snprintf(subdir, PATH_MAX, "%s%s", tmpdir, root);
+ snprintf(tmproot, PATH_MAX, "%s/%s", tmpdir, "chroot");
+
+ if (mkdir(subdir, 0755)) {
+ ksft_perror("mkdir");
+ goto err_tmpdir;
+ }
+
+ if (mount(subdir, subdir, NULL, MS_BIND, 0)) {
+ ksft_perror("mount");
+ goto err_subdir;
+ }
+
+ if (mkdir(tmproot, 0755)) {
+ ksft_perror("mkdir");
+ goto err_subdir;
+ }
+
+ fd = open(subdir, O_PATH);
+ if (fd < 0) {
+ ksft_perror("open");
+ goto err_tmproot;
+ }
+
+ if (chroot(tmproot)) {
+ ksft_perror("chroot");
+ goto err_fd;
+ }
+
+ sm = statmount_alloc_by_fd(fd, STATMOUNT_MNT_ROOT | STATMOUNT_MNT_POINT);
+ if (!sm) {
+ ksft_test_result_fail("statmount by fd failed: %s\n", strerror(errno));
+ goto err_chroot;
+ }
+
+ if (sm->size < sizeof(*sm)) {
+ ksft_test_result_fail("unexpected size: %u < %u\n",
+ sm->size, (uint32_t) sizeof(*sm));
+ goto err_chroot;
+ }
+
+ if (sm->mask & STATMOUNT_MNT_POINT) {
+ ksft_test_result_fail("STATMOUNT_MNT_POINT unexpectedly set in statmount\n");
+ goto err_chroot;
+ }
+
+ if (!(sm->mask & STATMOUNT_MNT_ROOT)) {
+ ksft_test_result_fail("STATMOUNT_MNT_ROOT not set in statmount\n");
+ goto err_chroot;
+ }
+
+ if (strcmp(root, sm->str + sm->mnt_root) != 0) {
+ ksft_test_result_fail("statmount returned incorrect mnt_root,"
+ "statmount mnt_root: %s != %s\n",
+ sm->str + sm->mnt_root, root);
+ goto err_chroot;
+ }
+
+ if (chroot(".")) {
+ ksft_perror("chroot");
+ goto out;
+ }
+
+ free(sm);
+ sm = statmount_alloc_by_fd(fd, STATMOUNT_MNT_ROOT | STATMOUNT_MNT_POINT);
+ if (!sm) {
+ ksft_test_result_fail("statmount by fd failed: %s\n", strerror(errno));
+ goto err_fd;
+ }
+
+ if (sm->size < sizeof(*sm)) {
+ ksft_test_result_fail("unexpected size: %u < %u\n",
+ sm->size, (uint32_t) sizeof(*sm));
+ goto out;
+ }
+
+ if (!(sm->mask & STATMOUNT_MNT_POINT)) {
+ ksft_test_result_fail("STATMOUNT_MNT_POINT not set in statmount\n");
+ goto out;
+ }
+
+ if (!(sm->mask & STATMOUNT_MNT_ROOT)) {
+ ksft_test_result_fail("STATMOUNT_MNT_ROOT not set in statmount\n");
+ goto out;
+ }
+
+ if (strcmp(subdir, sm->str + sm->mnt_point) != 0) {
+ ksft_test_result_fail("statmount returned incorrect mnt_point,"
+ "statmount mnt_point: %s != %s\n", sm->str + sm->mnt_point, subdir);
+ goto out;
+ }
+
+ if (strcmp(root, sm->str + sm->mnt_root) != 0) {
+ ksft_test_result_fail("statmount returned incorrect mnt_root,"
+ "statmount mnt_root: %s != %s\n", sm->str + sm->mnt_root, root);
+ goto out;
+ }
+
+ ksft_test_result_pass("statmount by fd\n");
+ goto out;
+err_chroot:
+ chroot(".");
+out:
+ free(sm);
+err_fd:
+ close(fd);
+err_tmproot:
+ rmdir(tmproot);
+err_subdir:
+ umount2(subdir, MNT_DETACH);
+ rmdir(subdir);
+err_tmpdir:
+ umount2(tmpdir, MNT_DETACH);
+ rmdir(tmpdir);
+}
+
+static void test_statmount_by_fd_unmounted(void)
+{
+ const char root[] = "/test.unmounted";
+ char tmpdir[] = "/statmount.fd.XXXXXX";
+ char subdir[PATH_MAX];
+ int fd;
+ struct statmount *sm = NULL;
+
+ if (!mkdtemp(tmpdir)) {
+ ksft_perror("mkdtemp");
+ return;
+ }
+
+ if (mount("statmount.test", tmpdir, "tmpfs", 0, NULL)) {
+ ksft_perror("mount");
+ rmdir(tmpdir);
+ return;
+ }
+
+ snprintf(subdir, PATH_MAX, "%s%s", tmpdir, root);
+
+ if (mkdir(subdir, 0755)) {
+ ksft_perror("mkdir");
+ goto err_tmpdir;
+ }
+
+ if (mount(subdir, subdir, 0, MS_BIND, NULL)) {
+ ksft_perror("mount");
+ goto err_subdir;
+ }
+
+ fd = open(subdir, O_PATH);
+ if (fd < 0) {
+ ksft_perror("open");
+ goto err_subdir;
+ }
+
+ if (umount2(tmpdir, MNT_DETACH)) {
+ ksft_perror("umount2");
+ goto err_fd;
+ }
+
+ sm = statmount_alloc_by_fd(fd, STATMOUNT_MNT_POINT | STATMOUNT_MNT_ROOT);
+ if (!sm) {
+ ksft_test_result_fail("statmount by fd unmounted: %s\n",
+ strerror(errno));
+ goto err_sm;
+ }
+
+ if (sm->size < sizeof(*sm)) {
+ ksft_test_result_fail("unexpected size: %u < %u\n",
+ sm->size, (uint32_t) sizeof(*sm));
+ goto err_sm;
+ }
+
+ if (sm->mask & STATMOUNT_MNT_POINT) {
+ ksft_test_result_fail("STATMOUNT_MNT_POINT unexpectedly set in mask\n");
+ goto err_sm;
+ }
+
+ if (!(sm->mask & STATMOUNT_MNT_ROOT)) {
+ ksft_test_result_fail("STATMOUNT_MNT_ROOT not set in mask\n");
+ goto err_sm;
+ }
+
+ if (strcmp(sm->str + sm->mnt_root, root) != 0) {
+ ksft_test_result_fail("statmount returned incorrect mnt_root,"
+ "statmount mnt_root: %s != %s\n",
+ sm->str + sm->mnt_root, root);
+ goto err_sm;
+ }
+
+ ksft_test_result_pass("statmount by fd on unmounted mount\n");
+err_sm:
+ free(sm);
+err_fd:
+ close(fd);
+err_subdir:
+ umount2(subdir, MNT_DETACH);
+ rmdir(subdir);
+err_tmpdir:
+ umount2(tmpdir, MNT_DETACH);
+ rmdir(tmpdir);
+}
+
#define str_off(memb) (offsetof(struct statmount, memb) / sizeof(uint32_t))
int main(void)
@@ -669,14 +862,14 @@ int main(void)
ksft_print_header();
- ret = statmount(0, 0, 0, NULL, 0, 0);
+ ret = statmount(0, 0, 0, 0, NULL, 0, 0);
assert(ret == -1);
if (errno == ENOSYS)
ksft_exit_skip("statmount() syscall not supported\n");
setup_namespace();
- ksft_set_plan(15);
+ ksft_set_plan(17);
test_listmount_empty_root();
test_statmount_zero_mask();
test_statmount_mnt_basic();
@@ -693,6 +886,8 @@ int main(void)
test_statmount_string(all_mask, str_off(fs_type), "fs type & all");
test_listmount_tree();
+ test_statmount_by_fd_unmounted();
+ test_statmount_by_fd();
if (ksft_get_fail_cnt() + ksft_get_error_cnt() > 0)
diff --git a/tools/testing/selftests/filesystems/statmount/statmount_test_ns.c b/tools/testing/selftests/filesystems/statmount/statmount_test_ns.c
index d56d4103182f..e500905e4c07 100644
--- a/tools/testing/selftests/filesystems/statmount/statmount_test_ns.c
+++ b/tools/testing/selftests/filesystems/statmount/statmount_test_ns.c
@@ -34,31 +34,6 @@ static void handle_result(int ret, const char *testname)
ksft_test_result_skip("%s\n", testname);
}
-static inline int wait_for_pid(pid_t pid)
-{
- int status, ret;
-
-again:
- ret = waitpid(pid, &status, 0);
- if (ret == -1) {
- if (errno == EINTR)
- goto again;
-
- ksft_print_msg("waitpid returned -1, errno=%d\n", errno);
- return -1;
- }
-
- if (!WIFEXITED(status)) {
- ksft_print_msg(
- "waitpid !WIFEXITED, WIFSIGNALED=%d, WTERMSIG=%d\n",
- WIFSIGNALED(status), WTERMSIG(status));
- return -1;
- }
-
- ret = WEXITSTATUS(status);
- return ret;
-}
-
static int get_mnt_ns_id(const char *mnt_ns, uint64_t *mnt_ns_id)
{
int fd = open(mnt_ns, O_RDONLY);
@@ -102,7 +77,7 @@ static int _test_statmount_mnt_ns_id(void)
if (!root_id)
return NSID_ERROR;
- ret = statmount(root_id, 0, STATMOUNT_MNT_NS_ID, &sm, sizeof(sm), 0);
+ ret = statmount(root_id, 0, 0, STATMOUNT_MNT_NS_ID, &sm, sizeof(sm), 0);
if (ret == -1) {
ksft_print_msg("statmount mnt ns id: %s\n", strerror(errno));
return NSID_ERROR;
@@ -128,6 +103,98 @@ static int _test_statmount_mnt_ns_id(void)
return NSID_PASS;
}
+static int _test_statmount_mnt_ns_id_by_fd(void)
+{
+ struct statmount sm;
+ uint64_t mnt_ns_id;
+ int ret, fd, mounted = 1, status = NSID_ERROR;
+ char mnt[] = "/statmount.fd.XXXXXX";
+
+ ret = get_mnt_ns_id("/proc/self/ns/mnt", &mnt_ns_id);
+ if (ret != NSID_PASS)
+ return ret;
+
+ if (!mkdtemp(mnt)) {
+ ksft_print_msg("statmount by fd mnt ns id mkdtemp: %s\n", strerror(errno));
+ return NSID_ERROR;
+ }
+
+ if (mount(mnt, mnt, NULL, MS_BIND, 0)) {
+ ksft_print_msg("statmount by fd mnt ns id mount: %s\n", strerror(errno));
+ status = NSID_ERROR;
+ goto err;
+ }
+
+ fd = open(mnt, O_PATH);
+ if (fd < 0) {
+ ksft_print_msg("statmount by fd mnt ns id open: %s\n", strerror(errno));
+ goto err;
+ }
+
+ ret = statmount(0, 0, fd, STATMOUNT_MNT_NS_ID, &sm, sizeof(sm), STATMOUNT_BY_FD);
+ if (ret == -1) {
+ ksft_print_msg("statmount mnt ns id statmount: %s\n", strerror(errno));
+ status = NSID_ERROR;
+ goto out;
+ }
+
+ if (sm.size != sizeof(sm)) {
+ ksft_print_msg("unexpected size: %u != %u\n", sm.size,
+ (uint32_t)sizeof(sm));
+ status = NSID_FAIL;
+ goto out;
+ }
+ if (sm.mask != STATMOUNT_MNT_NS_ID) {
+ ksft_print_msg("statmount mnt ns id unavailable\n");
+ status = NSID_SKIP;
+ goto out;
+ }
+
+ if (sm.mnt_ns_id != mnt_ns_id) {
+ ksft_print_msg("unexpected mnt ns ID: 0x%llx != 0x%llx\n",
+ (unsigned long long)sm.mnt_ns_id,
+ (unsigned long long)mnt_ns_id);
+ status = NSID_FAIL;
+ goto out;
+ }
+
+ mounted = 0;
+ if (umount2(mnt, MNT_DETACH)) {
+ ksft_print_msg("statmount by fd mnt ns id umount2: %s\n", strerror(errno));
+ goto out;
+ }
+
+ ret = statmount(0, 0, fd, STATMOUNT_MNT_NS_ID, &sm, sizeof(sm), STATMOUNT_BY_FD);
+ if (ret == -1) {
+ ksft_print_msg("statmount mnt ns id statmount: %s\n", strerror(errno));
+ status = NSID_ERROR;
+ goto out;
+ }
+
+ if (sm.size != sizeof(sm)) {
+ ksft_print_msg("unexpected size: %u != %u\n", sm.size,
+ (uint32_t)sizeof(sm));
+ status = NSID_FAIL;
+ goto out;
+ }
+
+ if (sm.mask == STATMOUNT_MNT_NS_ID) {
+ ksft_print_msg("unexpected STATMOUNT_MNT_NS_ID in mask\n");
+ status = NSID_FAIL;
+ goto out;
+ }
+
+ status = NSID_PASS;
+out:
+ close(fd);
+ if (mounted)
+ umount2(mnt, MNT_DETACH);
+err:
+ rmdir(mnt);
+ return status;
+}
+
+
static void test_statmount_mnt_ns_id(void)
{
pid_t pid;
@@ -148,6 +215,9 @@ static void test_statmount_mnt_ns_id(void)
if (ret != NSID_PASS)
exit(ret);
ret = _test_statmount_mnt_ns_id();
+ if (ret != NSID_PASS)
+ exit(ret);
+ ret = _test_statmount_mnt_ns_id_by_fd();
exit(ret);
}
@@ -179,7 +249,7 @@ static int validate_external_listmount(pid_t pid, uint64_t child_nr_mounts)
for (int i = 0; i < nr_mounts; i++) {
struct statmount sm;
- ret = statmount(list[i], mnt_ns_id, STATMOUNT_MNT_NS_ID, &sm,
+ ret = statmount(list[i], mnt_ns_id, 0, STATMOUNT_MNT_NS_ID, &sm,
sizeof(sm), 0);
if (ret < 0) {
ksft_print_msg("statmount mnt ns id: %s\n", strerror(errno));
@@ -275,7 +345,7 @@ int main(void)
int ret;
ksft_print_header();
- ret = statmount(0, 0, 0, NULL, 0, 0);
+ ret = statmount(0, 0, 0, 0, NULL, 0, 0);
assert(ret == -1);
if (errno == ENOSYS)
ksft_exit_skip("statmount() syscall not supported\n");
diff --git a/tools/testing/selftests/filesystems/ustat_test.c b/tools/testing/selftests/filesystems/ustat_test.c
new file mode 100644
index 000000000000..d429fd18d779
--- /dev/null
+++ b/tools/testing/selftests/filesystems/ustat_test.c
@@ -0,0 +1,135 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Test ustat(2): looking up superblocks by device number.
+ *
+ * ustat() resolves a device number to a mounted superblock via
+ * user_get_super(). Check that the device number of a mounted tmpfs (an
+ * anonymous device) resolves, that it stops resolving once the filesystem
+ * is unmounted and that bogus device numbers report EINVAL.
+ */
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <sched.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/mount.h>
+#include <sys/stat.h>
+#include <sys/syscall.h>
+#include <unistd.h>
+
+#include "../kselftest_harness.h"
+
+/* struct ustat is not exported through UAPI, mirror include/linux/types.h. */
+struct ustat_buf {
+ int f_tfree;
+ unsigned long f_tinode;
+ char f_fname[6];
+ char f_fpack[6];
+ /* slack in case an architecture lays the struct out differently */
+ char pad[64];
+};
+
+#ifdef __NR_ustat
+
+/*
+ * The kernel decodes @dev with new_decode_dev(), which matches the low 32
+ * bits of the st_dev encoding stat(2) returns for any major below 4096.
+ */
+static int sys_ustat(unsigned int dev, struct ustat_buf *buf)
+{
+ return syscall(__NR_ustat, dev, buf);
+}
+
+static int write_string(const char *path, const char *string)
+{
+ ssize_t len = strlen(string);
+ int fd;
+
+ fd = open(path, O_WRONLY);
+ if (fd < 0)
+ return -1;
+ if (write(fd, string, len) != len) {
+ close(fd);
+ return -1;
+ }
+ return close(fd);
+}
+
+/* Enter namespaces in which mounting a tmpfs instance is allowed. */
+static int setup_namespaces(void)
+{
+ uid_t uid = getuid();
+ gid_t gid = getgid();
+ char map[64];
+
+ if (unshare(CLONE_NEWNS | (uid ? CLONE_NEWUSER : 0)))
+ return -1;
+
+ if (uid) {
+ if (write_string("/proc/self/setgroups", "deny"))
+ return -1;
+ snprintf(map, sizeof(map), "0 %d 1", uid);
+ if (write_string("/proc/self/uid_map", map))
+ return -1;
+ snprintf(map, sizeof(map), "0 %d 1", gid);
+ if (write_string("/proc/self/gid_map", map))
+ return -1;
+ }
+
+ return mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL);
+}
+
+TEST(resolves_mounted_superblock)
+{
+ char dir[] = "/tmp/ustat_test.XXXXXX";
+ struct ustat_buf ub;
+ struct stat st;
+
+ ASSERT_NE(NULL, mkdtemp(dir));
+
+ if (setup_namespaces()) {
+ rmdir(dir);
+ SKIP(return, "cannot set up namespaces: %s", strerror(errno));
+ }
+
+ ASSERT_EQ(0, mount("ustat_test", dir, "tmpfs", 0, NULL));
+ ASSERT_EQ(0, stat(dir, &st));
+
+ memset(&ub, 0xff, sizeof(ub));
+ ASSERT_EQ(0, sys_ustat(st.st_dev, &ub))
+ TH_LOG("ustat(%u): %s", (unsigned int)st.st_dev,
+ strerror(errno));
+
+ ASSERT_EQ(0, umount(dir));
+
+ /* The unmount removed the superblock, the device is gone. */
+ ASSERT_EQ(-1, sys_ustat(st.st_dev, &ub));
+ ASSERT_EQ(EINVAL, errno);
+
+ rmdir(dir);
+}
+
+TEST(bogus_device_numbers)
+{
+ struct ustat_buf ub;
+
+ ASSERT_EQ(-1, sys_ustat(0, &ub));
+ ASSERT_EQ(EINVAL, errno);
+
+ /* major 4095, minor 1048575: nothing plausible lives there */
+ ASSERT_EQ(-1, sys_ustat((0xfffu << 8) | 0xffu | (0xfff00u << 12), &ub));
+ ASSERT_EQ(EINVAL, errno);
+}
+
+#else /* !__NR_ustat */
+
+TEST(unsupported)
+{
+ SKIP(return, "ustat(2) is not available on this architecture");
+}
+
+#endif /* __NR_ustat */
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/utils.c b/tools/testing/selftests/filesystems/utils.c
index c9dd5412b37b..d73d7d8171db 100644
--- a/tools/testing/selftests/filesystems/utils.c
+++ b/tools/testing/selftests/filesystems/utils.c
@@ -158,7 +158,7 @@ static int get_userns_fd_cb(void *data)
_exit(0);
}
-static int wait_for_pid(pid_t pid)
+int wait_for_pid(pid_t pid)
{
int status, ret;
@@ -450,7 +450,7 @@ out_close:
return fret;
}
-static int write_file(const char *path, const char *val)
+int write_file(const char *path, const char *val)
{
int fd = open(path, O_WRONLY);
size_t len = strlen(val);
@@ -515,6 +515,32 @@ int setup_userns(void)
return 0;
}
+int enter_userns(void)
+{
+ int ret;
+ char buf[32];
+ uid_t uid = getuid();
+ gid_t gid = getgid();
+
+ ret = unshare(CLONE_NEWUSER);
+ if (ret)
+ return ret;
+
+ sprintf(buf, "0 %d 1", uid);
+ ret = write_file("/proc/self/uid_map", buf);
+ if (ret)
+ return ret;
+ ret = write_file("/proc/self/setgroups", "deny");
+ if (ret)
+ return ret;
+ sprintf(buf, "0 %d 1", gid);
+ ret = write_file("/proc/self/gid_map", buf);
+ if (ret)
+ return ret;
+
+ return 0;
+}
+
/* caps_down - lower all effective caps */
int caps_down(void)
{
diff --git a/tools/testing/selftests/filesystems/utils.h b/tools/testing/selftests/filesystems/utils.h
index 70f7ccc607f4..d03085cef5cb 100644
--- a/tools/testing/selftests/filesystems/utils.h
+++ b/tools/testing/selftests/filesystems/utils.h
@@ -28,6 +28,7 @@ extern int cap_down(cap_value_t down);
extern bool switch_ids(uid_t uid, gid_t gid);
extern int setup_userns(void);
+extern int enter_userns(void);
static inline bool switch_userns(int fd, uid_t uid, gid_t gid, bool drop_caps)
{
@@ -43,6 +44,8 @@ static inline bool switch_userns(int fd, uid_t uid, gid_t gid, bool drop_caps)
return true;
}
+extern int wait_for_pid(pid_t pid);
+extern int write_file(const char *path, const char *val);
extern uint64_t get_unique_mnt_id(const char *path);
#endif /* __IDMAP_UTILS_H */
diff --git a/tools/testing/selftests/filesystems/xattr/.gitignore b/tools/testing/selftests/filesystems/xattr/.gitignore
new file mode 100644
index 000000000000..092d14094c0f
--- /dev/null
+++ b/tools/testing/selftests/filesystems/xattr/.gitignore
@@ -0,0 +1,3 @@
+xattr_socket_test
+xattr_sockfs_test
+xattr_socket_types_test
diff --git a/tools/testing/selftests/filesystems/xattr/Makefile b/tools/testing/selftests/filesystems/xattr/Makefile
new file mode 100644
index 000000000000..95364ffb10e9
--- /dev/null
+++ b/tools/testing/selftests/filesystems/xattr/Makefile
@@ -0,0 +1,6 @@
+# SPDX-License-Identifier: GPL-2.0
+
+CFLAGS += $(KHDR_INCLUDES)
+TEST_GEN_PROGS := xattr_socket_test xattr_sockfs_test xattr_socket_types_test
+
+include ../../lib.mk
diff --git a/tools/testing/selftests/filesystems/xattr/xattr_socket_test.c b/tools/testing/selftests/filesystems/xattr/xattr_socket_test.c
new file mode 100644
index 000000000000..fac0a4c6bc05
--- /dev/null
+++ b/tools/testing/selftests/filesystems/xattr/xattr_socket_test.c
@@ -0,0 +1,470 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (c) 2026 Christian Brauner <brauner@kernel.org>
+/*
+ * Test extended attributes on path-based Unix domain sockets.
+ *
+ * Path-based Unix domain sockets are bound to a filesystem path and their
+ * inodes live on the underlying filesystem (e.g. tmpfs). These tests verify
+ * that user.* and trusted.* xattr operations work correctly on them using
+ * path-based syscalls (setxattr, getxattr, etc.).
+ *
+ * Covers SOCK_STREAM, SOCK_DGRAM, and SOCK_SEQPACKET socket types.
+ */
+
+#define _GNU_SOURCE
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/un.h>
+#include <sys/xattr.h>
+#include <unistd.h>
+
+#include "../../kselftest_harness.h"
+
+#define TEST_XATTR_NAME "user.testattr"
+#define TEST_XATTR_VALUE "testvalue"
+#define TEST_XATTR_VALUE2 "newvalue"
+
+/*
+ * Fixture for path-based Unix domain socket tests.
+ * Creates a SOCK_STREAM socket bound to a path in /tmp (typically tmpfs).
+ */
+FIXTURE(xattr_socket)
+{
+ char socket_path[PATH_MAX];
+ int sockfd;
+};
+
+FIXTURE_VARIANT(xattr_socket)
+{
+ int sock_type;
+ const char *name;
+};
+
+FIXTURE_VARIANT_ADD(xattr_socket, stream) {
+ .sock_type = SOCK_STREAM,
+ .name = "stream",
+};
+
+FIXTURE_VARIANT_ADD(xattr_socket, dgram) {
+ .sock_type = SOCK_DGRAM,
+ .name = "dgram",
+};
+
+FIXTURE_VARIANT_ADD(xattr_socket, seqpacket) {
+ .sock_type = SOCK_SEQPACKET,
+ .name = "seqpacket",
+};
+
+FIXTURE_SETUP(xattr_socket)
+{
+ struct sockaddr_un addr;
+ int ret;
+
+ self->sockfd = -1;
+
+ snprintf(self->socket_path, sizeof(self->socket_path),
+ "/tmp/xattr_socket_test_%s.%d", variant->name, getpid());
+ unlink(self->socket_path);
+
+ self->sockfd = socket(AF_UNIX, variant->sock_type, 0);
+ ASSERT_GE(self->sockfd, 0) {
+ TH_LOG("Failed to create socket: %s", strerror(errno));
+ }
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sun_family = AF_UNIX;
+ strncpy(addr.sun_path, self->socket_path, sizeof(addr.sun_path) - 1);
+
+ ret = bind(self->sockfd, (struct sockaddr *)&addr, sizeof(addr));
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("Failed to bind socket to %s: %s",
+ self->socket_path, strerror(errno));
+ }
+}
+
+FIXTURE_TEARDOWN(xattr_socket)
+{
+ if (self->sockfd >= 0)
+ close(self->sockfd);
+ unlink(self->socket_path);
+}
+
+TEST_F(xattr_socket, set_user_xattr)
+{
+ int ret;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("setxattr failed: %s (errno=%d)", strerror(errno), errno);
+ }
+}
+
+TEST_F(xattr_socket, get_user_xattr)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("setxattr failed: %s", strerror(errno));
+ }
+
+ memset(buf, 0, sizeof(buf));
+ ret = getxattr(self->socket_path, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE)) {
+ TH_LOG("getxattr returned %zd, expected %zu: %s",
+ ret, strlen(TEST_XATTR_VALUE), strerror(errno));
+ }
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE);
+}
+
+TEST_F(xattr_socket, list_user_xattr)
+{
+ char list[1024];
+ ssize_t ret;
+ bool found = false;
+ char *ptr;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("setxattr failed: %s", strerror(errno));
+ }
+
+ memset(list, 0, sizeof(list));
+ ret = listxattr(self->socket_path, list, sizeof(list));
+ ASSERT_GT(ret, 0) {
+ TH_LOG("listxattr failed: %s", strerror(errno));
+ }
+
+ for (ptr = list; ptr < list + ret; ptr += strlen(ptr) + 1) {
+ if (strcmp(ptr, TEST_XATTR_NAME) == 0) {
+ found = true;
+ break;
+ }
+ }
+ ASSERT_TRUE(found) {
+ TH_LOG("xattr %s not found in list", TEST_XATTR_NAME);
+ }
+}
+
+TEST_F(xattr_socket, remove_user_xattr)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("setxattr failed: %s", strerror(errno));
+ }
+
+ ret = removexattr(self->socket_path, TEST_XATTR_NAME);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("removexattr failed: %s", strerror(errno));
+ }
+
+ ret = getxattr(self->socket_path, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENODATA) {
+ TH_LOG("Expected ENODATA, got %s", strerror(errno));
+ }
+}
+
+/*
+ * Test that xattrs persist across socket close and reopen.
+ * The xattr is on the filesystem inode, not the socket fd.
+ */
+TEST_F(xattr_socket, xattr_persistence)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("setxattr failed: %s", strerror(errno));
+ }
+
+ close(self->sockfd);
+ self->sockfd = -1;
+
+ memset(buf, 0, sizeof(buf));
+ ret = getxattr(self->socket_path, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE)) {
+ TH_LOG("getxattr after close failed: %s", strerror(errno));
+ }
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE);
+}
+
+TEST_F(xattr_socket, update_user_xattr)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE2, strlen(TEST_XATTR_VALUE2), 0);
+ ASSERT_EQ(ret, 0);
+
+ memset(buf, 0, sizeof(buf));
+ ret = getxattr(self->socket_path, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE2));
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE2);
+}
+
+TEST_F(xattr_socket, xattr_create_flag)
+{
+ int ret;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE2, strlen(TEST_XATTR_VALUE2), XATTR_CREATE);
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, EEXIST);
+}
+
+TEST_F(xattr_socket, xattr_replace_flag)
+{
+ int ret;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), XATTR_REPLACE);
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENODATA);
+}
+
+TEST_F(xattr_socket, multiple_xattrs)
+{
+ char buf[256];
+ ssize_t ret;
+ int i;
+ char name[64], value[64];
+ const int num_xattrs = 5;
+
+ for (i = 0; i < num_xattrs; i++) {
+ snprintf(name, sizeof(name), "user.test%d", i);
+ snprintf(value, sizeof(value), "value%d", i);
+ ret = setxattr(self->socket_path, name, value, strlen(value), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("setxattr %s failed: %s", name, strerror(errno));
+ }
+ }
+
+ for (i = 0; i < num_xattrs; i++) {
+ snprintf(name, sizeof(name), "user.test%d", i);
+ snprintf(value, sizeof(value), "value%d", i);
+ memset(buf, 0, sizeof(buf));
+ ret = getxattr(self->socket_path, name, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(value));
+ ASSERT_STREQ(buf, value);
+ }
+}
+
+TEST_F(xattr_socket, xattr_empty_value)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME, "", 0, 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = getxattr(self->socket_path, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, 0);
+}
+
+TEST_F(xattr_socket, xattr_get_size)
+{
+ ssize_t ret;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = getxattr(self->socket_path, TEST_XATTR_NAME, NULL, 0);
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE));
+}
+
+TEST_F(xattr_socket, xattr_buffer_too_small)
+{
+ char buf[2];
+ ssize_t ret;
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = getxattr(self->socket_path, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ERANGE);
+}
+
+TEST_F(xattr_socket, xattr_nonexistent)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = getxattr(self->socket_path, "user.nonexistent", buf, sizeof(buf));
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENODATA);
+}
+
+TEST_F(xattr_socket, remove_nonexistent_xattr)
+{
+ int ret;
+
+ ret = removexattr(self->socket_path, "user.nonexistent");
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENODATA);
+}
+
+TEST_F(xattr_socket, large_xattr_value)
+{
+ char large_value[4096];
+ char read_buf[4096];
+ ssize_t ret;
+
+ memset(large_value, 'A', sizeof(large_value));
+
+ ret = setxattr(self->socket_path, TEST_XATTR_NAME,
+ large_value, sizeof(large_value), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("setxattr with large value failed: %s", strerror(errno));
+ }
+
+ memset(read_buf, 0, sizeof(read_buf));
+ ret = getxattr(self->socket_path, TEST_XATTR_NAME,
+ read_buf, sizeof(read_buf));
+ ASSERT_EQ(ret, (ssize_t)sizeof(large_value));
+ ASSERT_EQ(memcmp(large_value, read_buf, sizeof(large_value)), 0);
+}
+
+/*
+ * Test lsetxattr/lgetxattr (don't follow symlinks).
+ * Socket files aren't symlinks, so this should work the same.
+ */
+TEST_F(xattr_socket, lsetxattr_lgetxattr)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = lsetxattr(self->socket_path, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("lsetxattr failed: %s", strerror(errno));
+ }
+
+ memset(buf, 0, sizeof(buf));
+ ret = lgetxattr(self->socket_path, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE));
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE);
+}
+
+/*
+ * Fixture for trusted.* xattr tests.
+ * These require CAP_SYS_ADMIN.
+ */
+FIXTURE(xattr_socket_trusted)
+{
+ char socket_path[PATH_MAX];
+ int sockfd;
+};
+
+FIXTURE_VARIANT(xattr_socket_trusted)
+{
+ int sock_type;
+ const char *name;
+};
+
+FIXTURE_VARIANT_ADD(xattr_socket_trusted, stream) {
+ .sock_type = SOCK_STREAM,
+ .name = "stream",
+};
+
+FIXTURE_VARIANT_ADD(xattr_socket_trusted, dgram) {
+ .sock_type = SOCK_DGRAM,
+ .name = "dgram",
+};
+
+FIXTURE_VARIANT_ADD(xattr_socket_trusted, seqpacket) {
+ .sock_type = SOCK_SEQPACKET,
+ .name = "seqpacket",
+};
+
+FIXTURE_SETUP(xattr_socket_trusted)
+{
+ struct sockaddr_un addr;
+ int ret;
+
+ self->sockfd = -1;
+
+ snprintf(self->socket_path, sizeof(self->socket_path),
+ "/tmp/xattr_socket_trusted_%s.%d", variant->name, getpid());
+ unlink(self->socket_path);
+
+ self->sockfd = socket(AF_UNIX, variant->sock_type, 0);
+ ASSERT_GE(self->sockfd, 0);
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sun_family = AF_UNIX;
+ strncpy(addr.sun_path, self->socket_path, sizeof(addr.sun_path) - 1);
+
+ ret = bind(self->sockfd, (struct sockaddr *)&addr, sizeof(addr));
+ ASSERT_EQ(ret, 0);
+}
+
+FIXTURE_TEARDOWN(xattr_socket_trusted)
+{
+ if (self->sockfd >= 0)
+ close(self->sockfd);
+ unlink(self->socket_path);
+}
+
+TEST_F(xattr_socket_trusted, set_trusted_xattr)
+{
+ char buf[256];
+ ssize_t len;
+ int ret;
+
+ ret = setxattr(self->socket_path, "trusted.testattr",
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ if (ret == -1 && errno == EPERM)
+ SKIP(return, "Need CAP_SYS_ADMIN for trusted.* xattrs");
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("setxattr trusted.testattr failed: %s", strerror(errno));
+ }
+
+ memset(buf, 0, sizeof(buf));
+ len = getxattr(self->socket_path, "trusted.testattr",
+ buf, sizeof(buf));
+ ASSERT_EQ(len, (ssize_t)strlen(TEST_XATTR_VALUE));
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE);
+}
+
+TEST_F(xattr_socket_trusted, get_trusted_xattr_unprivileged)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = getxattr(self->socket_path, "trusted.testattr", buf, sizeof(buf));
+ ASSERT_EQ(ret, -1);
+ ASSERT_TRUE(errno == ENODATA || errno == EPERM) {
+ TH_LOG("Expected ENODATA or EPERM, got %s", strerror(errno));
+ }
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/xattr/xattr_socket_types_test.c b/tools/testing/selftests/filesystems/xattr/xattr_socket_types_test.c
new file mode 100644
index 000000000000..bfabe91b2ed1
--- /dev/null
+++ b/tools/testing/selftests/filesystems/xattr/xattr_socket_types_test.c
@@ -0,0 +1,177 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (c) 2026 Christian Brauner <brauner@kernel.org>
+/*
+ * Test user.* xattrs on various socket families.
+ *
+ * All socket types use sockfs for their inodes, so user.* xattrs should
+ * work on any socket regardless of address family. This tests AF_INET,
+ * AF_INET6, AF_NETLINK, AF_PACKET, and abstract namespace AF_UNIX sockets.
+ */
+
+#define _GNU_SOURCE
+#include <errno.h>
+#include <stddef.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/types.h>
+#include <sys/un.h>
+#include <sys/xattr.h>
+#include <linux/netlink.h>
+#include <unistd.h>
+
+#include "../../kselftest_harness.h"
+
+#define TEST_XATTR_NAME "user.testattr"
+#define TEST_XATTR_VALUE "testvalue"
+
+FIXTURE(xattr_socket_types)
+{
+ int sockfd;
+};
+
+FIXTURE_VARIANT(xattr_socket_types)
+{
+ int family;
+ int type;
+ int protocol;
+};
+
+FIXTURE_VARIANT_ADD(xattr_socket_types, inet) {
+ .family = AF_INET,
+ .type = SOCK_STREAM,
+ .protocol = 0,
+};
+
+FIXTURE_VARIANT_ADD(xattr_socket_types, inet6) {
+ .family = AF_INET6,
+ .type = SOCK_STREAM,
+ .protocol = 0,
+};
+
+FIXTURE_VARIANT_ADD(xattr_socket_types, netlink) {
+ .family = AF_NETLINK,
+ .type = SOCK_RAW,
+ .protocol = NETLINK_USERSOCK,
+};
+
+FIXTURE_VARIANT_ADD(xattr_socket_types, packet) {
+ .family = AF_PACKET,
+ .type = SOCK_DGRAM,
+ .protocol = 0,
+};
+
+FIXTURE_SETUP(xattr_socket_types)
+{
+ self->sockfd = socket(variant->family, variant->type,
+ variant->protocol);
+ if (self->sockfd < 0 &&
+ (errno == EAFNOSUPPORT || errno == EPERM || errno == EACCES))
+ SKIP(return, "socket(%d, %d, %d) not available: %s",
+ variant->family, variant->type, variant->protocol,
+ strerror(errno));
+ ASSERT_GE(self->sockfd, 0) {
+ TH_LOG("Failed to create socket(%d, %d, %d): %s",
+ variant->family, variant->type, variant->protocol,
+ strerror(errno));
+ }
+}
+
+FIXTURE_TEARDOWN(xattr_socket_types)
+{
+ if (self->sockfd >= 0)
+ close(self->sockfd);
+}
+
+TEST_F(xattr_socket_types, set_get_list_remove)
+{
+ char buf[256], list[4096], *ptr;
+ ssize_t ret;
+ bool found;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("fsetxattr failed: %s", strerror(errno));
+ }
+
+ memset(buf, 0, sizeof(buf));
+ ret = fgetxattr(self->sockfd, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE));
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE);
+
+ memset(list, 0, sizeof(list));
+ ret = flistxattr(self->sockfd, list, sizeof(list));
+ ASSERT_GT(ret, 0);
+ found = false;
+ for (ptr = list; ptr < list + ret; ptr += strlen(ptr) + 1) {
+ if (strcmp(ptr, TEST_XATTR_NAME) == 0)
+ found = true;
+ }
+ ASSERT_TRUE(found);
+
+ ret = fremovexattr(self->sockfd, TEST_XATTR_NAME);
+ ASSERT_EQ(ret, 0);
+
+ ret = fgetxattr(self->sockfd, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENODATA);
+}
+
+/*
+ * Test abstract namespace AF_UNIX socket.
+ * Abstract sockets don't have a filesystem path; their inodes live in
+ * sockfs so user.* xattrs should work via fsetxattr/fgetxattr.
+ */
+FIXTURE(xattr_abstract)
+{
+ int sockfd;
+};
+
+FIXTURE_SETUP(xattr_abstract)
+{
+ struct sockaddr_un addr;
+ char name[64];
+ int ret, len;
+
+ self->sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
+ ASSERT_GE(self->sockfd, 0);
+
+ len = snprintf(name, sizeof(name), "xattr_test_abstract_%d", getpid());
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sun_family = AF_UNIX;
+ addr.sun_path[0] = '\0';
+ memcpy(&addr.sun_path[1], name, len);
+
+ ret = bind(self->sockfd, (struct sockaddr *)&addr,
+ offsetof(struct sockaddr_un, sun_path) + 1 + len);
+ ASSERT_EQ(ret, 0);
+}
+
+FIXTURE_TEARDOWN(xattr_abstract)
+{
+ if (self->sockfd >= 0)
+ close(self->sockfd);
+}
+
+TEST_F(xattr_abstract, set_get)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("fsetxattr on abstract socket failed: %s",
+ strerror(errno));
+ }
+
+ memset(buf, 0, sizeof(buf));
+ ret = fgetxattr(self->sockfd, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE));
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/filesystems/xattr/xattr_sockfs_test.c b/tools/testing/selftests/filesystems/xattr/xattr_sockfs_test.c
new file mode 100644
index 000000000000..b4824b01a86d
--- /dev/null
+++ b/tools/testing/selftests/filesystems/xattr/xattr_sockfs_test.c
@@ -0,0 +1,363 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (c) 2026 Christian Brauner <brauner@kernel.org>
+/*
+ * Test extended attributes on sockfs sockets.
+ *
+ * Sockets created via socket() have their inodes in sockfs, which supports
+ * user.* xattrs with per-inode limits: up to 128 xattrs and 128KB total
+ * value size. These tests verify xattr operations via fsetxattr/fgetxattr/
+ * flistxattr/fremovexattr on the socket fd, as well as limit enforcement.
+ */
+
+#define _GNU_SOURCE
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/types.h>
+#include <sys/xattr.h>
+#include <unistd.h>
+
+#include "../../kselftest_harness.h"
+
+#define TEST_XATTR_NAME "user.testattr"
+#define TEST_XATTR_VALUE "testvalue"
+#define TEST_XATTR_VALUE2 "newvalue"
+
+/* Per-inode limits for user.* xattrs on sockfs (from include/linux/xattr.h) */
+#define SIMPLE_XATTR_MAX_NR 128
+#define SIMPLE_XATTR_MAX_SIZE (128 << 10) /* 128 KB */
+
+#ifndef XATTR_SIZE_MAX
+#define XATTR_SIZE_MAX 65536
+#endif
+
+/*
+ * Fixture for sockfs socket xattr tests.
+ * Creates an AF_UNIX socket (lives in sockfs, not bound to any path).
+ */
+FIXTURE(xattr_sockfs)
+{
+ int sockfd;
+};
+
+FIXTURE_SETUP(xattr_sockfs)
+{
+ self->sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
+ ASSERT_GE(self->sockfd, 0) {
+ TH_LOG("Failed to create socket: %s", strerror(errno));
+ }
+}
+
+FIXTURE_TEARDOWN(xattr_sockfs)
+{
+ if (self->sockfd >= 0)
+ close(self->sockfd);
+}
+
+TEST_F(xattr_sockfs, set_get_user_xattr)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("fsetxattr failed: %s", strerror(errno));
+ }
+
+ memset(buf, 0, sizeof(buf));
+ ret = fgetxattr(self->sockfd, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE)) {
+ TH_LOG("fgetxattr returned %zd: %s", ret, strerror(errno));
+ }
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE);
+}
+
+/*
+ * Test listing xattrs on a sockfs socket.
+ * Should include user.* xattrs and system.sockprotoname.
+ */
+TEST_F(xattr_sockfs, list_user_xattr)
+{
+ char list[4096];
+ ssize_t ret;
+ char *ptr;
+ bool found_user = false;
+ bool found_proto = false;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("fsetxattr failed: %s", strerror(errno));
+ }
+
+ memset(list, 0, sizeof(list));
+ ret = flistxattr(self->sockfd, list, sizeof(list));
+ ASSERT_GT(ret, 0) {
+ TH_LOG("flistxattr failed: %s", strerror(errno));
+ }
+
+ for (ptr = list; ptr < list + ret; ptr += strlen(ptr) + 1) {
+ if (strcmp(ptr, TEST_XATTR_NAME) == 0)
+ found_user = true;
+ if (strcmp(ptr, "system.sockprotoname") == 0)
+ found_proto = true;
+ }
+ ASSERT_TRUE(found_user) {
+ TH_LOG("user xattr not found in list");
+ }
+ ASSERT_TRUE(found_proto) {
+ TH_LOG("system.sockprotoname not found in list");
+ }
+}
+
+TEST_F(xattr_sockfs, remove_user_xattr)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = fremovexattr(self->sockfd, TEST_XATTR_NAME);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("fremovexattr failed: %s", strerror(errno));
+ }
+
+ ret = fgetxattr(self->sockfd, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENODATA);
+}
+
+TEST_F(xattr_sockfs, update_user_xattr)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE2, strlen(TEST_XATTR_VALUE2), 0);
+ ASSERT_EQ(ret, 0);
+
+ memset(buf, 0, sizeof(buf));
+ ret = fgetxattr(self->sockfd, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE2));
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE2);
+}
+
+TEST_F(xattr_sockfs, xattr_create_flag)
+{
+ int ret;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE2, strlen(TEST_XATTR_VALUE2),
+ XATTR_CREATE);
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, EEXIST);
+}
+
+TEST_F(xattr_sockfs, xattr_replace_flag)
+{
+ int ret;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE),
+ XATTR_REPLACE);
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENODATA);
+}
+
+TEST_F(xattr_sockfs, get_nonexistent)
+{
+ char buf[256];
+ ssize_t ret;
+
+ ret = fgetxattr(self->sockfd, "user.nonexistent", buf, sizeof(buf));
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENODATA);
+}
+
+TEST_F(xattr_sockfs, empty_value)
+{
+ ssize_t ret;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME, "", 0, 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = fgetxattr(self->sockfd, TEST_XATTR_NAME, NULL, 0);
+ ASSERT_EQ(ret, 0);
+}
+
+TEST_F(xattr_sockfs, get_size)
+{
+ ssize_t ret;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = fgetxattr(self->sockfd, TEST_XATTR_NAME, NULL, 0);
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE));
+}
+
+TEST_F(xattr_sockfs, buffer_too_small)
+{
+ char buf[2];
+ ssize_t ret;
+
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0);
+
+ ret = fgetxattr(self->sockfd, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ERANGE);
+}
+
+/*
+ * Test maximum number of user.* xattrs per socket.
+ * The kernel enforces SIMPLE_XATTR_MAX_NR (128), so the 129th should
+ * fail with ENOSPC.
+ */
+TEST_F(xattr_sockfs, max_nr_xattrs)
+{
+ char name[32];
+ int i, ret;
+
+ for (i = 0; i < SIMPLE_XATTR_MAX_NR; i++) {
+ snprintf(name, sizeof(name), "user.test%03d", i);
+ ret = fsetxattr(self->sockfd, name, "v", 1, 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("fsetxattr %s failed at i=%d: %s",
+ name, i, strerror(errno));
+ }
+ }
+
+ ret = fsetxattr(self->sockfd, "user.overflow", "v", 1, 0);
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENOSPC) {
+ TH_LOG("Expected ENOSPC for xattr %d, got %s",
+ SIMPLE_XATTR_MAX_NR + 1, strerror(errno));
+ }
+}
+
+/*
+ * Test maximum total value size for user.* xattrs.
+ * The kernel enforces SIMPLE_XATTR_MAX_SIZE (128KB). Individual xattr
+ * values are limited to XATTR_SIZE_MAX (64KB) by the VFS, so we need
+ * at least two xattrs to hit the total limit.
+ */
+TEST_F(xattr_sockfs, max_xattr_size)
+{
+ char *value;
+ int ret;
+
+ value = malloc(XATTR_SIZE_MAX);
+ ASSERT_NE(value, NULL);
+ memset(value, 'A', XATTR_SIZE_MAX);
+
+ /* First 64KB xattr - total = 64KB */
+ ret = fsetxattr(self->sockfd, "user.big1", value, XATTR_SIZE_MAX, 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("first large xattr failed: %s", strerror(errno));
+ }
+
+ /* Second 64KB xattr - total = 128KB (exactly at limit) */
+ ret = fsetxattr(self->sockfd, "user.big2", value, XATTR_SIZE_MAX, 0);
+ free(value);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("second large xattr failed: %s", strerror(errno));
+ }
+
+ /* Third xattr with 1 byte - total > 128KB, should fail */
+ ret = fsetxattr(self->sockfd, "user.big3", "v", 1, 0);
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENOSPC) {
+ TH_LOG("Expected ENOSPC when exceeding size limit, got %s",
+ strerror(errno));
+ }
+}
+
+/*
+ * Test that removing an xattr frees limit space, allowing re-addition.
+ */
+TEST_F(xattr_sockfs, limit_remove_readd)
+{
+ char name[32];
+ int i, ret;
+
+ /* Fill up to the maximum count */
+ for (i = 0; i < SIMPLE_XATTR_MAX_NR; i++) {
+ snprintf(name, sizeof(name), "user.test%03d", i);
+ ret = fsetxattr(self->sockfd, name, "v", 1, 0);
+ ASSERT_EQ(ret, 0);
+ }
+
+ /* Verify we're at the limit */
+ ret = fsetxattr(self->sockfd, "user.overflow", "v", 1, 0);
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENOSPC);
+
+ /* Remove one xattr */
+ ret = fremovexattr(self->sockfd, "user.test000");
+ ASSERT_EQ(ret, 0);
+
+ /* Now we should be able to add one more */
+ ret = fsetxattr(self->sockfd, "user.newattr", "v", 1, 0);
+ ASSERT_EQ(ret, 0) {
+ TH_LOG("re-add after remove failed: %s", strerror(errno));
+ }
+}
+
+/*
+ * Test that two different sockets have independent xattr limits.
+ */
+TEST_F(xattr_sockfs, limits_per_inode)
+{
+ char buf[256];
+ int sock2;
+ ssize_t ret;
+
+ sock2 = socket(AF_UNIX, SOCK_STREAM, 0);
+ ASSERT_GE(sock2, 0);
+
+ /* Set xattr on first socket */
+ ret = fsetxattr(self->sockfd, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE, strlen(TEST_XATTR_VALUE), 0);
+ ASSERT_EQ(ret, 0);
+
+ /* First socket's xattr should not be visible on second socket */
+ ret = fgetxattr(sock2, TEST_XATTR_NAME, NULL, 0);
+ ASSERT_EQ(ret, -1);
+ ASSERT_EQ(errno, ENODATA);
+
+ /* Second socket should independently accept xattrs */
+ ret = fsetxattr(sock2, TEST_XATTR_NAME,
+ TEST_XATTR_VALUE2, strlen(TEST_XATTR_VALUE2), 0);
+ ASSERT_EQ(ret, 0);
+
+ /* Verify each socket has its own value */
+ memset(buf, 0, sizeof(buf));
+ ret = fgetxattr(self->sockfd, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE));
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE);
+
+ memset(buf, 0, sizeof(buf));
+ ret = fgetxattr(sock2, TEST_XATTR_NAME, buf, sizeof(buf));
+ ASSERT_EQ(ret, (ssize_t)strlen(TEST_XATTR_VALUE2));
+ ASSERT_STREQ(buf, TEST_XATTR_VALUE2);
+
+ close(sock2);
+}
+
+TEST_HARNESS_MAIN