Files
DroidDeck/tools/tests/test_openat2_preload.py
MaxsTechReview f27d0b006f perf: Wine's write watches stop costing a signal per page on arm64 (#1333)
Wine asks for userfaultfd with the i386 syscall number on arm64, so it
never gets one and falls back to mprotect and a SIGSEGV per first write,
which under proot is a ptrace stop each time. libblsession hands it the
real userfaultfd and, where Android refuses the PAGEMAP_SCAN ioctl, answers
it from /proc/self/pagemap and UFFDIO_WRITEPROTECT.
2026-10-22 19:53:12 +00:00

319 lines
14 KiB
Python

import errno
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload"
FASTPATH = Path(__file__).resolve().parents[1] / "proot/fastpath/fastpath.c"
# Opens each "<dirfd>\t<flags>\t<path>" case with openat2(RESOLVE_IN_ROOT) as FEX does: first through the kernel's own
# openat2 on argv[1] when it has one, then, with openat2 failing ENOSYS as under proot, through the preload on argv[2].
PROGRAM = r"""
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>
#ifndef SYS_openat2
#define SYS_openat2 437
#endif
struct how { uint64_t flags, mode, resolve; };
static char cases[256][512];
static int count;
static void run(long (*call)(long, ...), const char *tree) {
int root = open(tree, O_PATH | O_DIRECTORY | O_CLOEXEC), outer = open("/", O_PATH | O_DIRECTORY | O_CLOEXEC);
for (int i = 0; i < count; i++) {
char *flags = strchr(cases[i], '\t') + 1, *path = strchr(flags, '\t') + 1;
struct how h = {strtoull(flags, NULL, 0), 0, 0x10};
if (h.flags & O_CREAT) h.mode = 0644;
long fd = call(SYS_openat2, (long)(cases[i][0] == 'o' ? outer : root), (long)path, (long)&h, (long)sizeof(h));
struct stat st;
if (fd < 0) {
printf("ERR %d\n", errno);
} else {
fstat((int)fd, &st);
printf("OK %llu %llu\n", (unsigned long long)st.st_dev, (unsigned long long)st.st_ino);
close((int)fd);
}
}
}
int main(int argc, char **argv) {
if (argc < 3) return 2;
while (count < 256 && fgets(cases[count], sizeof(cases[count]), stdin)) {
cases[count][strcspn(cases[count], "\n")] = 0;
count++;
}
long (*kernel)(long, ...) = (long (*)(long, ...))dlsym(dlopen("libc.so.6", RTLD_LAZY | RTLD_NOLOAD), "syscall");
struct how probe = {O_RDONLY, 0, 0x10};
long fd = kernel(SYS_openat2, (long)AT_FDCWD, (long)".", (long)&probe, (long)sizeof(probe));
if (fd >= 0) {
close((int)fd);
printf("kernel\n");
run(kernel, argv[1]);
struct sock_filter filter[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_openat2, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | ENOSYS),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = {sizeof(filter) / sizeof(filter[0]), filter};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) {
perror("seccomp");
return 3;
}
}
printf("preload\n");
run(syscall, argv[2]);
return 0;
}
"""
COUNTER = r"""
#define _GNU_SOURCE
#include <dlfcn.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
static int opens, stats, links;
int openat(int dirfd, const char *path, int flags, ...) {
va_list args;
va_start(args, flags);
mode_t mode = va_arg(args, int);
va_end(args);
opens++;
return ((int (*)(int, const char *, int, ...))dlsym(RTLD_NEXT, "openat"))(dirfd, path, flags, mode);
}
int fstatat(int dirfd, const char *path, struct stat *st, int flags) {
stats++;
return ((int (*)(int, const char *, struct stat *, int))dlsym(RTLD_NEXT, "fstatat"))(dirfd, path, st, flags);
}
int fstatat64(int dirfd, const char *path, struct stat64 *st, int flags) {
stats++;
return ((int (*)(int, const char *, struct stat64 *, int))dlsym(RTLD_NEXT, "fstatat64"))(dirfd, path, st, flags);
}
ssize_t readlinkat(int dirfd, const char *path, char *buf, size_t size) {
links++;
return ((ssize_t (*)(int, const char *, char *, size_t))dlsym(RTLD_NEXT, "readlinkat"))(dirfd, path, buf, size);
}
__attribute__((destructor)) static void report(void) {
fprintf(stderr, "calls %d %d %d\n", opens, stats, links);
}
"""
RAW_OPEN = r"""
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>
int main(int argc, char **argv) {
char text[16] = "";
long fd = syscall(SYS_openat, AT_FDCWD, argv[1], O_RDONLY | O_CLOEXEC, 0);
if (fd < 0 || read((int)fd, text, sizeof(text) - 1) < 0) return 1;
printf("%s\n", text);
return syscall(SYS_openat, AT_FDCWD, argv[2], O_RDONLY | O_CLOEXEC, 0) == -1 ? 0 : 1;
}
"""
O_PATH = getattr(os, "O_PATH", 0o10000000)
# (dirfd: r = the tree, o = "/"), flags, path, expected: a path in the tree, an errno name, or "proc" for a file outside it.
CASES = [
("r", os.O_RDONLY, "etc/alternatives/hn", "etc/hostname"),
("r", os.O_RDONLY, "/lib64/x86_64-linux-gnu/libfoo.so.1", "usr/lib/x86_64-linux-gnu/libfoo.so.1.2"),
("r", os.O_RDONLY, "lib/x86_64-linux-gnu/libfoo.so.1", "usr/lib/x86_64-linux-gnu/libfoo.so.1.2"),
("r", os.O_RDONLY, "etc/escape", "etc/hostname"),
("r", os.O_RDONLY, "dirlink/../etc/hostname", "etc/hostname"),
("r", os.O_RDONLY, "rootlink/rootlink//etc/./hostname", "etc/hostname"),
("r", os.O_RDONLY, "../../etc/hostname", "etc/hostname"),
("r", os.O_RDONLY | os.O_DIRECTORY, "/", "."),
("r", os.O_RDONLY | os.O_DIRECTORY, "usr/../..", "."),
("r", os.O_RDONLY, "chain/l2", "chain/end"),
("r", os.O_RDONLY, "chain/l1", "ELOOP"),
("r", os.O_RDONLY, "loop/a/x", "ELOOP"),
("r", os.O_RDONLY | os.O_NOFOLLOW, "etc/alternatives/hn", "ELOOP"),
("r", O_PATH | os.O_NOFOLLOW, "etc/alternatives/hn", "etc/alternatives/hn"),
("r", os.O_RDONLY | os.O_NOFOLLOW, "lib/x86_64-linux-gnu/libfoo.so.1.2", "usr/lib/x86_64-linux-gnu/libfoo.so.1.2"),
("r", os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY, "dirlink/", "usr"),
("r", os.O_RDONLY, "file/", "ENOTDIR"),
("r", os.O_RDONLY, "file/../etc/hostname", "ENOTDIR"),
("r", os.O_RDONLY, "nonexist/../etc/hostname", "ENOENT"),
("r", os.O_RDWR | os.O_CREAT | os.O_EXCL, "dangling", "EEXIST"),
("r", os.O_RDWR | os.O_CREAT, "dangling", "usr/newfile"),
("r", os.O_RDWR | os.O_CREAT, "missing/newfile", "ENOENT"),
("r", os.O_RDWR | os.O_CREAT, "newdir/", "EISDIR"),
("o", os.O_RDONLY | os.O_NONBLOCK, "proc/self/fd/0", "EXDEV"),
("o", os.O_RDONLY, "proc/self/exe", "EXDEV"),
("o", os.O_RDONLY, "proc/self/cwd/x", "EXDEV"),
("o", os.O_RDONLY, "proc/self/status", "proc"),
]
# Only the preload: an absolute link to a path missing in the tree but present outside it, as droiddeck-fex's rootfs has.
HOST_CASES = [
("r", os.O_RDONLY, "lib/x86_64-linux-gnu/libbar.so.2", "host:libbar.so.2"),
("r", os.O_RDONLY, "hostdir/sub/data", "host:sub/data"),
("r", os.O_RDONLY | os.O_DIRECTORY, "hostdir/sub", "host:sub"),
("r", os.O_RDONLY, "etc/alternatives/hn", "etc/hostname"),
("r", os.O_RDONLY, "hostdir/missing", "ENOENT"),
]
def make_tree(root, outside):
for folder in ("usr/lib/x86_64-linux-gnu", "etc/alternatives", "loop", "chain"):
(root / folder).mkdir(parents=True)
(root / "usr/lib/x86_64-linux-gnu/libfoo.so.1.2").write_text("x86")
(root / "etc/hostname").write_text("inside")
(root / "file").write_text("f")
(root / "chain/end").write_text("end")
links = {"usr/lib/x86_64-linux-gnu/libfoo.so.1": "libfoo.so.1.2", "lib": "usr/lib", "lib64": "/usr/lib",
"etc/alternatives/hn": "/etc/hostname", "etc/escape": "../../../../../../etc/hostname", "loop/a": "b",
"loop/b": "a", "dirlink": "/usr", "rootlink": "/", "dangling": "/usr/newfile"}
links.update({"chain/l%d" % i: ("l%d" % (i + 1) if i < 41 else "end") for i in range(1, 42)})
links.update({"usr/lib/x86_64-linux-gnu/libbar.so.2": str(outside / "libbar.so.2"), "hostdir": str(outside)})
for link, target in links.items():
os.symlink(target, root / link)
def make_outside(outside):
(outside / "sub").mkdir(parents=True)
(outside / "libbar.so.2").write_text("host")
(outside / "sub/data").write_text("host")
def labels(lines, root, outside):
inodes = {(os.lstat(root).st_dev, os.lstat(root).st_ino): "."}
for base, prefix in ((root, ""), (outside, "host:")):
for folder, dirs, files in os.walk(base):
for name in dirs + files:
st = os.lstat(os.path.join(folder, name))
inodes[(st.st_dev, st.st_ino)] = prefix + os.path.relpath(os.path.join(folder, name), base)
out = []
for line in lines:
kind, *values = line.split()
if kind == "OK":
out.append(inodes.get((int(values[0]), int(values[1])), "proc"))
else:
out.append(errno.errorcode[int(values[0])])
return out
@unittest.skipUnless(shutil.which("cc") and os.path.isdir("/proc/self"), "needs cc and procfs")
class Openat2PreloadTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = Path(tempfile.mkdtemp())
cls.lib = cls.tmp / "libopenat2.so"
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-pthread", "-o", str(cls.lib),
str(PRELOAD / "robust.c"), str(PRELOAD / "fsync.c"), str(PRELOAD / "noexec.c"), str(PRELOAD / "writewatch.c"), "-ldl"], check=True)
source = cls.tmp / "openat2.c"
source.write_text(PROGRAM)
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-o", str(cls.tmp / "openat2"), str(source), "-ldl"], check=True)
cls.trees = (cls.tmp / "kernel", cls.tmp / "preload")
cls.outside = cls.tmp / "outside"
make_outside(cls.outside)
for tree in cls.trees:
make_tree(tree, cls.outside)
every = CASES + HOST_CASES
cases = "".join("%s\t%d\t%s\n" % case[:3] for case in every)
result = subprocess.run([str(cls.tmp / "openat2"), *map(str, cls.trees)], input=cases, capture_output=True, text=True,
env=dict(os.environ, LD_PRELOAD=str(cls.lib)), timeout=60)
if result.returncode != 0:
raise AssertionError(result.stdout + result.stderr)
lines = result.stdout.splitlines()
cls.kernel = labels(lines[1:1 + len(CASES)], cls.trees[0], cls.outside) if lines[0] == "kernel" else None
cls.preload = labels(lines[-len(every):], cls.trees[1], cls.outside)
counter = cls.tmp / "counter.c"
counter.write_text(COUNTER)
(cls.tmp / "fp").mkdir()
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-o", str(cls.tmp / "fp/libblfastpath.so"),
str(counter), "-ldl"], check=True)
tree = cls.tmp / "counted"
make_tree(tree, cls.outside)
counted = subprocess.run([str(cls.tmp / "openat2"), str(tree), str(tree)], input=cases, capture_output=True,
text=True, env=dict(os.environ, LD_PRELOAD="%s:%s" % (cls.lib, cls.tmp / "fp/libblfastpath.so")),
timeout=60)
if counted.returncode != 0:
raise AssertionError(counted.stdout + counted.stderr)
cls.counted = labels(counted.stdout.splitlines()[-len(every):], tree, cls.outside)
cls.calls = [int(n) for n in counted.stderr.split("calls ")[-1].split()]
raw = cls.tmp / "raw_open.c"
raw.write_text(RAW_OPEN)
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-o", str(cls.tmp / "raw_open"), str(raw)], check=True)
cls.raw = subprocess.run([str(cls.tmp / "raw_open"), str(tree / "etc/hostname"), str(tree / "missing")],
capture_output=True, text=True, timeout=60,
env=dict(os.environ, LD_PRELOAD="%s:%s" % (cls.lib, cls.tmp / "fp/libblfastpath.so")))
(cls.tmp / "real").mkdir()
subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-pthread", "-o",
str(cls.tmp / "real/libblfastpath.so"), str(FASTPATH), "-ldl"], check=True)
shutil.copy(cls.tmp / "raw_open", cls.tmp / "FEX")
try:
cls.fex = subprocess.run([str(cls.tmp / "FEX"), str(tree / "etc/hostname"), str(tree / "missing")],
capture_output=True, text=True, timeout=20,
env=dict(os.environ, PROOT_FP_ROOT="/", PROOT_FP_KEY="test",
LD_PRELOAD="%s:%s" % (cls.lib, cls.tmp / "real/libblfastpath.so")))
except subprocess.TimeoutExpired:
cls.fex = None
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp, ignore_errors=True)
def test_preload_resolves_in_the_root_without_openat2(self):
for case, got in zip(CASES, self.preload):
self.assertEqual(got, case[3], case)
self.assertTrue((self.trees[1] / "usr/newfile").exists())
def test_preload_finishes_a_link_to_a_path_only_outside_the_root_there(self):
for case, got in zip(HOST_CASES, self.preload[len(CASES):]):
self.assertEqual(got, case[3], case)
def test_the_walk_asks_the_fast_path_when_it_is_loaded(self):
self.assertEqual(self.counted, self.preload)
self.assertTrue(all(n > 0 for n in self.calls), self.calls)
def test_a_raw_openat_syscall_asks_the_fast_path_too(self):
self.assertEqual(self.raw.returncode, 0, self.raw.stderr)
self.assertEqual(self.raw.stdout, "inside\n")
self.assertEqual(self.raw.stderr.split("calls ")[-1].split(), ["2", "0", "0"])
def test_a_first_raw_openat_in_fex_does_not_wait_on_the_fast_path_starting(self):
self.assertIsNotNone(self.fex, "hung")
self.assertEqual(self.fex.returncode, 0, self.fex.stderr)
self.assertEqual(self.fex.stdout, "inside\n")
def test_the_kernel_agrees(self):
if self.kernel is None:
self.skipTest("this kernel or sandbox has no openat2")
for case, got in zip(CASES, self.kernel):
self.assertEqual(got, case[3], case)
if __name__ == "__main__":
unittest.main()