Add trace tests for the landlock_enforce_domain event in trace_test.c,
asserting field counts after the syscall returns rather than line
ordering across per-CPU buffers.  They cover single-threaded and TSYNC
enforcement (complete and process_wide set), a multi-threaded non-TSYNC
process (process_wide clear), the single-threaded non-leader edge case,
the flags-only path that creates no domain, and a thread-sync abort that
emits create_domain and free_domain but no enforce_domain.

landlock_enforce_domain is added to the fixture enable path and every
disable list so its zero-events assertions cannot be tripped by a stray
enforcement event.

Test coverage for security/landlock is 91.6% of 2552 lines according to
LLVM 22.

Cc: Günther Noack <[email protected]>
Cc: Tingmao Wang <[email protected]>
Signed-off-by: Mickaël Salaün <[email protected]>
---

Changes since v2:
- New patch.
---
 tools/testing/selftests/landlock/trace.h      |   9 +
 tools/testing/selftests/landlock/trace_test.c | 522 ++++++++++++++++++
 2 files changed, 531 insertions(+)

diff --git a/tools/testing/selftests/landlock/trace.h 
b/tools/testing/selftests/landlock/trace.h
index e03c689ef398..9e901823ddaf 100644
--- a/tools/testing/selftests/landlock/trace.h
+++ b/tools/testing/selftests/landlock/trace.h
@@ -25,6 +25,8 @@
        TRACEFS_LANDLOCK_DIR "/landlock_create_ruleset/enable"
 #define TRACEFS_CREATE_DOMAIN_ENABLE \
        TRACEFS_LANDLOCK_DIR "/landlock_create_domain/enable"
+#define TRACEFS_ENFORCE_DOMAIN_ENABLE \
+       TRACEFS_LANDLOCK_DIR "/landlock_enforce_domain/enable"
 #define TRACEFS_ADD_RULE_FS_ENABLE \
        TRACEFS_LANDLOCK_DIR "/landlock_add_rule_fs/enable"
 #define TRACEFS_ADD_RULE_NET_ENABLE \
@@ -108,6 +110,13 @@
        "parent=[0-9a-f]\\+ "      \
        "ruleset=[0-9a-f]\\+\\.[0-9]\\+$"
 
+#define REGEX_ENFORCE_DOMAIN(task)  \
+       TRACE_PREFIX(task)          \
+       "landlock_enforce_domain: " \
+       "domain=[0-9a-f]\\+ "       \
+       "complete=[01] "            \
+       "process_wide=[01]$"
+
 #define REGEX_CHECK_RULE_FS(task)  \
        TRACE_PREFIX(task)         \
        "landlock_check_rule_fs: " \
diff --git a/tools/testing/selftests/landlock/trace_test.c 
b/tools/testing/selftests/landlock/trace_test.c
index a141f22ad98f..e2415b98868d 100644
--- a/tools/testing/selftests/landlock/trace_test.c
+++ b/tools/testing/selftests/landlock/trace_test.c
@@ -9,6 +9,7 @@
 #include <errno.h>
 #include <fcntl.h>
 #include <linux/landlock.h>
+#include <pthread.h>
 #include <sched.h>
 #include <stdio.h>
 #include <string.h>
@@ -47,6 +48,7 @@ FIXTURE_SETUP(trace)
 
        ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CREATE_RULESET_ENABLE, true));
        ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CREATE_DOMAIN_ENABLE, true));
+       ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ENFORCE_DOMAIN_ENABLE, true));
        ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, true));
        ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_NET_ENABLE, true));
        ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, true));
@@ -69,6 +71,7 @@ FIXTURE_TEARDOWN(trace)
        set_cap(_metadata, CAP_SYS_ADMIN);
        tracefs_enable_event(TRACEFS_CREATE_RULESET_ENABLE, false);
        tracefs_enable_event(TRACEFS_CREATE_DOMAIN_ENABLE, false);
+       tracefs_enable_event(TRACEFS_ENFORCE_DOMAIN_ENABLE, false);
        tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, false);
        tracefs_enable_event(TRACEFS_ADD_RULE_NET_ENABLE, false);
        tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, false);
@@ -98,6 +101,8 @@ TEST_F(trace, no_trace_when_disabled)
        ASSERT_EQ(0,
                  tracefs_enable_event(TRACEFS_CREATE_RULESET_ENABLE, false));
        ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CREATE_DOMAIN_ENABLE, false));
+       ASSERT_EQ(0,
+                 tracefs_enable_event(TRACEFS_ENFORCE_DOMAIN_ENABLE, false));
        ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, false));
        ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_NET_ENABLE, false));
        ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, false));
@@ -1069,6 +1074,523 @@ TEST_F(trace, free_ruleset_on_close)
        free(buf);
 }
 
+/*
+ * Counts landlock_enforce_domain lines, filtered by @domain (NULL matches 
any),
+ * @complete and @process_wide (a negative value matches any).  Builds the
+ * anchored regex dynamically so a single helper covers every field assertion.
+ */
+static int count_enforce_matches(const char *buf, const char *domain,
+                                int complete, int process_wide)
+{
+       char pattern[512], dom[80], comp[8], pw[8];
+
+       if (domain)
+               snprintf(dom, sizeof(dom), "%s", domain);
+       else
+               snprintf(dom, sizeof(dom), "[0-9a-f]\\+");
+       if (complete < 0)
+               snprintf(comp, sizeof(comp), "[01]");
+       else
+               snprintf(comp, sizeof(comp), "%d", complete);
+       if (process_wide < 0)
+               snprintf(pw, sizeof(pw), "[01]");
+       else
+               snprintf(pw, sizeof(pw), "%d", process_wide);
+
+       snprintf(pattern, sizeof(pattern),
+                TRACE_PREFIX(TRACE_TASK) "landlock_enforce_domain: "
+                                         "domain=%s "
+                                         "complete=%s process_wide=%s$",
+                dom, comp, pw);
+       return tracefs_count_matches(buf, pattern);
+}
+
+/* Idle sibling: waits on the barrier so it is a live thread, then sleeps. */
+static void *enforce_idle(void *arg)
+{
+       pthread_barrier_t *barrier = arg;
+
+       pthread_barrier_wait(barrier);
+       while (true)
+               sleep(1);
+       return NULL;
+}
+
+/*
+ * Child body: spawns @nthreads idle siblings (barrier-synchronized so they are
+ * live when the syscall runs), then enforces a domain with @flags.  Returns 0
+ * on success; the process exits afterwards, reaping the siblings.
+ */
+static int child_enforce(int nthreads, __u32 flags)
+{
+       pthread_t threads[8];
+       pthread_barrier_t barrier;
+       int ruleset_fd, i;
+
+       if (nthreads > 0) {
+               if (pthread_barrier_init(&barrier, NULL, nthreads + 1))
+                       return 1;
+               for (i = 0; i < nthreads; i++)
+                       if (pthread_create(&threads[i], NULL, enforce_idle,
+                                          &barrier))
+                               return 1;
+               pthread_barrier_wait(&barrier);
+       }
+
+       ruleset_fd = build_enforce_ruleset();
+       if (ruleset_fd < 0)
+               return 1;
+
+       prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+       if (landlock_restrict_self(ruleset_fd, flags))
+               return 1;
+       close(ruleset_fd);
+       return 0;
+}
+
+/* Reads the group's live thread count from /proc/self/status 
(get_nr_threads). */
+static int read_thread_count(void)
+{
+       char line[128];
+       FILE *f;
+       int n = -1;
+
+       f = fopen("/proc/self/status", "re");
+       if (!f)
+               return -1;
+       while (fgets(line, sizeof(line), f))
+               if (sscanf(line, "Threads: %d", &n) == 1)
+                       break;
+       fclose(f);
+       return n;
+}
+
+/* Exit code the non-leader child uses to signal "could not go 
single-threaded". */
+#define ENFORCE_SKIP_EXIT 2
+
+/*
+ * Runs in a spawned thread after the group leader called pthread_exit().  
Waits
+ * until it is the only live thread (get_nr_threads() == 1 despite not being 
the
+ * leader), then enforces a domain.
+ */
+static void *enforce_nonleader(void *arg)
+{
+       int ruleset_fd, i;
+
+       for (i = 0; i < 200; i++) {
+               if (read_thread_count() == 1)
+                       break;
+               usleep(10000);
+       }
+       if (read_thread_count() != 1)
+               _exit(ENFORCE_SKIP_EXIT);
+
+       ruleset_fd = build_enforce_ruleset();
+       if (ruleset_fd < 0)
+               _exit(1);
+       prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+       if (landlock_restrict_self(ruleset_fd, 0))
+               _exit(1);
+       _exit(0);
+}
+
+/*
+ * Verifies the single-threaded non-TSYNC case: one create_domain and exactly
+ * one enforce_domain, with complete=1 (the operation concludes) and
+ * process_wide=1 (get_nr_threads() == 1), sharing the created domain ID.
+ */
+TEST_F(trace, enforce_single)
+{
+       pid_t pid;
+       int status;
+       char *buf;
+       char domain[64];
+
+       ASSERT_EQ(0, tracefs_clear_buf());
+
+       pid = fork();
+       ASSERT_LE(0, pid);
+       if (pid == 0)
+               _exit(child_enforce(0, 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);
+
+       EXPECT_EQ(1,
+                 tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK)));
+       EXPECT_EQ(1, count_enforce_matches(buf, NULL, -1, -1))
+       {
+               TH_LOG("Expected 1 enforce_domain event\n%s", buf);
+       }
+       EXPECT_EQ(1, count_enforce_matches(buf, NULL, 1, 1));
+
+       ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CREATE_DOMAIN(TRACE_TASK),
+                                          "domain", domain, sizeof(domain)));
+       EXPECT_EQ(1, count_enforce_matches(buf, domain, 1, 1));
+
+       free(buf);
+}
+
+/*
+ * Verifies that TSYNC on a single-threaded process still concludes: one
+ * enforce_domain with complete=1 and process_wide=1.
+ */
+TEST_F(trace, enforce_tsync_single)
+{
+       pid_t pid;
+       int status;
+       char *buf;
+
+       ASSERT_EQ(0, tracefs_clear_buf());
+
+       pid = fork();
+       ASSERT_LE(0, pid);
+       if (pid == 0)
+               _exit(child_enforce(0, LANDLOCK_RESTRICT_SELF_TSYNC));
+
+       ASSERT_EQ(pid, waitpid(pid, &status, 0));
+       ASSERT_TRUE(WIFEXITED(status));
+       EXPECT_EQ(0, WEXITSTATUS(status));
+
+       buf = tracefs_read_buf();
+       ASSERT_NE(NULL, buf);
+
+       EXPECT_EQ(1,
+                 tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK)));
+       EXPECT_EQ(1, count_enforce_matches(buf, NULL, -1, -1));
+       EXPECT_EQ(1, count_enforce_matches(buf, NULL, 1, 1));
+
+       free(buf);
+}
+
+/*
+ * Verifies TSYNC across a multi-threaded process: one create_domain and
+ * exactly N+1 enforce_domain events (caller plus N siblings), of which exactly
+ * one is complete=1, all are process_wide=1, and all carry the same domain ID.
+ * These are order-independent field counts, evaluated after the syscall
+ * returns (the caller has waited on all siblings by then).
+ */
+TEST_F(trace, enforce_tsync_multithread)
+{
+       const int nsiblings = 3;
+       pid_t pid;
+       int status;
+       char *buf;
+       char domain[64];
+
+       ASSERT_EQ(0, tracefs_clear_buf());
+
+       pid = fork();
+       ASSERT_LE(0, pid);
+       if (pid == 0)
+               _exit(child_enforce(nsiblings, LANDLOCK_RESTRICT_SELF_TSYNC));
+
+       ASSERT_EQ(pid, waitpid(pid, &status, 0));
+       ASSERT_TRUE(WIFEXITED(status));
+       EXPECT_EQ(0, WEXITSTATUS(status));
+
+       buf = tracefs_read_buf();
+       ASSERT_NE(NULL, buf);
+
+       EXPECT_EQ(1,
+                 tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK)));
+       EXPECT_EQ(nsiblings + 1, count_enforce_matches(buf, NULL, -1, -1))
+       {
+               TH_LOG("Expected %d enforce_domain events\n%s", nsiblings + 1,
+                      buf);
+       }
+       EXPECT_EQ(1, count_enforce_matches(buf, NULL, 1, -1));
+       EXPECT_EQ(nsiblings, count_enforce_matches(buf, NULL, 0, -1));
+       EXPECT_EQ(nsiblings + 1, count_enforce_matches(buf, NULL, -1, 1));
+
+       ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CREATE_DOMAIN(TRACE_TASK),
+                                          "domain", domain, sizeof(domain)));
+       EXPECT_EQ(nsiblings + 1, count_enforce_matches(buf, domain, -1, -1));
+
+       free(buf);
+}
+
+/*
+ * Verifies the disambiguation case: a non-TSYNC restrict_self on a
+ * multi-threaded process enforces only the caller, so the single 
enforce_domain
+ * has process_wide=0 (the siblings stay unconfined).
+ */
+TEST_F(trace, enforce_multithread_non_tsync)
+{
+       const int nsiblings = 3;
+       pid_t pid;
+       int status;
+       char *buf;
+
+       ASSERT_EQ(0, tracefs_clear_buf());
+
+       pid = fork();
+       ASSERT_LE(0, pid);
+       if (pid == 0)
+               _exit(child_enforce(nsiblings, 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);
+
+       EXPECT_EQ(1,
+                 tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK)));
+       EXPECT_EQ(1, count_enforce_matches(buf, NULL, -1, -1));
+       EXPECT_EQ(1, count_enforce_matches(buf, NULL, 1, 0))
+       {
+               TH_LOG("Expected process_wide=0 enforce_domain event\n%s", buf);
+       }
+
+       free(buf);
+}
+
+/*
+ * Verifies that a single-threaded caller that is not the group leader (the
+ * leader called pthread_exit()) still reports process_wide=1, the case
+ * get_nr_threads() handles and the leader-relative thread_group_empty() would
+ * not.  SKIPs if the group cannot be reduced to one live thread.
+ */
+TEST_F(trace, enforce_single_non_leader)
+{
+       pid_t pid;
+       int status;
+       char *buf;
+
+       ASSERT_EQ(0, tracefs_clear_buf());
+
+       pid = fork();
+       ASSERT_LE(0, pid);
+       if (pid == 0) {
+               pthread_t worker;
+
+               if (pthread_create(&worker, NULL, enforce_nonleader, NULL))
+                       _exit(1);
+               /* Leader leaves; the worker becomes the only live thread. */
+               pthread_exit(NULL);
+       }
+
+       ASSERT_EQ(pid, waitpid(pid, &status, 0));
+       ASSERT_TRUE(WIFEXITED(status));
+       if (WEXITSTATUS(status) == ENFORCE_SKIP_EXIT)
+               SKIP(return, "could not reduce group to a single thread");
+       EXPECT_EQ(0, WEXITSTATUS(status));
+
+       buf = tracefs_read_buf();
+       ASSERT_NE(NULL, buf);
+
+       EXPECT_EQ(1,
+                 tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK)));
+       EXPECT_EQ(1, count_enforce_matches(buf, NULL, 1, 1))
+       {
+               TH_LOG("Expected process_wide=1 for non-leader caller\n%s",
+                      buf);
+       }
+
+       free(buf);
+}
+
+/*
+ * Verifies the flags-only path (ruleset_fd == -1) creates no domain and emits
+ * neither create_domain nor enforce_domain, with and without TSYNC.
+ */
+TEST_F(trace, enforce_flags_only)
+{
+       pid_t pid;
+       int status;
+       char *buf;
+
+       ASSERT_EQ(0, tracefs_clear_buf());
+
+       pid = fork();
+       ASSERT_LE(0, pid);
+       if (pid == 0) {
+               prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+               if (landlock_restrict_self(
+                           -1, LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF))
+                       _exit(1);
+               if (landlock_restrict_self(
+                           -1, LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF |
+                                       LANDLOCK_RESTRICT_SELF_TSYNC))
+                       _exit(1);
+               _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);
+
+       EXPECT_EQ(0,
+                 tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK)));
+       EXPECT_EQ(0, count_enforce_matches(buf, NULL, -1, -1))
+       {
+               TH_LOG("No enforce_domain expected on flags-only path\n%s",
+                      buf);
+       }
+
+       free(buf);
+}
+
+static void enforce_nop_handler(int sig)
+{
+}
+
+struct abort_signaler_data {
+       pthread_t target;
+       volatile bool stop;
+};
+
+/* Hammers the target thread with SIGUSR1 to interrupt the TSYNC prepare wait. 
*/
+static void *abort_signaler(void *arg)
+{
+       struct abort_signaler_data *data = arg;
+
+       while (!data->stop)
+               pthread_kill(data->target, SIGUSR1);
+       return NULL;
+}
+
+/*
+ * Child body for the abort test: with idle siblings and a signaler 
interrupting
+ * it, repeatedly enforces under TSYNC.  An interrupted attempt aborts its
+ * just-created domain (create_domain + free_domain, zero enforce_domain) while
+ * -ERESTARTNOINTR transparently restarts the syscall, so a successful retry 
may
+ * add its own full lifecycle.
+ */
+static int child_abort(int nsiblings, int attempts)
+{
+       pthread_t threads[16];
+       pthread_t signaler;
+       pthread_barrier_t barrier;
+       struct abort_signaler_data data = {};
+       struct sigaction sa = {};
+       int i;
+
+       sa.sa_handler = enforce_nop_handler;
+       if (sigaction(SIGUSR1, &sa, NULL))
+               return 1;
+
+       if (pthread_barrier_init(&barrier, NULL, nsiblings + 1))
+               return 1;
+       for (i = 0; i < nsiblings; i++)
+               if (pthread_create(&threads[i], NULL, enforce_idle, &barrier))
+                       return 1;
+       pthread_barrier_wait(&barrier);
+
+       data.target = pthread_self();
+       if (pthread_create(&signaler, NULL, abort_signaler, &data))
+               return 1;
+
+       prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
+       for (i = 0; i < attempts; i++) {
+               int ruleset_fd = build_enforce_ruleset();
+
+               if (ruleset_fd < 0)
+                       break;
+               /* Ignore the result: an abort returns an error, that is fine. 
*/
+               landlock_restrict_self(ruleset_fd,
+                                      LANDLOCK_RESTRICT_SELF_TSYNC);
+               close(ruleset_fd);
+       }
+
+       data.stop = true;
+       pthread_join(signaler, NULL);
+       return 0;
+}
+
+/*
+ * Verifies the abort contract: a domain aborted by a thread-sync failure emits
+ * create_domain and free_domain but zero enforce_domain.  The signal race is
+ * probabilistic and -ERESTARTNOINTR may add a successful retry's lifecycle, so
+ * events are grouped by domain ID and the test SKIPs if no abort occurred.
+ */
+TEST_F(trace, enforce_abort)
+{
+       pid_t pid;
+       int status, retry;
+       char *buf = NULL;
+       const char *cursor;
+       char domain[64];
+       bool abort_found = false;
+
+       ASSERT_EQ(0, tracefs_clear_buf());
+
+       /* free_domain fires from a kworker, so widen the filter first. */
+       set_cap(_metadata, CAP_SYS_ADMIN);
+       tracefs_clear_pid_filter();
+       clear_cap(_metadata, CAP_SYS_ADMIN);
+
+       pid = fork();
+       ASSERT_LE(0, pid);
+       if (pid == 0)
+               _exit(child_abort(8, 8));
+
+       ASSERT_EQ(pid, waitpid(pid, &status, 0));
+       ASSERT_TRUE(WIFEXITED(status));
+       EXPECT_EQ(0, WEXITSTATUS(status));
+
+       /* Poll for the asynchronous free_domain events. */
+       for (retry = 0; retry < 10; retry++) {
+               usleep(100000);
+               set_cap(_metadata, CAP_SYS_ADMIN);
+               free(buf);
+               buf = tracefs_read_trace();
+               clear_cap(_metadata, CAP_SYS_ADMIN);
+               ASSERT_NE(NULL, buf);
+       }
+
+       set_cap(_metadata, CAP_SYS_ADMIN);
+       ASSERT_EQ(0, tracefs_set_pid_filter(getpid()));
+       clear_cap(_metadata, CAP_SYS_ADMIN);
+
+       /*
+        * Walk every create_domain and look for one whose domain ID has zero
+        * enforce_domain events but a matching free_domain: that is an aborted
+        * domain (created, never enforced, freed).
+        */
+       cursor = buf;
+       while (tracefs_extract_field(cursor, REGEX_CREATE_DOMAIN(TRACE_TASK),
+                                    "domain", domain, sizeof(domain)) == 0) {
+               const char *cd, *nl;
+               char free_pattern[256];
+
+               if (count_enforce_matches(buf, domain, -1, -1) == 0) {
+                       snprintf(
+                               free_pattern, sizeof(free_pattern),
+                               TRACE_PREFIX(
+                                       KWORKER_TASK) "landlock_free_domain: "
+                                                     "domain=%s 
denials=[0-9]\\+$",
+                               domain);
+                       if (tracefs_count_matches(buf, free_pattern) >= 1)
+                               abort_found = true;
+               }
+
+               cd = strstr(cursor, "landlock_create_domain:");
+               if (!cd)
+                       break;
+               nl = strchr(cd, '\n');
+               if (!nl)
+                       break;
+               cursor = nl + 1;
+       }
+
+       if (!abort_found) {
+               free(buf);
+               SKIP(return, "signal race did not produce a thread-sync abort");
+       }
+
+       free(buf);
+}
+
 /*
  * The following tests are intentionally elided because the underlying kernel
  * mechanisms are already validated by audit tests:
-- 
2.54.0


Reply via email to