Linux filesystem development
 help / color / mirror / Atom feed
From: Luis Henriques <luis@igalia.com>
To: Amir Goldstein <amir73il@gmail.com>
Cc: Christian Brauner <brauner@kernel.org>,
	 Miklos Szeredi <miklos@szeredi.hu>,
	 linux-fsdevel@vger.kernel.org, fuse-devel@lists.linux.dev
Subject: Re: [PATCH 2/2] selftests/fuse: add ACL_DONT_CACHE regression test
Date: Wed, 15 Jul 2026 17:01:26 +0100	[thread overview]
Message-ID: <87wluw2qeh.fsf@wotan.olymp> (raw)
In-Reply-To: <20260713220932.413004-3-amir73il@gmail.com> (Amir Goldstein's message of "Tue, 14 Jul 2026 00:09:32 +0200")

Hi Amir,

On Tue, Jul 14 2026, Amir Goldstein wrote:

> Add a test that reproduces the stale ACL bug fixed by:
>   "fs: preserve ACL_DONT_CACHE state in forget_cached_acl()"
>
> A FUSE mount that does not negotiate FUSE_POSIX_ACL initialises inodes
> with i_acl = ACL_DONT_CACHE.  Before the fix, calling
> forget_all_cached_acls() (e.g. from fuse_update_get_attr() on a
> statx(AT_STATX_FORCE_SYNC)) would silently replace ACL_DONT_CACHE with
> ACL_NOT_CACHED, enabling the kernel ACL cache.  A subsequent getxattr
> would populate the cache, and because fuse_set_acl() skips
> forget_all_cached_acls() for !fc->posix_acl, later ACL changes were
> not visible to callers — getxattr returned stale data.
>
> The test mounts a minimal libfuse3 lowlevel filesystem (no
> FUSE_POSIX_ACL negotiated) and:
>   1. Issues two getxattrs — both must reach the daemon, proving
>      ACL_DONT_CACHE suppresses caching before any trigger.
>   2. Calls statx(AT_STATX_FORCE_SYNC) to trigger forget_all_cached_acls().
>   3. Issues another getxattr (populates the cache on a buggy kernel).
>   4. Switches the daemon to a different-sized ACL (ACL_B).
>   5. Issues a final getxattr — expects ACL_B (44 bytes) and daemon
>      call count 4; a buggy kernel returns stale ACL_A (28 bytes).

A question: in my own tests I have been following a different approach,
where the filesystem is mounted from a shell script that does the actual
testing.  I wonder if you think your approach (i.e. writing the actual
test in C, using the kselftest macros) is the preferred one.

Anyway, the test looks good to me, and I just have a few minor comments
below.

> fuse_acl_cache_test is only built when libfuse3 is detected via
> pkg-config.

Yeah, I've sent a patch recently converting the only existing test to also
user fuse3 instead.  Not sure that's acceptable though, since there's a
chance of breaking some CI.

> Signed-off-by: Amir Goldstein <amir73il@gmail.com>
> ---
>  .../selftests/filesystems/fuse/Makefile       |  10 +
>  .../filesystems/fuse/fuse_acl_cache_test.c    | 348 ++++++++++++++++++
>  2 files changed, 358 insertions(+)
>  create mode 100644 tools/testing/selftests/filesystems/fuse/fuse_acl_cache_test.c
>
> diff --git a/tools/testing/selftests/filesystems/fuse/Makefile b/tools/testing/selftests/filesystems/fuse/Makefile
> index 612aad69a93aa..f471414842750 100644
> --- a/tools/testing/selftests/filesystems/fuse/Makefile
> +++ b/tools/testing/selftests/filesystems/fuse/Makefile
> @@ -5,6 +5,13 @@ CFLAGS += -Wall -O2 -g $(KHDR_INCLUDES)
>  TEST_GEN_PROGS := fusectl_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
>  
>  VAR_CFLAGS := $(shell pkg-config fuse --cflags 2>/dev/null)
> @@ -19,3 +26,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 0000000000000..b2e5db8555040
> --- /dev/null
> +++ b/tools/testing/selftests/filesystems/fuse/fuse_acl_cache_test.c
> @@ -0,0 +1,348 @@
> +// 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/syscall.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  */
> +};

Wouldn't it be OK to add a dependency on libacl to make this more
readable?

> 
> +
> +/*
> + * 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 syscall(SYS_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);

Why not calling statx(2) instead?  Are you using a libc that doesn't
include a wrapper?

(Also, I wonder if having the global state protected with a mutex is
really needed in the scope of this test... but yeah, that's the correct
thing to do, of course.)

Cheers,
-- 
Luís

> 
> +	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
> -- 
> 2.54.0
>


  reply	other threads:[~2026-07-15 16:01 UTC|newest]

Thread overview: 9+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-13 22:09 [PATCH 0/2] Fix for unintended FUSE ACL cache Amir Goldstein
2026-07-13 22:09 ` [PATCH 1/2] fs: preserve ACL_DONT_CACHE state in forget_cached_acl() Amir Goldstein
2026-07-15  9:02   ` Christian Brauner
2026-07-15  9:40     ` Amir Goldstein
2026-07-15 15:43   ` Luis Henriques
2026-07-13 22:09 ` [PATCH 2/2] selftests/fuse: add ACL_DONT_CACHE regression test Amir Goldstein
2026-07-15 16:01   ` Luis Henriques [this message]
2026-07-15 17:38     ` Amir Goldstein
2026-07-15 20:42       ` Luis Henriques

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=87wluw2qeh.fsf@wotan.olymp \
    --to=luis@igalia.com \
    --cc=amir73il@gmail.com \
    --cc=brauner@kernel.org \
    --cc=fuse-devel@lists.linux.dev \
    --cc=linux-fsdevel@vger.kernel.org \
    --cc=miklos@szeredi.hu \
    /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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox