From: jamz <jamz@amazon.com>
To: Miklos Szeredi <miklos@szeredi.hu>
Cc: Jimmy Zuber <jamz@amazon.com>, <fuse-devel@lists.linux.dev>,
<linux-kselftest@vger.kernel.org>, Shuah Khan <shuah@kernel.org>,
"Bernd Schubert" <bernd@bsbernd.com>,
Jim Harris <jiharris@nvidia.com>,
"Horst Birthelmer" <horst@birthelmer.de>,
Luis Henriques <luis@igalia.com>, NeilBrown <neil@brown.name>
Subject: [PATCH 3/3] selftests/fuse: add test for FUSE_OPEN_ATOMIC
Date: Tue, 20 Oct 2026 14:16:47 +0000 [thread overview]
Message-ID: <20261020141647.3271341-4-jamz@amazon.com> (raw)
In-Reply-To: <20261020141647.3271341-1-jamz@amazon.com>
From: Jimmy Zuber <jamz@amazon.com>
Check the requests sent when opening and creating new, existing, missing
and expired names, against a raw /dev/fuse server with and without
FUSE_OPEN_ATOMIC, and that opening an existing read-only file is still
permission checked.
Assisted-by: Claude:claude-opus-5-5 [Claude-Code]
Signed-off-by: Jimmy Zuber <jamz@amazon.com>
---
.../selftests/filesystems/fuse/.gitignore | 1 +
.../selftests/filesystems/fuse/Makefile | 3 +-
.../filesystems/fuse/atomic_open_test.c | 473 ++++++++++++++++++
3 files changed, 476 insertions(+), 1 deletion(-)
create mode 100644 tools/testing/selftests/filesystems/fuse/atomic_open_test.c
diff --git a/tools/testing/selftests/filesystems/fuse/.gitignore b/tools/testing/selftests/filesystems/fuse/.gitignore
index b5b03db1118c..6e529fee96d4 100644
--- a/tools/testing/selftests/filesystems/fuse/.gitignore
+++ b/tools/testing/selftests/filesystems/fuse/.gitignore
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: GPL-2.0-only
+atomic_open_test
fuse_mnt
fusectl_test
test_syncfs
diff --git a/tools/testing/selftests/filesystems/fuse/Makefile b/tools/testing/selftests/filesystems/fuse/Makefile
index c2de8d225447..2fe20f0e9143 100644
--- a/tools/testing/selftests/filesystems/fuse/Makefile
+++ b/tools/testing/selftests/filesystems/fuse/Makefile
@@ -3,7 +3,7 @@
CFLAGS += -Wall -O2 -g $(KHDR_INCLUDES)
TEST_GEN_PROGS := fusectl_test test_syncfs
-TEST_GEN_PROGS += write_extend_eof_test
+TEST_GEN_PROGS += write_extend_eof_test atomic_open_test
TEST_GEN_FILES := fuse_mnt
# fuse_acl_cache_test requires libfuse3; add it only when the library is present.
@@ -16,6 +16,7 @@ endif
include ../../lib.mk
$(OUTPUT)/write_extend_eof_test: LDLIBS += -lpthread
+$(OUTPUT)/atomic_open_test: LDLIBS += -lpthread
VAR_CFLAGS := $(shell pkg-config fuse --cflags 2>/dev/null)
ifeq ($(VAR_CFLAGS),)
diff --git a/tools/testing/selftests/filesystems/fuse/atomic_open_test.c b/tools/testing/selftests/filesystems/fuse/atomic_open_test.c
new file mode 100644
index 000000000000..880367d6cb96
--- /dev/null
+++ b/tools/testing/selftests/filesystems/fuse/atomic_open_test.c
@@ -0,0 +1,473 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Test open() and create with and without FUSE_OPEN_ATOMIC.
+ *
+ * Requires root to mount fuse.
+ */
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.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 <sys/wait.h>
+#include <linux/fuse.h>
+
+#include "../../kselftest_harness.h"
+
+#define FUSE_ROOT_ID 1
+#define MAX_WRITE (128 * 1024)
+#define MAX_ENTRIES 16
+#define NOBODY 65534
+
+struct entry {
+ char name[64];
+ uint64_t nodeid;
+ uint32_t mode;
+ uint64_t entry_valid;
+};
+
+/* Server state, shared with the responder thread. */
+struct server {
+ int fd;
+ int atomic;
+ struct entry ent[MAX_ENTRIES];
+ int nent;
+ int nr_lookup, nr_create, nr_open, nr_open_atomic, nr_setattr;
+};
+
+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 struct entry *find(struct server *s, const char *name)
+{
+ int i;
+
+ for (i = 0; i < s->nent; i++)
+ if (!strcmp(s->ent[i].name, name))
+ return &s->ent[i];
+ return NULL;
+}
+
+static struct entry *add(struct server *s, const char *name, uint32_t mode)
+{
+ struct entry *e = &s->ent[s->nent];
+
+ snprintf(e->name, sizeof(e->name), "%.63s", name);
+ e->nodeid = FUSE_ROOT_ID + 1 + s->nent++;
+ e->mode = mode;
+ e->entry_valid = 3600;
+ return e;
+}
+
+/* A NULL entry is a negative entry, cached like a found one. */
+static void fill_entry(struct fuse_entry_out *out, struct entry *e)
+{
+ memset(out, 0, sizeof(*out));
+ out->entry_valid = e ? e->entry_valid : 3600;
+ if (!e)
+ return;
+ out->nodeid = e->nodeid;
+ out->attr_valid = 3600;
+ out->attr.ino = e->nodeid;
+ out->attr.mode = e->mode;
+ out->attr.nlink = S_ISDIR(e->mode) ? 2 : 1;
+ out->attr.blksize = 4096;
+}
+
+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};
+
+ 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_INIT_EXT;
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_GETATTR:
+ case FUSE_SETATTR: {
+ struct fuse_attr_out out = {0};
+ struct fuse_entry_out eo;
+ int i;
+
+ if (ih->opcode == FUSE_SETATTR)
+ s->nr_setattr++;
+ out.attr_valid = 3600;
+ if (ih->nodeid == FUSE_ROOT_ID) {
+ out.attr.ino = FUSE_ROOT_ID;
+ out.attr.mode = S_IFDIR | 0777;
+ out.attr.nlink = 2;
+ }
+ for (i = 0; i < s->nent; i++) {
+ if (s->ent[i].nodeid == ih->nodeid) {
+ fill_entry(&eo, &s->ent[i]);
+ out.attr = eo.attr;
+ }
+ }
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_LOOKUP: {
+ struct fuse_entry_out out;
+
+ s->nr_lookup++;
+ fill_entry(&out, find(s, (char *)(ih + 1)));
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_CREATE:
+ case FUSE_OPEN_ATOMIC: {
+ struct fuse_create_in *in = (void *)(ih + 1);
+ const char *name = (char *)(in + 1);
+ struct entry *e = find(s, name);
+ struct {
+ struct fuse_entry_out entry;
+ struct fuse_open_out open;
+ } out = {0};
+
+ if (ih->opcode == FUSE_CREATE)
+ s->nr_create++;
+ else
+ s->nr_open_atomic++;
+ if (ih->opcode == FUSE_OPEN_ATOMIC && !s->atomic) {
+ reply(s->fd, ih->unique, -ENOSYS, NULL, 0);
+ break;
+ }
+ if (e && (in->flags & O_CREAT) &&
+ (in->flags & O_EXCL)) {
+ reply(s->fd, ih->unique, -EEXIST, NULL, 0);
+ break;
+ }
+ if (!e && !(in->flags & O_CREAT)) {
+ fill_entry(&out.entry, NULL);
+ reply(s->fd, ih->unique, 0, &out.entry,
+ sizeof(out.entry));
+ break;
+ }
+ if (e && (!S_ISREG(e->mode) || (in->flags & O_TRUNC))) {
+ fill_entry(&out.entry, e);
+ reply(s->fd, ih->unique, 0, &out.entry,
+ sizeof(out.entry));
+ break;
+ }
+ if (!e) {
+ e = add(s, name, S_IFREG | in->mode);
+ if (ih->opcode == FUSE_OPEN_ATOMIC)
+ out.open.open_flags = FOPEN_CREATED;
+ }
+ fill_entry(&out.entry, e);
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_TMPFILE: {
+ struct fuse_create_in *in = (void *)(ih + 1);
+ struct entry e = {
+ .nodeid = 100,
+ .mode = S_IFREG | in->mode,
+ .entry_valid = 3600,
+ };
+ struct {
+ struct fuse_entry_out entry;
+ struct fuse_open_out open;
+ } out = {0};
+
+ fill_entry(&out.entry, &e);
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_OPEN: {
+ struct fuse_open_out out = {0};
+
+ s->nr_open++;
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_FLUSH:
+ case FUSE_RELEASE:
+ reply(s->fd, ih->unique, 0, NULL, 0);
+ break;
+ case FUSE_FORGET:
+ case FUSE_BATCH_FORGET:
+ break;
+ default:
+ reply(s->fd, ih->unique, -EOPNOTSUPP, NULL, 0);
+ break;
+ }
+ }
+}
+
+FIXTURE(fuse)
+{
+ struct server *srv;
+ char dir[64];
+ int mounted;
+};
+
+FIXTURE_VARIANT(fuse)
+{
+ int atomic;
+};
+
+FIXTURE_VARIANT_ADD(fuse, atomic)
+{
+ .atomic = 1,
+};
+
+FIXTURE_VARIANT_ADD(fuse, plain)
+{
+ .atomic = 0,
+};
+
+FIXTURE_SETUP(fuse)
+{
+ char opts[128];
+ struct stat st;
+ pthread_t t;
+
+ if (geteuid() != 0)
+ SKIP(return, "need root to mount fuse");
+
+ 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->atomic = variant->atomic;
+ add(self->srv, "ro", S_IFREG | 0444);
+ add(self->srv, "rw", S_IFREG | 0644);
+ add(self->srv, "stale", S_IFREG | 0644)->entry_valid = 0;
+
+ self->srv->fd = open("/dev/fuse", O_RDWR);
+ ASSERT_GE(self->srv->fd, 0);
+
+ strcpy(self->dir, "/tmp/fuse_aopen_XXXXXX");
+ ASSERT_NE(NULL, mkdtemp(self->dir));
+
+ snprintf(opts, sizeof(opts),
+ "fd=%d,rootmode=40000,user_id=0,group_id=0,default_permissions,allow_other",
+ 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));
+ pthread_detach(t);
+
+ /* Wait for FUSE_INIT: the root GETATTR is queued behind it */
+ ASSERT_EQ(0, stat(self->dir, &st));
+}
+
+FIXTURE_TEARDOWN(fuse)
+{
+ 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);
+}
+
+/* Open @name as an unprivileged user and return the errno, or 0. */
+static int open_as_nobody(const char *dir, const char *name, int flags)
+{
+ char path[128];
+ int status, fd;
+ pid_t pid;
+
+ snprintf(path, sizeof(path), "%s/%s", dir, name);
+ pid = fork();
+ if (pid < 0)
+ return -1;
+ if (pid == 0) {
+ if (setgid(NOBODY) || setuid(NOBODY))
+ _exit(255);
+ fd = open(path, flags, 0644);
+ _exit(fd < 0 ? errno : 0);
+ }
+ if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status))
+ return -1;
+ return WEXITSTATUS(status);
+}
+
+static int open_at(const char *dir, const char *name, int flags)
+{
+ char path[128];
+
+ snprintf(path, sizeof(path), "%s/%s", dir, name);
+ return open(path, flags, 0644);
+}
+
+TEST_F(fuse, create_new)
+{
+ int fd;
+
+ fd = open_at(self->dir, "a", O_CREAT | O_WRONLY);
+ ASSERT_GE(fd, 0);
+ close(fd);
+
+ fd = open_at(self->dir, "b", O_CREAT | O_EXCL | O_WRONLY);
+ ASSERT_GE(fd, 0);
+ close(fd);
+
+ fd = open(self->dir, O_TMPFILE | O_RDWR, 0600);
+ ASSERT_GE(fd, 0);
+ close(fd);
+
+ EXPECT_EQ(0, self->srv->nr_open);
+ if (variant->atomic) {
+ EXPECT_EQ(2, self->srv->nr_open_atomic);
+ EXPECT_EQ(0, self->srv->nr_lookup);
+ EXPECT_EQ(0, self->srv->nr_create);
+ } else {
+ EXPECT_EQ(1, self->srv->nr_open_atomic);
+ EXPECT_EQ(2, self->srv->nr_lookup);
+ EXPECT_EQ(2, self->srv->nr_create);
+ }
+}
+
+TEST_F(fuse, create_existing)
+{
+ int fd;
+
+ ASSERT_EQ(-1, open_at(self->dir, "ro", O_CREAT | O_EXCL | O_WRONLY));
+ ASSERT_EQ(EEXIST, errno);
+ ASSERT_EQ(EACCES, open_as_nobody(self->dir, "ro", O_CREAT | O_RDWR));
+
+ /* Truncation goes through FUSE_OPEN and SETATTR. */
+ fd = open_at(self->dir, "rw", O_CREAT | O_TRUNC | O_WRONLY);
+ ASSERT_GE(fd, 0);
+ close(fd);
+
+ EXPECT_EQ(0, self->srv->nr_create);
+ EXPECT_EQ(1, self->srv->nr_open);
+ EXPECT_EQ(1, self->srv->nr_setattr);
+ if (variant->atomic) {
+ EXPECT_EQ(3, self->srv->nr_open_atomic);
+ EXPECT_EQ(0, self->srv->nr_lookup);
+ } else {
+ EXPECT_EQ(1, self->srv->nr_open_atomic);
+ EXPECT_EQ(2, self->srv->nr_lookup);
+ }
+}
+
+TEST_F(fuse, open_existing)
+{
+ int fd;
+
+ ASSERT_EQ(EACCES, open_as_nobody(self->dir, "ro", O_RDWR));
+
+ fd = open_at(self->dir, "ro", O_RDONLY);
+ ASSERT_GE(fd, 0);
+ close(fd);
+
+ ASSERT_EQ(-1, open_at(self->dir, "missing", O_RDONLY));
+ ASSERT_EQ(ENOENT, errno);
+ ASSERT_EQ(-1, open_at(self->dir, "missing", O_RDONLY));
+ ASSERT_EQ(ENOENT, errno);
+
+ EXPECT_EQ(1, self->srv->nr_open);
+ if (variant->atomic) {
+ EXPECT_EQ(2, self->srv->nr_open_atomic);
+ EXPECT_EQ(0, self->srv->nr_lookup);
+ } else {
+ EXPECT_EQ(1, self->srv->nr_open_atomic);
+ EXPECT_EQ(2, self->srv->nr_lookup);
+ }
+}
+
+TEST_F(fuse, open_stale)
+{
+ struct stat st;
+ int fd;
+
+ fd = open_at(self->dir, "stale", O_RDONLY);
+ ASSERT_GE(fd, 0);
+ close(fd);
+
+ /* The entry has expired, the reopen checks the name. */
+ fd = open_at(self->dir, "stale", O_RDONLY);
+ ASSERT_GE(fd, 0);
+ close(fd);
+
+ /* Another file takes the name: the open finds the new one. */
+ find(self->srv, "stale")->nodeid = 200;
+ fd = open_at(self->dir, "stale", O_RDONLY);
+ ASSERT_GE(fd, 0);
+ ASSERT_EQ(0, fstat(fd, &st));
+ EXPECT_EQ(200, st.st_ino);
+ close(fd);
+
+ if (variant->atomic) {
+ EXPECT_EQ(4, self->srv->nr_open_atomic);
+ EXPECT_EQ(0, self->srv->nr_lookup);
+ EXPECT_EQ(0, self->srv->nr_open);
+ } else {
+ EXPECT_EQ(1, self->srv->nr_open_atomic);
+ EXPECT_EQ(4, self->srv->nr_lookup);
+ EXPECT_EQ(3, self->srv->nr_open);
+ }
+}
+
+TEST_F(fuse, open_stale_first)
+{
+ char path[128];
+ struct stat st;
+ int fd;
+
+ /* The first FUSE_OPEN_ATOMIC comes from reopening a cached name */
+ snprintf(path, sizeof(path), "%s/stale", self->dir);
+ ASSERT_EQ(0, stat(path, &st));
+ fd = open(path, O_RDONLY);
+ ASSERT_GE(fd, 0);
+ close(fd);
+
+ EXPECT_EQ(1, self->srv->nr_open_atomic);
+ if (variant->atomic) {
+ EXPECT_EQ(1, self->srv->nr_lookup);
+ EXPECT_EQ(0, self->srv->nr_open);
+ } else {
+ EXPECT_EQ(2, self->srv->nr_lookup);
+ EXPECT_EQ(1, self->srv->nr_open);
+ }
+}
+
+TEST_HARNESS_MAIN
--
2.50.1
prev parent reply other threads:[~2026-10-20 14:18 UTC|newest]
Thread overview: 4+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-20 14:16 [PATCH 0/3] fuse: atomic open jamz
2026-10-20 14:16 ` [PATCH 1/3] fuse: add FUSE_OPEN_ATOMIC jamz
2026-10-20 14:16 ` [PATCH 2/3] fuse: open cached files with FUSE_OPEN_ATOMIC jamz
2026-10-20 14:16 ` jamz [this message]
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=20261020141647.3271341-4-jamz@amazon.com \
--to=jamz@amazon.com \
--cc=bernd@bsbernd.com \
--cc=fuse-devel@lists.linux.dev \
--cc=horst@birthelmer.de \
--cc=jiharris@nvidia.com \
--cc=linux-kselftest@vger.kernel.org \
--cc=luis@igalia.com \
--cc=miklos@szeredi.hu \
--cc=neil@brown.name \
--cc=shuah@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.