[Devel] [PATCH vz10 v3 9/9] selftests/ve: add ve_perms test for proc and sysfs
Vasileios Almpanis
vasileios.almpanis at virtuozzo.com
Fri Jul 17 13:34:25 MSK 2026
On 7/17/26 1:25 AM, Mirian Shilakadze wrote:
> 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 | 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)
I would suggest to change the return type to int here and ASSERT_EQ in
callers
that files were really granted before the rest of the test proceeds.
> +{
> + 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);
> + write_file_at(cgv2_fd, path, line);
> +}
> +
> +/* Nonzero iff every file and the directory exist under @prefix on this host. */
> +static int enf_nodes_present(const char *prefix, const char *const files[],
> + const char *dir)
> +{
> + char probe[PATH_MAX];
> + int i;
> +
> + for (i = 0; i < NR_FILES; i++) {
> + snprintf(probe, sizeof(probe), "%s/%s", prefix, files[i]);
> + if (access(probe, F_OK) != 0)
> + return 0;
> + }
> + snprintf(probe, sizeof(probe), "%s/%s", prefix, dir);
> + return access(probe, F_OK) == 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], line[128], absent[128];
> + int i;
> +
> + if (!enf_nodes_present(variant->dir_prefix, variant->files, variant->dir))
> + SKIP(return, "enforcement nodes absent under %s", variant->dir_prefix);
> +
> + ve_file(path, sizeof(path), self->ctid_a, variant->fs);
> +
> + grant_files(self->cgv2_fd, path, variant->files, "r");
> + 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);
> +
> + snprintf(line, sizeof(line), "%s rx", variant->dir);
> + ASSERT_EQ(write_file_at(self->cgv2_fd, path, line), 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], line[128], absent[128];
> + int i;
> +
> + if (!enf_nodes_present(variant->dir_prefix, variant->files, variant->dir))
> + SKIP(return, "enforcement nodes absent under %s", variant->dir_prefix);
> +
> + ve_file(path, sizeof(path), self->ctid_a, variant->fs);
> +
> + /* 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. */
> + grant_files(self->cgv2_fd, path, variant->files, "w");
> + 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. */
> + snprintf(line, sizeof(line), "%s r", variant->dir);
> + ASSERT_EQ(write_file_at(self->cgv2_fd, path, line), 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 dddb09e948f9..0c6e54f0241c 100644
> --- a/tools/testing/selftests/ve/ve_selftest.h
> +++ b/tools/testing/selftests/ve/ve_selftest.h
> @@ -45,6 +45,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)
> {
--
Best regards, Vasileios Almpanis
Software Developer, Virtuozzo.
More information about the Devel
mailing list