Add a test for the per-VE permission allowlist. It runs against both
interfaces through a fixture variant, ve.proc_permissions and
ve.sysfs_permissions, which share the leaf logic in fs/ve_perms.c.
Each variant names a few host-only files and a host-only directory,
all registered nodes outside a container's default view. Reading the
list back is a seq file, so the test adds a read_file_at helper to the
shared ve_selftest.h.
From the host it grants read on a file, widens to rwx and removes it
checking the list each time, confirms an entry granted to one VE is
not seen by another, and confirms an unknown path is rejected. It
covers the write parser in one test: comment lines skipped, several
entries in one write, a later grant replacing an earlier one, a
duplicate op keeping the last, remove of an absent entry, and a
missing mask rejected, and checks each entry renders at its true
depth with a directory getting a trailing slash.
Enforcement is checked end to end from inside a VE, split into an
allowed and a denied test. A task enters the VE cgroup, unshares
CLONE_NEWVE and opens the entry on the inherited host /proc or /sys.
Granting r makes each file readable and granting x on the directory
lets it be traversed, while an ungranted file stays hidden, a file
granted only w cannot be read, and a directory granted only r cannot
be traversed.
https://virtuozzo.atlassian.net/browse/VSTOR-135286
Signed-off-by: Mirian Shilakadze <[email protected]>
---
tools/testing/selftests/ve/.gitignore | 1 +
tools/testing/selftests/ve/Makefile | 1 +
tools/testing/selftests/ve/ve_perms_test.c | 360 +++++++++++++++++++++
tools/testing/selftests/ve/ve_selftest.h | 26 ++
4 files changed, 388 insertions(+)
create mode 100644 tools/testing/selftests/ve/ve_perms_test.c
diff --git a/tools/testing/selftests/ve/.gitignore b/tools/testing/
selftests/ve/.gitignore
index 7bfff054f9e8..afa4c568c2c9 100644
--- a/tools/testing/selftests/ve/.gitignore
+++ b/tools/testing/selftests/ve/.gitignore
@@ -1 +1,2 @@
ve_ns_owner_test
+ve_perms_test
diff --git a/tools/testing/selftests/ve/Makefile b/tools/testing/
selftests/ve/Makefile
index aa03ab02dda9..ec40cbc7b3a1 100644
--- a/tools/testing/selftests/ve/Makefile
+++ b/tools/testing/selftests/ve/Makefile
@@ -3,5 +3,6 @@
CFLAGS += -g -Wall -O2
TEST_GEN_PROGS += ve_ns_owner_test
+TEST_GEN_PROGS += ve_perms_test
include ../lib.mk
diff --git a/tools/testing/selftests/ve/ve_perms_test.c b/tools/
testing/selftests/ve/ve_perms_test.c
new file mode 100644
index 000000000000..64bf8ab6fcc6
--- /dev/null
+++ b/tools/testing/selftests/ve/ve_perms_test.c
@@ -0,0 +1,360 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * ve_perms selftest
+ *
+ * Drives the per-VE permission allowlist from the host and checks
the per-VE
+ * bookkeeping through cgroupfs. The same tests run against both
interfaces via
+ * a fixture variant: ve.proc_permissions (procfs) and
ve.sysfs_permissions
+ * (sysfs), which share the leaf logic in fs/ve_perms.c. Each variant
names a
+ * few host-only files and a host-only directory, all registered
nodes outside
+ * a container's default view. The tests:
+ *
+ * - grant read on a file, widen to rwx, then remove it (list each
time)
+ * - confirm an entry granted to one VE is not seen by another
+ * - confirm a path that is not a registered entry is rejected
+ * - cover the write parser (masks, comments, several entries,
replace,
+ * duplicate op, remove of an absent entry, missing mask) and
check each
+ * entry renders at its true depth, a directory with a trailing
slash
+ * - enforce the r and x mask bits end to end from inside a VE
+ */
+#define _GNU_SOURCE
+#include <sched.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <sys/wait.h>
+#include <errno.h>
+
+#include "../kselftest_harness.h"
+#include "ve_selftest.h"
+
+#define NR_FILES 2
+
+FIXTURE(ve_perms)
+{
+ int cgv2_fd;
+ int ctid_a;
+ int ctid_b;
+};
+
+FIXTURE_VARIANT(ve_perms)
+{
+ const char *fs; /* "proc"/"sysfs": names the cgroup
file */
+ const char *dir_prefix; /* host mount point: "/proc" or "/
sys" */
+ const char *files[NR_FILES]; /* host-only readable leaves */
+ const char *dir; /* host-only directory */
+};
+
+FIXTURE_VARIANT_ADD(ve_perms, proc)
+{
+ .fs = "proc",
+ .dir_prefix = "/proc",
+ .files = { "interrupts", "slabinfo" },
+ .dir = "bus",
+};
+
+FIXTURE_VARIANT_ADD(ve_perms, sysfs)
+{
+ .fs = "sysfs",
+ .dir_prefix = "/sys",
+ .files = { "kernel/vmcoreinfo", "kernel/notes" },
+ .dir = "power",
+};
+
+/* The per-VE cgroup file for this ctid and filesystem. */
+static void ve_file(char *buf, size_t n, int ctid, const char *fs)
+{
+ snprintf(buf, n, "%d/ve.%s_permissions", ctid, fs);
+}
+
+/*
+ * Fork a child that joins VE cgroup @ctid, unshares into a fresh VE,
and opens
+ * @path with @oflag. Return 0 if the open succeeded, else the errno
it failed
+ * with, or -1 on a harness error. unshare needs a single threaded
caller,
+ * which the freshly forked child is.
+ */
+static int ve_open(int cgv2_fd, int ctid, const char *path, int oflag)
+{
+ int status;
+ pid_t pid;
+
+ pid = fork();
+ if (pid < 0)
+ return -1;
+ if (pid == 0) {
+ int fd;
+
+ if (enter_cgroup(cgv2_fd, ctid) < 0)
+ _exit(255);
+ if (unshare(CLONE_NEWVE) < 0)
+ _exit(255);
+ fd = open(path, oflag);
+ _exit(fd >= 0 ? 0 : errno);
+ }
+ if (waitpid(pid, &status, 0) < 0 || !WIFEXITED(status))
+ return -1;
+ return WEXITSTATUS(status);
+}
+
+/* As ve_open(), for an entry named relative to the filesystem mount
point. */
+static int ve_open_rel(int cgv2_fd, int ctid, const char *prefix,
+ const char *entry, int oflag)
+{
+ char abs[PATH_MAX];
+
+ if (snprintf(abs, sizeof(abs), "%s/%s", prefix, entry) >=
(int)sizeof(abs))
+ return -1;
+ return ve_open(cgv2_fd, ctid, abs, oflag);
+}
+
+FIXTURE_SETUP(ve_perms)
+{
+ self->cgv2_fd = mount_cg2_fd();
+ ASSERT_GE(self->cgv2_fd, 0);
+
+ ASSERT_EQ(write_file_at(self->cgv2_fd, "cgroup.subtree_control",
+ VE_CONTROLLERS), 0);
+
+ self->ctid_a = make_ve(self->cgv2_fd, CTID_MIN);
+ ASSERT_GE(self->ctid_a, 0);
+ self->ctid_b = make_ve(self->cgv2_fd, self->ctid_a + 1);
+ ASSERT_GE(self->ctid_b, 0);
+}
+
+FIXTURE_TEARDOWN(ve_perms)
+{
+ destroy_ve(self->cgv2_fd, self->ctid_b);
+ destroy_ve(self->cgv2_fd, self->ctid_a);
+ close(self->cgv2_fd);
+}
+
+/* Grant read, widen to rwx, then remove, checking the list each
time. */
+TEST_F(ve_perms, grant_widen_revoke)
+{
+ const char *file = variant->files[0];
+ char path[PATH_MAX], line[128], buf[8192];
+
+ ve_file(path, sizeof(path), self->ctid_a, variant->fs);
+
+ ASSERT_GE(read_file_at(self->cgv2_fd, path, buf, sizeof(buf)), 0);
+ EXPECT_EQ(strstr(buf, file), NULL);
+
+ snprintf(line, sizeof(line), "%s r", file);
+ ASSERT_EQ(write_file_at(self->cgv2_fd, path, line), 0);
+ ASSERT_GT(read_file_at(self->cgv2_fd, path, buf, sizeof(buf)), 0);
+ snprintf(line, sizeof(line), "%s r\n", file);
+ EXPECT_NE(strstr(buf, line), NULL);
+
+ snprintf(line, sizeof(line), "%s rwx", file);
+ ASSERT_EQ(write_file_at(self->cgv2_fd, path, line), 0);
+ ASSERT_GT(read_file_at(self->cgv2_fd, path, buf, sizeof(buf)), 0);
+ snprintf(line, sizeof(line), "%s rwx\n", file);
+ EXPECT_NE(strstr(buf, line), NULL);
+
+ snprintf(line, sizeof(line), "%s -", file);
+ ASSERT_EQ(write_file_at(self->cgv2_fd, path, line), 0);
+ ASSERT_GE(read_file_at(self->cgv2_fd, path, buf, sizeof(buf)), 0);
+ EXPECT_EQ(strstr(buf, file), NULL);
+}
+
+/* An entry granted to VE A must not appear for VE B. */
+TEST_F(ve_perms, per_ve_isolation)
+{
+ const char *file = variant->files[0];
+ char pa[PATH_MAX], pb[PATH_MAX], line[128], buf[8192];
+
+ ve_file(pa, sizeof(pa), self->ctid_a, variant->fs);
+ ve_file(pb, sizeof(pb), self->ctid_b, variant->fs);
+
+ snprintf(line, sizeof(line), "%s r", file);
+ ASSERT_EQ(write_file_at(self->cgv2_fd, pa, line), 0);
+
+ ASSERT_GT(read_file_at(self->cgv2_fd, pa, buf, sizeof(buf)), 0);
+ EXPECT_NE(strstr(buf, file), NULL);
+
+ ASSERT_GE(read_file_at(self->cgv2_fd, pb, buf, sizeof(buf)), 0);
+ EXPECT_EQ(strstr(buf, file), NULL);
+}
+
+/* A path that is not a registered entry is rejected. */
+TEST_F(ve_perms, reject_unknown_path)
+{
+ char path[PATH_MAX];
+
+ ve_file(path, sizeof(path), self->ctid_a, variant->fs);
+ EXPECT_LT(write_file_at(self->cgv2_fd, path, "no_such_entry_xyzzy
r"), 0);
+}
+
+/* Masks round-trip, are emitted in r,w,x order, and bad chars are
rejected. */
+TEST_F(ve_perms, mask_parsing)
+{
+ const char *file = variant->files[0];
+ char path[PATH_MAX], line[128], buf[8192];
+
+ ve_file(path, sizeof(path), self->ctid_a, variant->fs);
+
+ /* A single bit. */
+ snprintf(line, sizeof(line), "%s w", file);
+ ASSERT_EQ(write_file_at(self->cgv2_fd, path, line), 0);
+ ASSERT_GT(read_file_at(self->cgv2_fd, path, buf, sizeof(buf)), 0);
+ snprintf(line, sizeof(line), "%s w\n", file);
+ EXPECT_NE(strstr(buf, line), NULL);
+
+ /* Bits canonicalise to r, w, x order regardless of input order. */
+ snprintf(line, sizeof(line), "%s xwr", file);
+ ASSERT_EQ(write_file_at(self->cgv2_fd, path, line), 0);
+ ASSERT_GT(read_file_at(self->cgv2_fd, path, buf, sizeof(buf)), 0);
+ snprintf(line, sizeof(line), "%s rwx\n", file);
+ EXPECT_NE(strstr(buf, line), NULL);
+
+ /* An unknown mask character is rejected. */
+ snprintf(line, sizeof(line), "%s z", file);
+ EXPECT_LT(write_file_at(self->cgv2_fd, path, line), 0);
+}
+
+/*
+ * The write parser: comment lines are skipped, several entries take
effect in
+ * one write, a later grant replaces an earlier one, two ops on one
entry keep
+ * the last, removing an absent entry is a no-op, and a missing mask
is rejected.
+ */
+TEST_F(ve_perms, write_parser)
+{
+ const char *file = variant->files[0];
+ char path[PATH_MAX], line[256], buf[8192];
+ size_t off;
+ int i;
+
+ ve_file(path, sizeof(path), self->ctid_a, variant->fs);
+
+ /* Comment skipped, every file and the dir granted in one write. */
+ off = snprintf(line, sizeof(line), "# a comment\n");
+ for (i = 0; i < NR_FILES; i++)
+ off += snprintf(line + off, sizeof(line) - off, "%s r\n",
+ variant->files[i]);
+ snprintf(line + off, sizeof(line) - off, "%s r", variant->dir);
+ ASSERT_EQ(write_file_at(self->cgv2_fd, path, line), 0);
+
+ /* Each file renders at its true depth, the dir with a trailing
slash. */
+ ASSERT_GT(read_file_at(self->cgv2_fd, path, buf, sizeof(buf)), 0);
+ for (i = 0; i < NR_FILES; i++) {
+ snprintf(line, sizeof(line), "%s r\n", variant->files[i]);
+ EXPECT_NE(strstr(buf, line), NULL);
+ }
+ snprintf(line, sizeof(line), "%s/ r\n", variant->dir);
+ EXPECT_NE(strstr(buf, line), NULL);
+
+ /* A later grant replaces the mask, and a duplicate keeps the
last op. */
+ snprintf(line, sizeof(line), "%s r\n%s w", file, file);
+ ASSERT_EQ(write_file_at(self->cgv2_fd, path, line), 0);
+ ASSERT_GT(read_file_at(self->cgv2_fd, path, buf, sizeof(buf)), 0);
+ snprintf(line, sizeof(line), "%s w\n", file);
+ EXPECT_NE(strstr(buf, line), NULL);
+ snprintf(line, sizeof(line), "%s r\n", file);
+ EXPECT_EQ(strstr(buf, line), NULL);
+
+ /* Remove succeeds, and removing again (now absent) is still a
no-op. */
+ snprintf(line, sizeof(line), "%s -", file);
+ EXPECT_EQ(write_file_at(self->cgv2_fd, path, line), 0);
+ EXPECT_EQ(write_file_at(self->cgv2_fd, path, line), 0);
+
+ /* A path with no mask is rejected. */
+ EXPECT_LT(write_file_at(self->cgv2_fd, path, file), 0);
+}
+
+/* Grant @mask on every file in one write to @path. */
+static void grant_files(int cgv2_fd, const char *path,
+ const char *const files[], const char *mask)