From: Shakeel Butt <shakeel.butt@linux.dev>
To: Greg Kroah-Hartman <gregkh@linuxfoundation.org>,
Tejun Heo <tj@kernel.org>,
Christian Brauner <christian@brauner.io>
Cc: Meta kernel team <kernel-team@meta.com>,
linux-kselftest@vger.kernel.org, driver-core@lists.linux.dev,
linux-kernel@vger.kernel.org
Subject: [PATCH v2 1/4] selftests: cover kernfs file handles and same-parent rename
Date: Sat, 5 Sep 2026 12:16:10 -0700 [thread overview]
Message-ID: <20260905191613.3143937-2-shakeel.butt@linux.dev> (raw)
In-Reply-To: <20260905191613.3143937-1-shakeel.butt@linux.dev>
The kernfs tests only reach kernfs through lookup and readdir. Two
paths are not covered: file handles, which find a node without a lookup
through its parent, and rename of a node that keeps its parent.
Add three tests:
- decode a file handle, live and after the node is gone;
- decode while the node is being removed;
- look up a name while an interface is renamed, which renames its
/sys/class/net entry with the parent unchanged.
Two small fixes while here. ns_tag_isolates_class_net now counts only
symlinks, because bonding adds a bonding_masters attribute that is not a
device. A failing mkdtemp() now skips instead of aborting.
Assisted-by: LLM
Signed-off-by: Shakeel Butt <shakeel.butt@linux.dev>
---
tools/testing/selftests/filesystems/config | 1 +
.../selftests/filesystems/kernfs_test.c | 296 +++++++++++++++++-
2 files changed, 295 insertions(+), 2 deletions(-)
diff --git a/tools/testing/selftests/filesystems/config b/tools/testing/selftests/filesystems/config
index 7231710d5ce7..9f45bc493a30 100644
--- a/tools/testing/selftests/filesystems/config
+++ b/tools/testing/selftests/filesystems/config
@@ -1,5 +1,6 @@
CONFIG_CGROUPS=y
CONFIG_CGROUP_PIDS=y
+CONFIG_FHANDLE=y
CONFIG_NAMESPACES=y
CONFIG_NET=y
CONFIG_NET_NS=y
diff --git a/tools/testing/selftests/filesystems/kernfs_test.c b/tools/testing/selftests/filesystems/kernfs_test.c
index 2178428b9665..6e74da91ebca 100644
--- a/tools/testing/selftests/filesystems/kernfs_test.c
+++ b/tools/testing/selftests/filesystems/kernfs_test.c
@@ -610,6 +610,189 @@ TEST_F(kernfs_cgroup, lookup_vs_create_remove_stress)
}
}
+struct kernfs_handle {
+ struct file_handle h;
+ unsigned char buf[MAX_HANDLE_SZ];
+};
+
+static int kernfs_encode(const char *path, struct kernfs_handle *fh)
+{
+ int mount_id;
+
+ memset(fh, 0, sizeof(*fh));
+ fh->h.handle_bytes = sizeof(fh->buf);
+ return name_to_handle_at(AT_FDCWD, path, &fh->h, &mount_id, 0);
+}
+
+/*
+ * Skip only where file handles do not work at all. ENOENT must still
+ * fail: mkdir leaves a negative dentry cached, so the name resolves only
+ * after ->d_revalidate() drops it. The encode tests revalidation too.
+ */
+static bool fh_unsupported(int err)
+{
+ return err == EOPNOTSUPP || err == EPERM || err == ENOSYS;
+}
+
+/*
+ * Decoding a file needs CAP_DAC_READ_SEARCH in the initial user
+ * namespace. Probe once so the tests skip instead of fail.
+ */
+static bool fh_can_decode(int mfd, struct kernfs_handle *fh)
+{
+ int fd = open_by_handle_at(mfd, &fh->h, O_PATH);
+
+ if (fd < 0)
+ return errno != EPERM;
+ close(fd);
+ return true;
+}
+
+/*
+ * A file handle reaches a node without a lookup through its parent. A
+ * live node must decode. A removed one must not, because
+ * kernfs_find_and_get_node_by_id() refuses inactive nodes.
+ *
+ * Use O_PATH: opening a removed node fails with ENODEV, which would hide
+ * what is being tested.
+ */
+TEST_F(kernfs_cgroup, exportfs_decode_and_stale)
+{
+ char victim[PATH_MAX], procs[PATH_MAX];
+ struct kernfs_handle fh;
+ struct stat st;
+ int mfd, fd;
+
+ snprintf(victim, sizeof(victim), "%s/fh", self->scratch);
+ snprintf(procs, sizeof(procs), "%s/cgroup.procs", victim);
+ ASSERT_EQ(mkdir(victim, 0755), 0);
+
+ /* Any fd on the filesystem identifies it to open_by_handle_at(). */
+ mfd = open(self->scratch, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ ASSERT_GE(mfd, 0);
+
+ if (kernfs_encode(procs, &fh)) {
+ int err = errno;
+
+ close(mfd);
+ rmdir(victim);
+ ASSERT_TRUE(fh_unsupported(err))
+ TH_LOG("name_to_handle_at: %s", strerror(err));
+ SKIP(return, "name_to_handle_at: %s", strerror(err));
+ }
+
+ if (!fh_can_decode(mfd, &fh)) {
+ close(mfd);
+ rmdir(victim);
+ SKIP(return, "open_by_handle_at: no CAP_DAC_READ_SEARCH");
+ }
+
+ fd = open_by_handle_at(mfd, &fh.h, O_PATH);
+ ASSERT_GE(fd, 0);
+ EXPECT_EQ(fstat(fd, &st), 0);
+ EXPECT_EQ(st.st_nlink, 1);
+ EXPECT_EQ(close(fd), 0);
+
+ ASSERT_EQ(rmdir(victim), 0);
+
+ fd = open_by_handle_at(mfd, &fh.h, O_PATH);
+ EXPECT_LT(fd, 0);
+ if (fd >= 0)
+ close(fd);
+ else
+ EXPECT_EQ(errno, ESTALE);
+
+ EXPECT_EQ(close(mfd), 0);
+}
+
+#define FH_STRESS_SECS 2
+#define FH_DECODE_CAP 10000
+
+/*
+ * Decode file handles while the node is being removed. A decode must
+ * answer with a usable handle or ESTALE, never garbage and never a hang.
+ *
+ * The link count is checked too. An inode that reaches the inode hash
+ * after the removal cleared link counts keeps the 1 it was born with, so
+ * it never gets an IN_DELETE_SELF. This has not been seen to fire: it
+ * needs the decode to stall between the lookup by id and the hash insert,
+ * and nothing there blocks. It is kept because it is cheap and only
+ * looks once the directory is gone, so it cannot fail falsely.
+ */
+TEST_F(kernfs_cgroup, exportfs_decode_vs_rmdir_stress)
+{
+ int mfd, bad = 0, rounds = 0;
+ struct timespec end;
+
+ mfd = open(self->scratch, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ ASSERT_GE(mfd, 0);
+
+ clock_gettime(CLOCK_MONOTONIC, &end);
+ end.tv_sec += FH_STRESS_SECS;
+
+ while (!stress_deadline(&end)) {
+ char victim[PATH_MAX], procs[PATH_MAX];
+ int last = -1, fd, i;
+ struct kernfs_handle fh;
+ struct stat st;
+ pid_t pid;
+
+ snprintf(victim, sizeof(victim), "%s/fh%d", self->scratch,
+ rounds++);
+ snprintf(procs, sizeof(procs), "%s/cgroup.procs", victim);
+ if (mkdir(victim, 0755))
+ break;
+ if (kernfs_encode(procs, &fh)) {
+ int err = errno;
+
+ rmdir(victim);
+ ASSERT_TRUE(fh_unsupported(err))
+ TH_LOG("name_to_handle_at: %s", strerror(err));
+ SKIP(goto out, "name_to_handle_at: %s", strerror(err));
+ }
+ if (rounds == 1 && !fh_can_decode(mfd, &fh)) {
+ rmdir(victim);
+ SKIP(goto out,
+ "open_by_handle_at: no CAP_DAC_READ_SEARCH");
+ }
+
+ pid = fork();
+ ASSERT_GE(pid, 0);
+ if (pid == 0) {
+ rmdir_retry(victim);
+ _exit(0);
+ }
+
+ /*
+ * Decode until the removal deactivates the node. Keep the
+ * last one that worked: it ran closest to the removal.
+ */
+ for (i = 0; i < FH_DECODE_CAP; i++) {
+ fd = open_by_handle_at(mfd, &fh.h, O_PATH);
+ if (fd < 0)
+ break;
+ if (last >= 0)
+ close(last);
+ last = fd;
+ }
+ ASSERT_EQ(waitpid(pid, NULL, 0), pid);
+
+ if (last >= 0) {
+ if (access(victim, F_OK) && errno == ENOENT &&
+ !fstat(last, &st) && st.st_nlink != 0)
+ bad++;
+ close(last);
+ }
+ rmdir(victim);
+ }
+
+ EXPECT_EQ(bad, 0)
+ TH_LOG("%d of %d rounds decoded a removed node whose inode kept its link count",
+ bad, rounds);
+out:
+ close(mfd);
+}
+
/*
* sysfs is namespace tagged (KERNFS_NS) and supports rename; cgroup2 does
* neither. Run in a private netns with its own sysfs so the host is
@@ -635,7 +818,8 @@ FIXTURE_SETUP(kernfs_netns)
ASSERT_EQ(mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL), 0);
strcpy(self->mnt, "/tmp/kernfs_selftest_sysfs.XXXXXX");
- ASSERT_NE(mkdtemp(self->mnt), NULL);
+ if (!mkdtemp(self->mnt))
+ SKIP(return, "mkdtemp: %s", strerror(errno));
if (mount("none", self->mnt, "sysfs", 0, NULL)) {
rmdir(self->mnt);
@@ -662,6 +846,9 @@ FIXTURE_TEARDOWN(kernfs_netns)
* depends on the modules the host has. Check the set instead --
* if_nametoindex() resolves in the current netns, so every name sysfs shows
* must resolve there, and the counts must agree.
+ *
+ * Count only symlinks. Not every entry is a device: bonding adds a
+ * bonding_masters attribute to /sys/class/net in every namespace.
*/
TEST_F(kernfs_netns, ns_tag_isolates_class_net)
{
@@ -674,7 +861,7 @@ TEST_F(kernfs_netns, ns_tag_isolates_class_net)
d = opendir(self->net);
ASSERT_NE(d, NULL);
while ((de = readdir(d))) {
- if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, ".."))
+ if (de->d_type != DT_LNK)
continue;
EXPECT_NE(if_nametoindex(de->d_name), 0u)
TH_LOG("%s is not in this netns", de->d_name);
@@ -728,4 +915,109 @@ TEST_F(kernfs_netns, rename_is_revalidated)
EXPECT_EQ(stat(new_path, &st), 0);
}
+static int netdev_rename(const char *from, const char *to)
+{
+ struct ifreq ifr = {};
+ int sk, ret;
+
+ sk = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0);
+ if (sk < 0)
+ return -1;
+ strncpy(ifr.ifr_name, from, IFNAMSIZ - 1);
+ strncpy(ifr.ifr_newname, to, IFNAMSIZ - 1);
+ ret = ioctl(sk, SIOCSIFNAME, &ifr);
+ close(sk);
+ return ret;
+}
+
+/*
+ * Bounded by a count, not by time: every rename is logged and not rate
+ * limited, so a timed loop would flood the kernel log.
+ */
+#define RENAME_FLIPS 200
+#define RENAME_READERS 4
+
+/*
+ * Rename an interface while other tasks look up the names it moves
+ * between. This renames its /sys/class/net entry through
+ * kernfs_rename_ns() with the parent unchanged.
+ *
+ * The renamer checks what is certain: SIOCSIFNAME returns once the rename
+ * is done and nothing else renames here, so the new name must resolve and
+ * the old must not. The readers cannot check that, because the name can
+ * move between their two lstat() calls. They only check that a lookup
+ * returns success or ENOENT, and keep the lock busy while renames run.
+ *
+ * lstat() not stat(): /sys/class/net/<dev> is a symlink and is renamed
+ * before the directory it points at, so the two are not atomic.
+ */
+TEST_F(kernfs_netns, rename_vs_lookup_stress)
+{
+ char old_path[PATH_MAX], new_path[PATH_MAX];
+ pid_t pids[RENAME_READERS];
+ int i, status, n = 0, bad = 0;
+ struct stat st;
+ int done[2];
+
+ snprintf(old_path, sizeof(old_path), "%s/lo", self->net);
+ snprintf(new_path, sizeof(new_path), "%s/%s", self->net, TEST_IFNAME);
+
+ if (netdev_rename("lo", TEST_IFNAME))
+ SKIP(return, "SIOCSIFNAME: %s", strerror(errno));
+ if (netdev_rename(TEST_IFNAME, "lo"))
+ SKIP(return, "SIOCSIFNAME back: %s", strerror(errno));
+
+ /* Readers run until the renamer closes the write end. */
+ ASSERT_EQ(pipe2(done, O_NONBLOCK | O_CLOEXEC), 0);
+
+ for (i = 0; i < RENAME_READERS; i++) {
+ pid_t pid = fork();
+
+ ASSERT_GE(pid, 0);
+ if (pid == 0) {
+ struct stat rst;
+ char c;
+
+ close(done[1]);
+ while (read(done[0], &c, 1) < 0 && errno == EAGAIN) {
+ if (lstat(old_path, &rst) && errno != ENOENT)
+ _exit(20);
+ if (lstat(new_path, &rst) && errno != ENOENT)
+ _exit(21);
+ }
+ _exit(0);
+ }
+ pids[n++] = pid;
+ }
+ close(done[0]);
+
+ for (i = 0; i < RENAME_FLIPS; i++) {
+ if (netdev_rename("lo", TEST_IFNAME))
+ break;
+ if (lstat(new_path, &st) || !lstat(old_path, &st)) {
+ bad++;
+ break;
+ }
+ if (netdev_rename(TEST_IFNAME, "lo"))
+ break;
+ if (lstat(old_path, &st) || !lstat(new_path, &st)) {
+ bad++;
+ break;
+ }
+ }
+ close(done[1]);
+
+ for (i = 0; i < n; i++) {
+ ASSERT_EQ(waitpid(pids[i], &status, 0), pids[i]);
+ ASSERT_TRUE(WIFEXITED(status));
+ EXPECT_EQ(WEXITSTATUS(status), 0);
+ }
+
+ EXPECT_EQ(bad, 0)
+ TH_LOG("a completed rename left the wrong name resolving");
+
+ /* Leave the interface as the fixture found it. */
+ netdev_rename(TEST_IFNAME, "lo");
+}
+
TEST_HARNESS_MAIN
--
2.53.0-Meta
next prev parent reply other threads:[~2026-09-05 19:16 UTC|newest]
Thread overview: 6+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-09-05 19:16 [PATCH v2 0/4] kernfs: three standalone fixes Shakeel Butt
2026-09-05 19:16 ` Shakeel Butt [this message]
2026-09-05 19:16 ` [PATCH v2 2/4] kernfs: take kernfs_rename_lock for same-parent renames too Shakeel Butt
2026-09-05 19:16 ` [PATCH v2 3/4] kernfs: don't lose IN_DELETE_SELF when decoding a file handle Shakeel Butt
2026-09-05 19:16 ` [PATCH v2 4/4] kernfs: fix up the unlocked attribute reads on the creation paths Shakeel Butt
2026-09-10 7:37 ` [PATCH v2 0/4] kernfs: three standalone fixes Christian Brauner
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260905191613.3143937-2-shakeel.butt@linux.dev \
--to=shakeel.butt@linux.dev \
--cc=christian@brauner.io \
--cc=driver-core@lists.linux.dev \
--cc=gregkh@linuxfoundation.org \
--cc=kernel-team@meta.com \
--cc=linux-kernel@vger.kernel.org \
--cc=linux-kselftest@vger.kernel.org \
--cc=tj@kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.