From: "Mickaël Salaün" <mic@digikod.net>
To: "Günther Noack" <gnoack@google.com>,
"Steven Rostedt" <rostedt@goodmis.org>
Cc: "Mickaël Salaün" <mic@digikod.net>,
"Christian Brauner" <brauner@kernel.org>,
"Jann Horn" <jannh@google.com>, "Jeff Xu" <jeffxu@google.com>,
"Justin Suess" <utilityemal77@gmail.com>,
"Kees Cook" <kees@kernel.org>,
"Masami Hiramatsu" <mhiramat@kernel.org>,
"Mathieu Desnoyers" <mathieu.desnoyers@efficios.com>,
"Matthieu Buffet" <matthieu@buffet.re>,
"Mikhail Ivanov" <ivanov.mikhail1@huawei-partners.com>,
"Tingmao Wang" <m@maowtm.org>,
kernel-team@cloudflare.com,
linux-security-module@vger.kernel.org,
linux-trace-kernel@vger.kernel.org
Subject: [PATCH v4 15/19] selftests/landlock: Add filesystem tracepoint tests
Date: Tue, 11 Aug 2026 11:43:29 +0200 [thread overview]
Message-ID: <20260811094338.288094-16-mic@digikod.net> (raw)
In-Reply-To: <20260811094338.288094-1-mic@digikod.net>
Add filesystem-specific trace tests in a dedicated file, following the
audit-test pattern of living alongside each subsystem's functional
tests.
trace_fs_test.c verifies that the add_rule_fs, check_rule_fs, and
deny_access_fs events fire with the correct fields on matching rules and
denied accesses, that check_rule_fs does not fire for unhandled access
types, and that no event fires without a sandbox. A denial covered by a
quiet rule still emits a deny_access_fs event but with logged=0, the
same suppression verdict audit applies; because that verdict must not
depend on CONFIG_AUDIT, the test also runs under a tracepoints-only
build.
Add trace_layout1 fixture tests in fs_test.c that reuse the layout1
hierarchy to verify the per-layer grants field: field values, multi-rule
pathwalk short-circuit, request intersection, the optional truncate
right surfacing in the request and grants, and an empty grants set from
a rule that grants none of the requested rights.
Cc: Günther Noack <gnoack@google.com>
Cc: Tingmao Wang <m@maowtm.org>
Signed-off-by: Mickaël Salaün <mic@digikod.net>
---
Changes since v2:
https://patch.msgid.link/20260406143717.1815792-15-mic@digikod.net
- Assert the symbolic access-right names (e.g. read_dir) in the FS
trace grants field instead of hex masks, including the optional
truncate right and an empty grants set from a matching rule that
grants nothing requested.
- Add deny_access_fs_quiet, asserting a quieted filesystem denial emits
landlock_deny_access_fs with logged=0, and that quiet suppresses only
the logged verdict by anchoring the surviving domain and blockers
fields.
- Follow the check_rule_fs printk label rename request= to
access_request= (the trace-only field and its label now share one
name): parse the access_request field in the FS check_rule
assertions (the REGEX_CHECK_RULE_FS matcher carries the renamed
label from its first use in the trace lifecycle test).
Changes since v1:
- New patch.
---
tools/testing/selftests/landlock/fs_test.c | 483 +++++++++++++++++
.../selftests/landlock/trace_fs_test.c | 496 ++++++++++++++++++
2 files changed, 979 insertions(+)
create mode 100644 tools/testing/selftests/landlock/trace_fs_test.c
diff --git a/tools/testing/selftests/landlock/fs_test.c b/tools/testing/selftests/landlock/fs_test.c
index c419049a240c..73a39bd13b4f 100644
--- a/tools/testing/selftests/landlock/fs_test.c
+++ b/tools/testing/selftests/landlock/fs_test.c
@@ -44,6 +44,9 @@
#include "audit.h"
#include "common.h"
+#include "trace.h"
+
+#define TRACE_TASK "fs_test"
#ifndef renameat2
int renameat2(int olddirfd, const char *oldpath, int newdirfd,
@@ -10506,4 +10509,484 @@ TEST_F(audit_quiet_rename, quiet_behind_mountpoint_disconnected)
ASSERT_EQ(0, records.access);
}
+/* clang-format off */
+FIXTURE(trace_layout1) {
+ /* clang-format on */
+ int tracefs_ok;
+};
+
+FIXTURE_SETUP(trace_layout1)
+{
+ struct stat st;
+
+ /*
+ * Check tracefs availability before creating the layout, following the
+ * layout3_fs pattern: skip before any layout creation to avoid leaving
+ * stale TMP_DIR on skip.
+ */
+ if (stat(TRACEFS_LANDLOCK_DIR, &st)) {
+ self->tracefs_ok = 0;
+ SKIP(return, "tracefs not available");
+ }
+ self->tracefs_ok = 1;
+
+ /* Isolate tracefs state (PID filter, event enables). */
+ set_cap(_metadata, CAP_SYS_ADMIN);
+ ASSERT_EQ(0, unshare(CLONE_NEWNS));
+ ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL));
+ clear_cap(_metadata, CAP_SYS_ADMIN);
+
+ prepare_layout(_metadata);
+ create_layout1(_metadata);
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_EQ(0, tracefs_fixture_setup());
+ ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, true));
+ ASSERT_EQ(0, tracefs_clear());
+ ASSERT_EQ(0, tracefs_set_pid_filter(getpid()));
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+}
+
+FIXTURE_TEARDOWN_PARENT(trace_layout1)
+{
+ if (!self->tracefs_ok)
+ return;
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, false);
+ tracefs_clear_pid_filter();
+ tracefs_fixture_teardown();
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+
+ remove_layout1(_metadata);
+ cleanup_layout(_metadata);
+}
+
+/*
+ * Verifies that check_rule_fs events include correct field values: domain, dev,
+ * ino, access_request, and grants. All values are verified against stat() of
+ * the rule path on a deterministic tmpfs layout.
+ */
+TEST_F(trace_layout1, check_rule_fs_fields)
+{
+ struct stat dir_stat;
+ char expected_dev[32];
+ char expected_ino[32];
+ char *buf;
+ char field[64];
+
+ if (!self->tracefs_ok)
+ SKIP(return, "tracefs not available");
+
+ ASSERT_EQ(0, stat(dir_s1d1, &dir_stat));
+ snprintf(expected_dev, sizeof(expected_dev), "%u:%u",
+ major(dir_stat.st_dev), minor(dir_stat.st_dev));
+ snprintf(expected_ino, sizeof(expected_ino), "%lu", dir_stat.st_ino);
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_EQ(0, tracefs_clear());
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+
+ sandbox_child_fs_access(_metadata, dir_s1d1,
+ LANDLOCK_ACCESS_FS_READ_DIR,
+ LANDLOCK_ACCESS_FS_READ_DIR, dir_s1d1);
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ buf = tracefs_read_trace();
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_NE(NULL, buf);
+
+ EXPECT_EQ(1,
+ tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK)))
+ {
+ TH_LOG("Expected 1 check_rule_fs event\n%s", buf);
+ }
+
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
+ "dev", field, sizeof(field)));
+ EXPECT_STREQ(expected_dev, field)
+ {
+ TH_LOG("Expected dev=%s, got %s", expected_dev, field);
+ }
+
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
+ "ino", field, sizeof(field)));
+ EXPECT_STREQ(expected_ino, field)
+ {
+ TH_LOG("Expected ino=%s, got %s", expected_ino, field);
+ }
+
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
+ "access_request", field,
+ sizeof(field)));
+ EXPECT_STREQ("read_dir", field)
+ {
+ TH_LOG("Expected access_request=read_dir, got %s", field);
+ }
+
+ /*
+ * The domain handles only READ_DIR, so the rule carries the
+ * unhandled-rights padding; intersecting with the request leaves just
+ * the requested read_dir (no padding, no hex).
+ */
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
+ "grants", field, sizeof(field)));
+ EXPECT_STREQ("{read_dir}", field)
+ {
+ TH_LOG("Expected grants={read_dir}, got %s", field);
+ }
+
+ free(buf);
+}
+
+/*
+ * Verifies check_rule_fs behavior with multiple rules. With rules at s1d1 and
+ * s1d2 (a child of s1d1), accessing s1d2 produces only 1 event because the
+ * pathwalk short-circuits after the first rule fully unmasks the single layer.
+ */
+TEST_F(trace_layout1, check_rule_fs_multiple_rules)
+{
+ pid_t pid;
+ int status;
+ char *buf;
+ int count;
+
+ if (!self->tracefs_ok)
+ SKIP(return, "tracefs not available");
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_EQ(0, tracefs_clear());
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+
+ pid = fork();
+ ASSERT_LE(0, pid);
+
+ if (pid == 0) {
+ struct landlock_ruleset_attr attr = {
+ .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR,
+ };
+ struct landlock_path_beneath_attr path_beneath = {
+ .allowed_access = LANDLOCK_ACCESS_FS_READ_DIR,
+ };
+ int ruleset_fd, fd;
+
+ ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0);
+ if (ruleset_fd < 0)
+ _exit(1);
+
+ path_beneath.parent_fd =
+ open(dir_s1d1, O_PATH | O_DIRECTORY | O_CLOEXEC);
+ if (path_beneath.parent_fd < 0)
+ _exit(1);
+ if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
+ &path_beneath, 0))
+ _exit(1);
+ close(path_beneath.parent_fd);
+
+ path_beneath.parent_fd =
+ open(dir_s1d2, O_PATH | O_DIRECTORY | O_CLOEXEC);
+ if (path_beneath.parent_fd < 0)
+ _exit(1);
+ if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
+ &path_beneath, 0))
+ _exit(1);
+ close(path_beneath.parent_fd);
+
+ prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ if (landlock_restrict_self(ruleset_fd, 0))
+ _exit(1);
+ close(ruleset_fd);
+
+ fd = open(dir_s1d2, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (fd >= 0)
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(pid, waitpid(pid, &status, 0));
+ ASSERT_TRUE(WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status));
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ buf = tracefs_read_trace();
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_NE(NULL, buf);
+
+ /*
+ * Only 1 check_rule_fs event: the rule on dir_s1d2 fully unmasked the
+ * single layer, so the pathwalk short-circuits before reaching the
+ * dir_s1d1 rule.
+ */
+ count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
+ EXPECT_EQ(1, count)
+ {
+ TH_LOG("Expected 1 check_rule_fs event, got %d\n%s", count,
+ buf);
+ }
+
+ free(buf);
+}
+
+/*
+ * Verifies the grants array is intersected with the request: a handled,
+ * granted, but unrequested right (execute) is filtered out, leaving only the
+ * requested read_dir.
+ */
+TEST_F(trace_layout1, check_rule_fs_request_subset)
+{
+ char *buf;
+ char field[64];
+
+ if (!self->tracefs_ok)
+ SKIP(return, "tracefs not available");
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_EQ(0, tracefs_clear());
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+
+ /*
+ * Handle and grant READ_DIR|EXECUTE; the open only requests read_dir.
+ */
+ sandbox_child_fs_access(
+ _metadata, dir_s1d1,
+ LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE,
+ LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE,
+ dir_s1d1);
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ buf = tracefs_read_trace();
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_NE(NULL, buf);
+
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
+ "access_request", field,
+ sizeof(field)));
+ EXPECT_STREQ("read_dir", field);
+
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
+ "grants", field, sizeof(field)));
+ EXPECT_STREQ("{read_dir}", field);
+
+ free(buf);
+}
+
+/*
+ * Verifies that the optional TRUNCATE access right, which hook_file_open()
+ * speculatively evaluates on every open, appears in the access_request= and
+ * grants= fields. Opening file1_s1d1 read-only needs only read_file, but the
+ * open hook also evaluates truncate; the domain handles and the rule grants
+ * both, so the event reports access_request=read_file|truncate and
+ * grants={read_file|truncate}, and the open is allowed.
+ */
+TEST_F(trace_layout1, check_rule_fs_optional_access)
+{
+ pid_t pid;
+ int status;
+ char *buf;
+ char field[64];
+ int count;
+
+ if (!self->tracefs_ok)
+ SKIP(return, "tracefs not available");
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_EQ(0, tracefs_clear());
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+
+ pid = fork();
+ ASSERT_LE(0, pid);
+
+ if (pid == 0) {
+ struct landlock_ruleset_attr attr = {
+ .handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE |
+ LANDLOCK_ACCESS_FS_TRUNCATE,
+ };
+ struct landlock_path_beneath_attr path_beneath = {
+ .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE |
+ LANDLOCK_ACCESS_FS_TRUNCATE,
+ };
+ int ruleset_fd, fd;
+
+ ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0);
+ if (ruleset_fd < 0)
+ _exit(1);
+
+ path_beneath.parent_fd =
+ open(dir_s1d1, O_PATH | O_DIRECTORY | O_CLOEXEC);
+ if (path_beneath.parent_fd < 0)
+ _exit(1);
+ if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
+ &path_beneath, 0))
+ _exit(1);
+ close(path_beneath.parent_fd);
+
+ prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ if (landlock_restrict_self(ruleset_fd, 0))
+ _exit(1);
+ close(ruleset_fd);
+
+ /* Read-only open needs only read_file; truncate is optional. */
+ fd = open(file1_s1d1, O_RDONLY | O_CLOEXEC);
+ if (fd < 0)
+ _exit(1);
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(pid, waitpid(pid, &status, 0));
+ ASSERT_TRUE(WIFEXITED(status));
+ /* The open is allowed: the required read_file is granted. */
+ EXPECT_EQ(0, WEXITSTATUS(status));
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ buf = tracefs_read_trace();
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_NE(NULL, buf);
+
+ /* The rule at dir_s1d1 matches when opening file1_s1d1. */
+ count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
+ EXPECT_EQ(1, count)
+ {
+ TH_LOG("Expected 1 check_rule_fs event, got %d\n%s", count,
+ buf);
+ }
+
+ /* The open hook adds the optional truncate to the request. */
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
+ "access_request", field,
+ sizeof(field)));
+ EXPECT_STREQ("read_file|truncate", field);
+
+ /* The rule grants both, so truncate appears in the grants array. */
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
+ "grants", field, sizeof(field)));
+ EXPECT_STREQ("{read_file|truncate}", field);
+
+ free(buf);
+}
+
+/*
+ * Verifies that check_rule_fs fires for a rule that matches the inode even when
+ * it grants none of the requested rights, so the grants set is empty. Landlock
+ * cannot know a rule ignores the request before reading it, so the event is
+ * still emitted (grants={}), which lets a tracer see that the rule matched.
+ * The domain handles READ_DIR|EXECUTE, dir_s1d2 grants only EXECUTE and its
+ * parent dir_s1d1 grants only READ_DIR. Reading dir_s1d2 (requesting read_dir)
+ * first matches the dir_s1d2 rule, which grants nothing requested (grants={});
+ * walking up to dir_s1d1 then grants read_dir (grants={read_dir}) and allows
+ * the access.
+ */
+TEST_F(trace_layout1, check_rule_fs_empty_grant)
+{
+ pid_t pid;
+ int status;
+ char *buf;
+ int count;
+
+ if (!self->tracefs_ok)
+ SKIP(return, "tracefs not available");
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_EQ(0, tracefs_clear());
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+
+ pid = fork();
+ ASSERT_LE(0, pid);
+
+ if (pid == 0) {
+ struct landlock_ruleset_attr attr = {
+ .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR |
+ LANDLOCK_ACCESS_FS_EXECUTE,
+ };
+ struct landlock_path_beneath_attr path_beneath = {};
+ int ruleset_fd, fd;
+
+ ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0);
+ if (ruleset_fd < 0)
+ _exit(1);
+
+ /* Parent dir_s1d1 grants only READ_DIR. */
+ path_beneath.allowed_access = LANDLOCK_ACCESS_FS_READ_DIR;
+ path_beneath.parent_fd =
+ open(dir_s1d1, O_PATH | O_DIRECTORY | O_CLOEXEC);
+ if (path_beneath.parent_fd < 0)
+ _exit(1);
+ if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
+ &path_beneath, 0))
+ _exit(1);
+ close(path_beneath.parent_fd);
+
+ /* Child dir_s1d2 grants only EXECUTE. */
+ path_beneath.allowed_access = LANDLOCK_ACCESS_FS_EXECUTE;
+ path_beneath.parent_fd =
+ open(dir_s1d2, O_PATH | O_DIRECTORY | O_CLOEXEC);
+ if (path_beneath.parent_fd < 0)
+ _exit(1);
+ if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
+ &path_beneath, 0))
+ _exit(1);
+ close(path_beneath.parent_fd);
+
+ prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ if (landlock_restrict_self(ruleset_fd, 0))
+ _exit(1);
+ close(ruleset_fd);
+
+ fd = open(dir_s1d2, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (fd < 0)
+ _exit(1);
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(pid, waitpid(pid, &status, 0));
+ ASSERT_TRUE(WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status));
+
+ set_cap(_metadata, CAP_DAC_OVERRIDE);
+ buf = tracefs_read_trace();
+ clear_cap(_metadata, CAP_DAC_OVERRIDE);
+ ASSERT_NE(NULL, buf);
+
+ /*
+ * dir_s1d2 (grants nothing requested) then dir_s1d1 (grants read_dir).
+ */
+ count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
+ EXPECT_EQ(2, count)
+ {
+ TH_LOG("Expected 2 check_rule_fs events, got %d\n%s", count,
+ buf);
+ }
+
+ /* The dir_s1d2 rule matches the inode but grants none of read_dir. */
+ EXPECT_EQ(
+ 1,
+ tracefs_count_matches(
+ buf,
+ TRACE_PREFIX(
+ TRACE_TASK) "landlock_check_rule_fs: domain=[0-9a-f]\\+ "
+ "access_request=read_dir "
+ "dev=[0-9]\\+:[0-9]\\+ ino=[0-9]\\+ "
+ "grants={}$"))
+ {
+ TH_LOG("Expected a grants={} event\n%s", buf);
+ }
+
+ /* Walking up to dir_s1d1 grants the requested read_dir. */
+ EXPECT_EQ(
+ 1,
+ tracefs_count_matches(
+ buf,
+ TRACE_PREFIX(
+ TRACE_TASK) "landlock_check_rule_fs: domain=[0-9a-f]\\+ "
+ "access_request=read_dir "
+ "dev=[0-9]\\+:[0-9]\\+ ino=[0-9]\\+ "
+ "grants={read_dir}$"))
+ {
+ TH_LOG("Expected a grants={read_dir} event\n%s", buf);
+ }
+
+ free(buf);
+}
+
TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/landlock/trace_fs_test.c b/tools/testing/selftests/landlock/trace_fs_test.c
new file mode 100644
index 000000000000..5220f6a4bee1
--- /dev/null
+++ b/tools/testing/selftests/landlock/trace_fs_test.c
@@ -0,0 +1,496 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Landlock tests - Filesystem tracepoints
+ *
+ * Copyright © 2026 Cloudflare, Inc.
+ */
+
+#define _GNU_SOURCE
+#include <errno.h>
+#include <fcntl.h>
+#include <linux/landlock.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 "common.h"
+#include "trace.h"
+
+#define TRACE_TASK "trace_fs_test"
+
+/*
+ * Like REGEX_DENY_ACCESS_FS(), but pins the logged field to a specific value
+ * ("0" or "1") so a test can tell a suppressed (quiet) denial from a logged
+ * one. The tracepoint fires for every denial; logged carries the audit
+ * verdict.
+ */
+#define REGEX_DENY_ACCESS_FS_LOGGED(task, log) \
+ TRACE_PREFIX(task) \
+ "landlock_deny_access_fs: " \
+ "domain=[0-9a-f]\\+ " \
+ "same_exec=[01] " \
+ "logged=" log " " \
+ "blockers=[a-z_|]* " \
+ "dev=[0-9]\\+:[0-9]\\+ " \
+ "ino=[0-9]\\+ " \
+ "path=[^ ]*$"
+
+/* clang-format off */
+FIXTURE(trace_fs) {
+ /* clang-format on */
+ int tracefs_ok;
+};
+
+FIXTURE_SETUP(trace_fs)
+{
+ int ret;
+
+ set_cap(_metadata, CAP_SYS_ADMIN);
+ ASSERT_EQ(0, unshare(CLONE_NEWNS));
+ ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL));
+
+ ret = tracefs_fixture_setup();
+ if (ret) {
+ clear_cap(_metadata, CAP_SYS_ADMIN);
+ self->tracefs_ok = 0;
+ SKIP(return, "tracefs not available");
+ }
+ self->tracefs_ok = 1;
+
+ ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, true));
+ ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, true));
+ ASSERT_EQ(0, tracefs_enable_event(TRACEFS_DENY_ACCESS_FS_ENABLE, true));
+ ASSERT_EQ(0, tracefs_clear());
+ clear_cap(_metadata, CAP_SYS_ADMIN);
+}
+
+FIXTURE_TEARDOWN(trace_fs)
+{
+ if (!self->tracefs_ok)
+ return;
+
+ set_cap(_metadata, CAP_SYS_ADMIN);
+ tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, false);
+ tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, false);
+ tracefs_enable_event(TRACEFS_DENY_ACCESS_FS_ENABLE, false);
+ tracefs_fixture_teardown();
+ clear_cap(_metadata, CAP_SYS_ADMIN);
+}
+
+/*
+ * Baseline: verifies that without Landlock, the operation succeeds and no
+ * check_rule or deny_access trace events fire.
+ */
+TEST_F(trace_fs, unsandboxed)
+{
+ char *buf;
+ int count, status, fd;
+ pid_t pid;
+
+ ASSERT_EQ(0, tracefs_clear_buf());
+
+ pid = fork();
+ ASSERT_LE(0, pid);
+
+ if (pid == 0) {
+ /*
+ * No sandbox: verify that a normal FS access does not produce
+ * Landlock trace events.
+ */
+ fd = open("/usr", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (fd >= 0)
+ close(fd);
+ _exit(0);
+ }
+
+ ASSERT_EQ(pid, waitpid(pid, &status, 0));
+ ASSERT_TRUE(WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status));
+
+ buf = tracefs_read_buf();
+ ASSERT_NE(NULL, buf);
+
+ count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
+ EXPECT_EQ(0, count);
+ count = tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK));
+ EXPECT_EQ(0, count);
+
+ free(buf);
+}
+
+/*
+ * Verifies that adding a filesystem rule emits a landlock_add_rule_fs trace
+ * event with the expected path and field values: ruleset ID is non-zero,
+ * access_rights is non-zero, and path matches.
+ */
+TEST_F(trace_fs, add_rule_fs)
+{
+ struct landlock_ruleset_attr ruleset_attr = {
+ .handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE |
+ LANDLOCK_ACCESS_FS_WRITE_FILE |
+ LANDLOCK_ACCESS_FS_READ_DIR,
+ };
+ struct landlock_path_beneath_attr path_beneath = {
+ .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE,
+ };
+ char *buf, field_buf[64];
+ int ruleset_fd, count;
+
+ ruleset_fd =
+ landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0);
+ ASSERT_LE(0, ruleset_fd);
+
+ path_beneath.parent_fd = open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC);
+ ASSERT_LE(0, path_beneath.parent_fd);
+
+ ASSERT_EQ(0, landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
+ &path_beneath, 0));
+ ASSERT_EQ(0, close(path_beneath.parent_fd));
+ ASSERT_EQ(0, close(ruleset_fd));
+
+ buf = tracefs_read_buf();
+ ASSERT_NE(NULL, buf);
+
+ count = tracefs_count_matches(buf, REGEX_ADD_RULE_FS(TRACE_TASK));
+ EXPECT_EQ(1, count)
+ {
+ TH_LOG("Expected 1 add_rule_fs event, got %d\n%s", count, buf);
+ }
+
+ /* Ruleset ID should be non-zero. */
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_ADD_RULE_FS(TRACE_TASK),
+ "ruleset", field_buf,
+ sizeof(field_buf)));
+ EXPECT_STRNE("0", field_buf);
+
+ /* Access rights should be non-zero. */
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_ADD_RULE_FS(TRACE_TASK),
+ "access_rights", field_buf,
+ sizeof(field_buf)));
+ EXPECT_STRNE("", field_buf);
+
+ /* Path should be /usr. */
+ ASSERT_EQ(0,
+ tracefs_extract_field(buf, REGEX_ADD_RULE_FS(TRACE_TASK),
+ "path", field_buf, sizeof(field_buf)));
+ EXPECT_STREQ("/usr", field_buf);
+
+ free(buf);
+}
+
+/*
+ * Verifies that an allowed access emits check_rule events (rule matched during
+ * pathwalk) but does NOT emit deny_access events (no denial).
+ */
+TEST_F(trace_fs, allowed_access)
+{
+ char *buf, field_buf[64];
+ int count;
+
+ ASSERT_EQ(0, tracefs_clear_buf());
+
+ /* Rule allows READ_DIR for /usr, access /usr which is allowed. */
+ sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_DIR,
+ LANDLOCK_ACCESS_FS_READ_DIR, "/usr");
+
+ buf = tracefs_read_buf();
+ ASSERT_NE(NULL, buf);
+
+ count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
+ EXPECT_LE(1, count);
+
+ /* Single-layer grants array, intersected with the request. */
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
+ "grants", field_buf,
+ sizeof(field_buf)));
+ EXPECT_STREQ("{read_dir}", field_buf);
+
+ count = tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK));
+ EXPECT_EQ(0, count);
+
+ free(buf);
+}
+
+/*
+ * Verifies that accessing a path whose access type is not in the handled set
+ * does not emit landlock_check_rule events. The ruleset handles READ_FILE, but
+ * the directory open checks READ_DIR which is unhandled; Landlock has no
+ * opinion and no rule evaluation occurs.
+ */
+TEST_F(trace_fs, check_rule_unhandled)
+{
+ char *buf;
+ int count;
+
+ ASSERT_EQ(0, tracefs_clear_buf());
+
+ /* Handles READ_FILE only; READ_DIR is unhandled. */
+ sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_FILE,
+ LANDLOCK_ACCESS_FS_READ_FILE, "/tmp");
+
+ buf = tracefs_read_buf();
+ ASSERT_NE(NULL, buf);
+
+ /* No check_rule events because READ_DIR is not in the handled set. */
+ count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
+ EXPECT_EQ(0, count);
+
+ free(buf);
+}
+
+/*
+ * Verifies that nested domains (child sandboxed under a parent domain) emit
+ * check_rule events from both layers and produce a deny_access event when the
+ * inner domain's rule does not cover the access.
+ */
+TEST_F(trace_fs, check_rule_nested)
+{
+ char *buf, field_buf[64], *comma;
+ size_t first_len, second_len;
+ int count_rule, count_access, status;
+ pid_t pid;
+
+ ASSERT_EQ(0, tracefs_clear_buf());
+
+ pid = fork();
+ ASSERT_LE(0, pid);
+
+ if (pid == 0) {
+ struct landlock_ruleset_attr ruleset_attr = {
+ .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR,
+ };
+ struct landlock_path_beneath_attr path_beneath = {
+ .allowed_access = LANDLOCK_ACCESS_FS_READ_DIR,
+ };
+ int ruleset_fd, fd;
+
+ /* First layer: allow /usr. */
+ ruleset_fd = landlock_create_ruleset(&ruleset_attr,
+ sizeof(ruleset_attr), 0);
+ if (ruleset_fd < 0)
+ _exit(1);
+
+ path_beneath.parent_fd =
+ open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC);
+ if (path_beneath.parent_fd < 0) {
+ close(ruleset_fd);
+ _exit(1);
+ }
+
+ if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
+ &path_beneath, 0)) {
+ close(path_beneath.parent_fd);
+ close(ruleset_fd);
+ _exit(1);
+ }
+ close(path_beneath.parent_fd);
+
+ prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ if (landlock_restrict_self(ruleset_fd, 0)) {
+ close(ruleset_fd);
+ _exit(1);
+ }
+ close(ruleset_fd);
+
+ /* Second layer: also allow /usr. */
+ ruleset_fd = landlock_create_ruleset(&ruleset_attr,
+ sizeof(ruleset_attr), 0);
+ if (ruleset_fd < 0)
+ _exit(1);
+
+ path_beneath.parent_fd =
+ open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC);
+ if (path_beneath.parent_fd < 0) {
+ close(ruleset_fd);
+ _exit(1);
+ }
+
+ if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
+ &path_beneath, 0)) {
+ close(path_beneath.parent_fd);
+ close(ruleset_fd);
+ _exit(1);
+ }
+ close(path_beneath.parent_fd);
+
+ if (landlock_restrict_self(ruleset_fd, 0)) {
+ close(ruleset_fd);
+ _exit(1);
+ }
+ close(ruleset_fd);
+
+ /* Access /usr which is allowed by both layers. */
+ fd = open("/usr", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (fd >= 0)
+ close(fd);
+
+ /* Access /tmp which has no rule in either layer. */
+ fd = open("/tmp", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (fd >= 0)
+ close(fd);
+
+ _exit(0);
+ }
+
+ ASSERT_EQ(pid, waitpid(pid, &status, 0));
+ ASSERT_TRUE(WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status));
+
+ buf = tracefs_read_buf();
+ ASSERT_NE(NULL, buf);
+
+ count_rule =
+ tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK));
+ EXPECT_LE(1, count_rule);
+
+ /*
+ * Both layers have the same rule, so the grants array must have two
+ * identical symbolic entries, e.g. {read_dir,read_dir}.
+ */
+ ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK),
+ "grants", field_buf,
+ sizeof(field_buf)));
+ comma = strchr(field_buf, ',');
+ EXPECT_NE(0, !!comma);
+ if (comma) {
+ /*
+ * Verify both entries are identical: compare the substring
+ * before the comma with the substring after it (stripping the
+ * braces).
+ */
+ first_len = comma - field_buf - 1;
+ second_len = strlen(comma + 1) - 1;
+ EXPECT_EQ(first_len, second_len);
+ EXPECT_EQ(0, strncmp(field_buf + 1, comma + 1, first_len));
+ }
+
+ count_access =
+ tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK));
+ EXPECT_LE(1, count_access);
+
+ free(buf);
+}
+
+/*
+ * Verifies that a denied FS access emits a landlock_deny_access_fs trace event
+ * with the blocked access and path.
+ */
+TEST_F(trace_fs, deny_access_fs_denied)
+{
+ char *buf;
+ int count;
+
+ ASSERT_EQ(0, tracefs_clear_buf());
+
+ /*
+ * Rule allows READ_DIR for /usr, but access /tmp which has no rule.
+ * READ_DIR access to /tmp is denied by absence and should emit a
+ * deny_access_fs event.
+ */
+ sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_DIR,
+ LANDLOCK_ACCESS_FS_READ_DIR, "/tmp");
+
+ buf = tracefs_read_buf();
+ ASSERT_NE(NULL, buf);
+
+ count = tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK));
+ EXPECT_LE(1, count);
+
+ free(buf);
+}
+
+/*
+ * A denied FS access covered by a quiet rule (LANDLOCK_ADD_RULE_QUIET with the
+ * access listed in quiet_access_fs) still emits a landlock_deny_access_fs
+ * event, but with logged=0, the same audit-logging verdict audit would apply to
+ * suppress the record.
+ */
+TEST_F(trace_fs, deny_access_fs_quiet)
+{
+ char *buf, field[64];
+ pid_t pid;
+ int status;
+
+ ASSERT_EQ(0, tracefs_clear_buf());
+
+ pid = fork();
+ ASSERT_LE(0, pid);
+ if (pid == 0) {
+ struct landlock_ruleset_attr ruleset_attr = {
+ .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR,
+ .quiet_access_fs = LANDLOCK_ACCESS_FS_READ_DIR,
+ };
+ struct landlock_path_beneath_attr path_beneath = {
+ .allowed_access = 0,
+ };
+ int ruleset_fd, fd;
+
+ ruleset_fd = landlock_create_ruleset(&ruleset_attr,
+ sizeof(ruleset_attr), 0);
+ if (ruleset_fd < 0)
+ _exit(1);
+
+ /* Marks /tmp quiet without granting any access. */
+ path_beneath.parent_fd =
+ open("/tmp", O_PATH | O_DIRECTORY | O_CLOEXEC);
+ if (path_beneath.parent_fd < 0) {
+ close(ruleset_fd);
+ _exit(1);
+ }
+ if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
+ &path_beneath, LANDLOCK_ADD_RULE_QUIET)) {
+ close(path_beneath.parent_fd);
+ close(ruleset_fd);
+ _exit(1);
+ }
+ close(path_beneath.parent_fd);
+
+ prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+ if (landlock_restrict_self(ruleset_fd, 0)) {
+ close(ruleset_fd);
+ _exit(1);
+ }
+ close(ruleset_fd);
+
+ /* Denied READ_DIR on the quiet /tmp: suppressed, logged=0. */
+ fd = open("/tmp", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ if (fd >= 0)
+ close(fd);
+ _exit(0);
+ }
+ ASSERT_EQ(pid, waitpid(pid, &status, 0));
+ ASSERT_TRUE(WIFEXITED(status));
+ EXPECT_EQ(0, WEXITSTATUS(status));
+
+ buf = tracefs_read_buf();
+ ASSERT_NE(NULL, buf);
+
+ /* The event fires with the suppressed verdict. */
+ EXPECT_LE(1, tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS_LOGGED(
+ TRACE_TASK, "0")));
+ /* The quiet rule must not leave the denial logged. */
+ EXPECT_EQ(0, tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS_LOGGED(
+ TRACE_TASK, "1")));
+
+ /*
+ * Quiet suppresses only the logged verdict: the rest of the denial
+ * event stays populated (non-zero domain, non-empty blockers).
+ */
+ ASSERT_EQ(0, tracefs_extract_field(
+ buf, REGEX_DENY_ACCESS_FS_LOGGED(TRACE_TASK, "0"),
+ "domain", field, sizeof(field)));
+ EXPECT_STRNE("0", field);
+ ASSERT_EQ(0, tracefs_extract_field(
+ buf, REGEX_DENY_ACCESS_FS_LOGGED(TRACE_TASK, "0"),
+ "blockers", field, sizeof(field)));
+ EXPECT_STRNE("", field);
+
+ free(buf);
+}
+
+TEST_HARNESS_MAIN
--
2.54.0
next prev parent reply other threads:[~2026-08-11 9:44 UTC|newest]
Thread overview: 25+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-08-11 9:43 [PATCH v4 00/19] Landlock tracepoints Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 01/19] landlock: Prepare ruleset and domain type split Mickaël Salaün
2026-08-16 17:56 ` Tingmao Wang
2026-08-11 9:43 ` [PATCH v4 02/19] landlock: Move domain query functions to domain.c Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 03/19] landlock: Split struct landlock_domain from struct landlock_ruleset Mickaël Salaün
2026-08-20 10:45 ` Günther Noack
2026-08-11 9:43 ` [PATCH v4 04/19] landlock: Split denial logging from audit into common framework Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 05/19] landlock: Decouple the per-denial logging decision from CONFIG_AUDIT Mickaël Salaün
2026-08-16 22:39 ` Tingmao Wang
2026-08-11 9:43 ` [PATCH v4 06/19] landlock: Consolidate access-right and scope names in a shared header Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 07/19] landlock: Add create_ruleset and free_ruleset tracepoints Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 08/19] landlock: Add landlock_add_rule_fs and landlock_add_rule_net tracepoints Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 09/19] landlock: Add create_domain and free_domain tracepoints Mickaël Salaün
2026-08-20 10:36 ` Günther Noack
2026-08-11 9:43 ` [PATCH v4 10/19] landlock: Add landlock_enforce_domain tracepoint Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 11/19] landlock: Add tracepoints for rule checking Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 12/19] landlock: Add landlock_deny_access_fs and landlock_deny_access_net Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 13/19] landlock: Add tracepoints for ptrace and scope denials Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 14/19] selftests/landlock: Add trace event test infrastructure and tests Mickaël Salaün
2026-08-11 9:43 ` Mickaël Salaün [this message]
2026-08-11 9:43 ` [PATCH v4 16/19] selftests/landlock: Add network tracepoint tests Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 17/19] selftests/landlock: Add scope and ptrace " Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 18/19] selftests/landlock: Add landlock_enforce_domain trace tests Mickaël Salaün
2026-08-11 9:43 ` [PATCH v4 19/19] landlock: Document tracepoints Mickaël Salaün
2026-08-11 13:51 ` [PATCH v4 00/19] Landlock tracepoints Steven Rostedt
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=20260811094338.288094-16-mic@digikod.net \
--to=mic@digikod.net \
--cc=brauner@kernel.org \
--cc=gnoack@google.com \
--cc=ivanov.mikhail1@huawei-partners.com \
--cc=jannh@google.com \
--cc=jeffxu@google.com \
--cc=kees@kernel.org \
--cc=kernel-team@cloudflare.com \
--cc=linux-security-module@vger.kernel.org \
--cc=linux-trace-kernel@vger.kernel.org \
--cc=m@maowtm.org \
--cc=mathieu.desnoyers@efficios.com \
--cc=matthieu@buffet.re \
--cc=mhiramat@kernel.org \
--cc=rostedt@goodmis.org \
--cc=utilityemal77@gmail.com \
/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.