* [PATCH 1/2] fuse: skip LOOKUP before exclusive create
2026-10-20 15:34 [PATCH 0/2] fuse: skip LOOKUP before exclusive create Jimmy Zuber
@ 2026-10-20 15:34 ` Jimmy Zuber
2026-10-20 15:34 ` [PATCH 2/2] selftests/fuse: test FUSE_EXCL_CREATE Jimmy Zuber
1 sibling, 0 replies; 3+ messages in thread
From: Jimmy Zuber @ 2026-10-20 15:34 UTC (permalink / raw)
To: Miklos Szeredi
Cc: fuse-devel, linux-kselftest, Shuah Khan, Bernd Schubert,
Jim Harris, Horst Birthelmer, Luis Henriques
mkdir(), mknod(), symlink(), link() and renameat2() with RENAME_NOREPLACE
look the new name up with FUSE_LOOKUP before sending the request, which
costs a round trip to the server.
Add an INIT flag, FUSE_EXCL_CREATE, with which the server fails
FUSE_MKNOD, FUSE_MKDIR, FUSE_SYMLINK, FUSE_LINK, and FUSE_RENAME2 with
RENAME_NOREPLACE, with -EEXIST for an existing name. When the server
sets it, the lookup of a name that is about to be created returns
without sending FUSE_LOOKUP, and an existing name is reported by the
-EEXIST of the request. After a failed request the name stays
uncached, so the next lookup goes to the server.
A rename that may replace its target looks the target up, so that the
kernel can check the permission to replace it.
Assisted-by: Claude:claude-opus-5-5 [Claude-Code]
Signed-off-by: Jimmy Zuber <jamz@amazon.com>
---
fs/fuse/dir.c | 10 ++++++++++
fs/fuse/fuse_i.h | 3 +++
fs/fuse/inode.c | 4 +++-
include/uapi/linux/fuse.h | 10 +++++++++-
4 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/fs/fuse/dir.c b/fs/fuse/dir.c
index f48fafccce4b..e491e6b3215a 100644
--- a/fs/fuse/dir.c
+++ b/fs/fuse/dir.c
@@ -627,6 +627,16 @@ static struct dentry *fuse_lookup(struct inode *dir, struct dentry *entry,
return ERR_PTR(-EIO);
fc = get_fuse_conn_super(dir->i_sb);
+
+ /* The request fails with -EEXIST if the name exists */
+ if (fc->excl_create &&
+ (flags & (LOOKUP_CREATE | LOOKUP_EXCL)) ==
+ (LOOKUP_CREATE | LOOKUP_EXCL)) {
+ if (entry->d_name.len > fc->name_max)
+ return ERR_PTR(-ENAMETOOLONG);
+ return NULL;
+ }
+
epoch = atomic_read(&fc->epoch);
locked = fuse_lock_inode(dir);
diff --git a/fs/fuse/fuse_i.h b/fs/fuse/fuse_i.h
index 8546855386b5..babbacdfb757 100644
--- a/fs/fuse/fuse_i.h
+++ b/fs/fuse/fuse_i.h
@@ -509,6 +509,9 @@ struct fuse_conn {
*/
unsigned atomic_o_trunc:1;
+ /** @excl_create: Create requests check for an existing name */
+ unsigned excl_create:1;
+
/**
* @export_support: Filesystem supports NFS exporting.
* Only set in INIT
diff --git a/fs/fuse/inode.c b/fs/fuse/inode.c
index cbb10e19e7e8..1f1be0c521a1 100644
--- a/fs/fuse/inode.c
+++ b/fs/fuse/inode.c
@@ -1432,6 +1432,8 @@ static void process_init_reply(struct fuse_args *args, int error)
if (fuse_syncfs_enable(fc, flags))
fc->sync_fs = 1;
+ if (flags & FUSE_EXCL_CREATE)
+ fc->excl_create = 1;
} else {
ra_pages = fc->max_read / PAGE_SIZE;
fc->no_lock = 1;
@@ -1490,7 +1492,7 @@ static struct fuse_init_args *fuse_new_init(struct fuse_mount *fm)
FUSE_SECURITY_CTX | FUSE_CREATE_SUPP_GROUP |
FUSE_HAS_EXPIRE_ONLY | FUSE_DIRECT_IO_ALLOW_MMAP |
FUSE_NO_EXPORT_SUPPORT | FUSE_HAS_RESEND | FUSE_ALLOW_IDMAP |
- FUSE_REQUEST_TIMEOUT;
+ FUSE_REQUEST_TIMEOUT | FUSE_EXCL_CREATE;
#ifdef CONFIG_FUSE_VDAX
if (fm->fc->vdax)
flags |= FUSE_MAP_ALIGNMENT;
diff --git a/include/uapi/linux/fuse.h b/include/uapi/linux/fuse.h
index 10a7f31c4bdf..8c079d3080bf 100644
--- a/include/uapi/linux/fuse.h
+++ b/include/uapi/linux/fuse.h
@@ -251,6 +251,9 @@
*
* 7.47
* - add FUSE_HAS_SYNCFS opt-in flag for privileged userspace servers
+ *
+ * 7.48
+ * - add FUSE_EXCL_CREATE
*/
#ifndef _LINUX_FUSE_H
@@ -286,7 +289,7 @@
#define FUSE_KERNEL_VERSION 7
/** Minor version number of this interface */
-#define FUSE_KERNEL_MINOR_VERSION 47
+#define FUSE_KERNEL_MINOR_VERSION 48
/** The node ID of the root inode */
#define FUSE_ROOT_ID 1
@@ -473,6 +476,10 @@ struct fuse_file_lock {
* with CAP_SYS_ADMIN in the initial user namespace (the same
* privilege that mounting virtiofs or fuseblk requires).
* Insufficiently privileged servers ignore it.
+ * FUSE_EXCL_CREATE: FUSE_MKNOD, FUSE_MKDIR, FUSE_SYMLINK, FUSE_LINK and
+ * FUSE_RENAME2 with RENAME_NOREPLACE fail with -EEXIST for an
+ * existing name, so the kernel sends them without a LOOKUP of
+ * the name
*/
#define FUSE_ASYNC_READ (1 << 0)
#define FUSE_POSIX_LOCKS (1 << 1)
@@ -522,6 +529,7 @@ struct fuse_file_lock {
#define FUSE_REQUEST_TIMEOUT (1ULL << 42)
#define FUSE_HAS_IO_URING_BUFPOOL (1ULL << 43)
#define FUSE_HAS_SYNCFS (1ULL << 44)
+#define FUSE_EXCL_CREATE (1ULL << 45)
/**
* CUSE INIT request/reply flags
--
2.50.1
^ permalink raw reply related [flat|nested] 3+ messages in thread* [PATCH 2/2] selftests/fuse: test FUSE_EXCL_CREATE
2026-10-20 15:34 [PATCH 0/2] fuse: skip LOOKUP before exclusive create Jimmy Zuber
2026-10-20 15:34 ` [PATCH 1/2] " Jimmy Zuber
@ 2026-10-20 15:34 ` Jimmy Zuber
1 sibling, 0 replies; 3+ messages in thread
From: Jimmy Zuber @ 2026-10-20 15:34 UTC (permalink / raw)
To: Miklos Szeredi
Cc: fuse-devel, linux-kselftest, Shuah Khan, Bernd Schubert,
Jim Harris, Horst Birthelmer, Luis Henriques
Check that mkdir(), mknod(), symlink(), link() and renameat2() with
RENAME_NOREPLACE of new names send no FUSE_LOOKUP when the server sets
FUSE_EXCL_CREATE, and that creating an existing name fails with EEXIST.
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/excl_create_test.c | 339 ++++++++++++++++++
3 files changed, 342 insertions(+), 1 deletion(-)
create mode 100644 tools/testing/selftests/filesystems/fuse/excl_create_test.c
diff --git a/tools/testing/selftests/filesystems/fuse/.gitignore b/tools/testing/selftests/filesystems/fuse/.gitignore
index b5b03db1118c..325874e7659d 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
+excl_create_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..9ddb60d36405 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 excl_create_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)/excl_create_test: LDLIBS += -lpthread
VAR_CFLAGS := $(shell pkg-config fuse --cflags 2>/dev/null)
ifeq ($(VAR_CFLAGS),)
diff --git a/tools/testing/selftests/filesystems/fuse/excl_create_test.c b/tools/testing/selftests/filesystems/fuse/excl_create_test.c
new file mode 100644
index 000000000000..36b675554256
--- /dev/null
+++ b/tools/testing/selftests/filesystems/fuse/excl_create_test.c
@@ -0,0 +1,339 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Test mkdir(), mknod(), symlink(), link() and renameat2() with
+ * RENAME_NOREPLACE, with and without FUSE_EXCL_CREATE.
+ *
+ * Requires root to mount fuse.
+ */
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <pthread.h>
+#include <stdint.h>
+#include <stdio.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 MAX_WRITE (128 * 1024)
+#define MAX_ENTRIES 16
+
+struct entry {
+ char name[64];
+ uint64_t nodeid;
+ uint32_t mode;
+};
+
+/* Server state, shared with the responder thread. */
+struct server {
+ int fd;
+ int excl;
+ struct entry ent[MAX_ENTRIES];
+ int nent;
+ int nr_lookup, nr_mkobj, nr_rename;
+};
+
+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;
+ 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 = 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;
+ char *in = (char *)(ih + 1);
+
+ 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 *init = (void *)in;
+ struct fuse_init_out out = {0};
+
+ out.major = FUSE_KERNEL_VERSION;
+ out.minor = FUSE_KERNEL_MINOR_VERSION;
+ out.max_readahead = init->max_readahead;
+ out.max_write = MAX_WRITE;
+ out.max_background = 16;
+ out.congestion_threshold = 12;
+ out.flags = FUSE_INIT_EXT;
+ if (s->excl)
+ out.flags2 = FUSE_EXCL_CREATE >> 32;
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_GETATTR: {
+ struct fuse_attr_out out = {0};
+ struct fuse_entry_out eo;
+ int i;
+
+ 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, in));
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_MKNOD:
+ case FUSE_MKDIR:
+ case FUSE_SYMLINK:
+ case FUSE_LINK: {
+ struct fuse_entry_out out;
+ const char *name;
+ uint32_t mode;
+
+ s->nr_mkobj++;
+ if (ih->opcode == FUSE_MKNOD) {
+ struct fuse_mknod_in *mi = (void *)in;
+
+ name = (char *)(mi + 1);
+ mode = mi->mode;
+ } else if (ih->opcode == FUSE_MKDIR) {
+ struct fuse_mkdir_in *mi = (void *)in;
+
+ name = (char *)(mi + 1);
+ mode = S_IFDIR | mi->mode;
+ } else if (ih->opcode == FUSE_SYMLINK) {
+ name = in;
+ mode = S_IFLNK | 0777;
+ } else {
+ name = (char *)((struct fuse_link_in *)in + 1);
+ mode = S_IFREG | 0644;
+ }
+ if (find(s, name)) {
+ reply(s->fd, ih->unique, -EEXIST, NULL, 0);
+ break;
+ }
+ fill_entry(&out, add(s, name, mode));
+ if (ih->opcode == FUSE_LINK)
+ out.attr.nlink = 2;
+ reply(s->fd, ih->unique, 0, &out, sizeof(out));
+ break;
+ }
+ case FUSE_RENAME2: {
+ struct fuse_rename2_in *ri = (void *)in;
+ const char *oldname = (char *)(ri + 1);
+ const char *newname = oldname + strlen(oldname) + 1;
+ struct entry *e = find(s, oldname);
+
+ s->nr_rename++;
+ if (!e || ri->flags != RENAME_NOREPLACE) {
+ reply(s->fd, ih->unique, -EINVAL, NULL, 0);
+ break;
+ }
+ if (find(s, newname)) {
+ reply(s->fd, ih->unique, -EEXIST, NULL, 0);
+ break;
+ }
+ snprintf(e->name, sizeof(e->name), "%.63s", newname);
+ 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 excl;
+};
+
+FIXTURE_VARIANT_ADD(fuse, excl)
+{
+ .excl = 1,
+};
+
+FIXTURE_VARIANT_ADD(fuse, plain)
+{
+ .excl = 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->excl = variant->excl;
+ add(self->srv, "src", S_IFREG | 0644);
+ add(self->srv, "taken", S_IFREG | 0644);
+
+ self->srv->fd = open("/dev/fuse", O_RDWR);
+ ASSERT_GE(self->srv->fd, 0);
+
+ strcpy(self->dir, "/tmp/fuse_excl_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));
+ 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);
+}
+
+TEST_F(fuse, create_new)
+{
+ char path[128], src[128];
+
+ snprintf(path, sizeof(path), "%s/d", self->dir);
+ ASSERT_EQ(0, mkdir(path, 0755));
+ snprintf(path, sizeof(path), "%s/f", self->dir);
+ ASSERT_EQ(0, mkfifo(path, 0644));
+ snprintf(path, sizeof(path), "%s/l", self->dir);
+ ASSERT_EQ(0, symlink("src", path));
+ snprintf(src, sizeof(src), "%s/src", self->dir);
+ snprintf(path, sizeof(path), "%s/h", self->dir);
+ ASSERT_EQ(0, link(src, path));
+ snprintf(path, sizeof(path), "%s/r", self->dir);
+ ASSERT_EQ(0, renameat2(AT_FDCWD, src, AT_FDCWD, path,
+ RENAME_NOREPLACE));
+
+ EXPECT_EQ(4, self->srv->nr_mkobj);
+ EXPECT_EQ(1, self->srv->nr_rename);
+ /* Only "src" is looked up with FUSE_EXCL_CREATE */
+ EXPECT_EQ(variant->excl ? 1 : 6, self->srv->nr_lookup);
+}
+
+TEST_F(fuse, create_existing)
+{
+ char path[128], src[128];
+
+ snprintf(path, sizeof(path), "%s/taken", self->dir);
+ ASSERT_EQ(-1, mkdir(path, 0755));
+ ASSERT_EQ(EEXIST, errno);
+ ASSERT_EQ(-1, symlink("src", path));
+ ASSERT_EQ(EEXIST, errno);
+ snprintf(src, sizeof(src), "%s/src", self->dir);
+ ASSERT_EQ(-1, renameat2(AT_FDCWD, src, AT_FDCWD, path,
+ RENAME_NOREPLACE));
+ ASSERT_EQ(EEXIST, errno);
+
+ if (variant->excl) {
+ /* Only "src" is looked up */
+ EXPECT_EQ(2, self->srv->nr_mkobj);
+ EXPECT_EQ(1, self->srv->nr_rename);
+ EXPECT_EQ(1, self->srv->nr_lookup);
+ } else {
+ /* The cached name is looked up again for each request */
+ EXPECT_EQ(0, self->srv->nr_mkobj);
+ EXPECT_EQ(0, self->srv->nr_rename);
+ EXPECT_EQ(4, self->srv->nr_lookup);
+ }
+}
+
+TEST_HARNESS_MAIN
--
2.50.1
^ permalink raw reply related [flat|nested] 3+ messages in thread