[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