[Devel] [PATCH vz10 v4 9/9] selftests/ve: add ve_perms test for proc and sysfs
Mirian Shilakadze
mirian.shilakadze at virtuozzo.com
Tue Jul 21 22:34:51 MSK 2026
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 <mirian.shilakadze at virtuozzo.com>
---
tools/testing/selftests/ve/.gitignore | 1 +
tools/testing/selftests/ve/Makefile | 1 +
tools/testing/selftests/ve/ve_perms_test.c | 415 +++++++++++++++++++++
tools/testing/selftests/ve/ve_selftest.h | 26 ++
4 files changed, 443 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..4522950c17f2
--- /dev/null
+++ b/tools/testing/selftests/ve/ve_perms_test.c
@@ -0,0 +1,415 @@
+// 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);
+}
+
+/* Set @entry to @mask in one write to @path (@mask may be "-" to remove). */
+static int set_entry(int cgv2_fd, const char *path, const char *entry,
+ const char *mask)
+{
+ char line[256];
+
+ snprintf(line, sizeof(line), "%s %s", entry, mask);
+ return write_file_at(cgv2_fd, path, line);
+}
+
+/* Set every file to @mask in one write to @path. */
+static int set_entries(int cgv2_fd, const char *path,
+ const char *const files[], const char *mask)
+{
+ char line[256];
+ size_t off = 0;
+ int i;
+
+ for (i = 0; i < NR_FILES; i++)
+ off += snprintf(line + off, sizeof(line) - off, "%s %s\n",
+ files[i], mask);
+ return write_file_at(cgv2_fd, path, line);
+}
+
+/* Nonzero iff @entry exists under @prefix on this host. */
+static int entry_present(const char *prefix, const char *entry)
+{
+ char probe[PATH_MAX];
+
+ snprintf(probe, sizeof(probe), "%s/%s", prefix, entry);
+ return access(probe, F_OK) == 0;
+}
+
+/* Nonzero iff every file and the directory exist under @prefix on this host. */
+static int entries_present(const char *prefix, const char *const files[],
+ const char *dir)
+{
+ int i;
+
+ for (i = 0; i < NR_FILES; i++)
+ if (!entry_present(prefix, files[i]))
+ return 0;
+ return entry_present(prefix, dir);
+}
+
+/*
+ * Nonzero iff @entry is already granted to every VE by the ve0 default map
+ * (ve.default_<fs>_permissions). The listing can be large (the sysfs set has
+ * an entry per registered node), so read it one line at a time.
+ */
+static int default_granted(int cgv2_fd, const char *fs, const char *entry)
+{
+ char name[32], line[PATH_MAX + 16];
+ int fd, found = 0;
+ FILE *f;
+
+ snprintf(name, sizeof(name), "ve.default_%s_permissions", fs);
+ fd = openat(cgv2_fd, name, O_RDONLY);
+ if (fd < 0)
+ return 0;
+ f = fdopen(fd, "r");
+ if (!f) {
+ close(fd);
+ return 0;
+ }
+ while (fgets(line, sizeof(line), f)) {
+ if (strstr(line, entry)) {
+ found = 1;
+ break;
+ }
+ }
+ fclose(f);
+ return found;
+}
+
+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];
+
+ if (!entry_present(variant->dir_prefix, file))
+ SKIP(return, "%s absent under %s", file, variant->dir_prefix);
+
+ 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);
+
+ ASSERT_EQ(set_entry(self->cgv2_fd, path, file, "r"), 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);
+
+ ASSERT_EQ(set_entry(self->cgv2_fd, path, file, "rwx"), 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);
+
+ ASSERT_EQ(set_entry(self->cgv2_fd, path, file, "-"), 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], buf[8192];
+
+ if (!entry_present(variant->dir_prefix, file))
+ SKIP(return, "%s absent under %s", file, variant->dir_prefix);
+
+ ve_file(pa, sizeof(pa), self->ctid_a, variant->fs);
+ ve_file(pb, sizeof(pb), self->ctid_b, variant->fs);
+
+ ASSERT_EQ(set_entry(self->cgv2_fd, pa, file, "r"), 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(set_entry(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];
+
+ if (!entry_present(variant->dir_prefix, file))
+ SKIP(return, "%s absent under %s", file, variant->dir_prefix);
+
+ ve_file(path, sizeof(path), self->ctid_a, variant->fs);
+
+ /* A single bit. */
+ ASSERT_EQ(set_entry(self->cgv2_fd, path, file, "w"), 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. */
+ ASSERT_EQ(set_entry(self->cgv2_fd, path, file, "xwr"), 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. */
+ EXPECT_LT(set_entry(self->cgv2_fd, path, file, "z"), 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;
+
+ if (!entries_present(variant->dir_prefix, variant->files, variant->dir))
+ SKIP(return, "entries absent under %s", variant->dir_prefix);
+
+ 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. */
+ EXPECT_EQ(set_entry(self->cgv2_fd, path, file, "-"), 0);
+ EXPECT_EQ(set_entry(self->cgv2_fd, path, file, "-"), 0);
+
+ /* A path with no mask is rejected. */
+ EXPECT_LT(write_file_at(self->cgv2_fd, path, file), 0);
+}
+
+/*
+ * Enforcement, allowed side: from inside a VE, granting r lets each file be
+ * read, and granting x on the directory lets it be traversed (the lookup then
+ * fails ENOENT on the missing child, not EACCES).
+ */
+TEST_F(ve_perms, enforce_allows)
+{
+ char path[PATH_MAX], absent[128];
+ int i;
+
+ if (!entries_present(variant->dir_prefix, variant->files, variant->dir))
+ SKIP(return, "entries absent under %s", variant->dir_prefix);
+
+ ve_file(path, sizeof(path), self->ctid_a, variant->fs);
+
+ ASSERT_EQ(set_entries(self->cgv2_fd, path, variant->files, "r"), 0);
+ for (i = 0; i < NR_FILES; i++)
+ EXPECT_EQ(ve_open_rel(self->cgv2_fd, self->ctid_a,
+ variant->dir_prefix, variant->files[i],
+ O_RDONLY), 0);
+
+ ASSERT_EQ(set_entry(self->cgv2_fd, path, variant->dir, "rx"), 0);
+ snprintf(absent, sizeof(absent), "%s/ve_perms_absent", variant->dir);
+ EXPECT_EQ(ve_open_rel(self->cgv2_fd, self->ctid_a, variant->dir_prefix,
+ absent, O_RDONLY), ENOENT);
+}
+
+/*
+ * Enforcement, denied side: an ungranted file is hidden, a file granted only w
+ * cannot be read (the r bit is required and distinct), and a directory granted
+ * only r cannot be traversed (the x bit is required).
+ */
+TEST_F(ve_perms, enforce_denies)
+{
+ char path[PATH_MAX], absent[128];
+ int i;
+
+ if (!entries_present(variant->dir_prefix, variant->files, variant->dir))
+ SKIP(return, "entries absent under %s", variant->dir_prefix);
+
+ ve_file(path, sizeof(path), self->ctid_a, variant->fs);
+
+ /*
+ * The hiding checks below only hold if the files are not granted to
+ * every VE by the ve0 default map. Skip if the host already lists one.
+ */
+ for (i = 0; i < NR_FILES; i++)
+ if (default_granted(self->cgv2_fd, variant->fs, variant->files[i]))
+ SKIP(return, "%s granted by default", variant->files[i]);
+
+ /* Ungranted: every file is hidden (the open fails). */
+ for (i = 0; i < NR_FILES; i++)
+ EXPECT_NE(ve_open_rel(self->cgv2_fd, self->ctid_a,
+ variant->dir_prefix, variant->files[i],
+ O_RDONLY), 0);
+
+ /* Granted w only: visible but not readable. */
+ ASSERT_EQ(set_entries(self->cgv2_fd, path, variant->files, "w"), 0);
+ for (i = 0; i < NR_FILES; i++)
+ EXPECT_EQ(ve_open_rel(self->cgv2_fd, self->ctid_a,
+ variant->dir_prefix, variant->files[i],
+ O_RDONLY), EACCES);
+
+ /* Directory granted r only: cannot be traversed. */
+ ASSERT_EQ(set_entry(self->cgv2_fd, path, variant->dir, "r"), 0);
+ snprintf(absent, sizeof(absent), "%s/ve_perms_absent", variant->dir);
+ EXPECT_EQ(ve_open_rel(self->cgv2_fd, self->ctid_a, variant->dir_prefix,
+ absent, O_RDONLY), EACCES);
+}
+
+TEST_HARNESS_MAIN
diff --git a/tools/testing/selftests/ve/ve_selftest.h b/tools/testing/selftests/ve/ve_selftest.h
index 3ca1c41e0d6c..69c0a52dd7ef 100644
--- a/tools/testing/selftests/ve/ve_selftest.h
+++ b/tools/testing/selftests/ve/ve_selftest.h
@@ -43,6 +43,32 @@ static inline int write_file_at(int dirfd, const char *path, const char *val)
return (ret == (int)len) ? 0 : -1;
}
+static inline int read_file_at(int dirfd, const char *path, char *buf,
+ size_t buflen)
+{
+ size_t off = 0;
+ int fd, ret;
+
+ fd = openat(dirfd, path, O_RDONLY);
+ if (fd < 0)
+ return -1;
+
+ /* A seq file may hand back its contents over several reads. */
+ while (off < buflen - 1) {
+ ret = read(fd, buf + off, buflen - 1 - off);
+ if (ret < 0) {
+ close(fd);
+ return -1;
+ }
+ if (ret == 0)
+ break;
+ off += ret;
+ }
+ close(fd);
+ buf[off] = '\0';
+ return off;
+}
+
static inline int read_u64_at(int dirfd, const char *path,
unsigned long long *out)
{
--
2.43.0
More information about the Devel
mailing list