On 7/17/26 2:34 PM, Vasileios Almpanis wrote:

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 <[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)
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.

Agreed, will be done in v4.

+{
+    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, Mirian Shilakadze
Linux C Virtualization Developer, Virtuozzo.

_______________________________________________
Devel mailing list
[email protected]
https://lists.openvz.org/mailman/listinfo/devel

Reply via email to