Files
DroidDeck/tools/tests/test_binfmt_preload.py
MaxsTechReview d9cd0d2cc8 Desktop: run programs, scripts and AppImages; Switch to Desktop without Deck mode (#549)
* Run programs, scripts and AppImages from the desktop

Windows programs open with the Proton Steam has chosen, and a Steam game's own
launch program is handed to Steam. x86-64 Linux programs and game scripts run
under FEX, and an exec the kernel refuses goes to droiddeck-open through
libblsession's binfmt.c. Programs on noexec shared storage pass KIO's checks; a
folder of Linux programs there, and sharun AppImages, are added by the app once
and run from the copy. Executable scripts run without KIO's prompt, mpv plays
on the software desktop, FEXServer's socket moves under XDG_RUNTIME_DIR, and
Proton runtimes no longer show up as games.

* Switch to Desktop from Big Picture without Deck mode

Big Picture's Switch to Desktop asks the client's SteamOS manager, which the
client only has when started as SteamOS (Deck mode). Without it the request
failed and the screen stayed on "Switching to Desktop..." for good. The live
compat helper now sees the request on its way to the client and hands it to
steamos-session-select, which opens the KDE desktop as Deck mode already did.

* Run x86 programs from shared storage where they lie

FEX asks for the guest's PROT_EXEC when it maps a program or library, which
the kernel refuses on noexec shared storage; FEX never runs those pages
itself, so libblsession maps them without it in FEX's processes. An x86
game, its libraries and its start script now run in place instead of being
copied in; only native code on noexec storage is still copied.

A Steam library's games are never copied, and a Linux game's launch program
opened from the desktop goes to Steam like a Windows one.
2026-10-10 19:12:20 -04:00

161 lines
6.6 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"
PROGRAM = r"""
#define _GNU_SOURCE
#include <errno.h>
#include <spawn.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
extern char **environ;
int main(int argc, char **argv) {
if (argc < 3) return 2;
const char *how = argv[1], *file = argv[2];
char **args = argv + 2;
if (!strcmp(how, "access")) {
int result = access(file, X_OK);
printf("access %d %d\n", result, result ? errno : 0);
return 0;
}
if (!strcmp(how, "spawn") || !strcmp(how, "spawnp")) {
pid_t pid;
int error = how[5] ? posix_spawnp(&pid, file, NULL, NULL, args, environ) : posix_spawn(&pid, file, NULL, NULL, args, environ);
if (error) { printf("errno %d\n", error); return 3; }
int status;
waitpid(pid, &status, 0);
printf("exit %d\n", WEXITSTATUS(status));
return 0;
}
if (!strcmp(how, "execve")) execve(file, args, environ);
else if (!strcmp(how, "execv")) execv(file, args);
else if (!strcmp(how, "execvp")) execvp(file, args);
else if (!strcmp(how, "execvpe")) execvpe(file, args, environ);
printf("errno %d\n", errno);
return 3;
}
"""
# Shell builtins only: anything it ran would be an x86 program, handed back to it.
HANDLER = "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HANDLER_LOG\"\nexit 7\n"
@unittest.skipUnless(shutil.which("gcc") and os.uname().machine == "x86_64", "needs gcc on an x86-64 host")
class BinfmtPreloadTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = Path(tempfile.mkdtemp())
cls.handler = cls.tmp / "handler"
cls.handler.write_text(HANDLER)
cls.handler.chmod(0o755)
cls.library = cls.tmp / "libbinfmt.so"
subprocess.run(["gcc", "-shared", "-fPIC", "-O2", "-Wall", "-Wextra", "-Werror", "-DBINFMT_TEST",
'-DBINFMT_HANDLER="%s"' % cls.handler, "-o", str(cls.library),
"-pthread"] + [str(PRELOAD / name) for name in ("binfmt.c", "net.c", "udevmon.c", "pathcache.c")] + ["-ldl"],
check=True)
source = cls.tmp / "program.c"
source.write_text(PROGRAM)
cls.program = cls.tmp / "program"
subprocess.run(["gcc", "-O2", "-o", str(cls.program), str(source)], check=True)
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp, True)
def setUp(self):
self.dir = Path(tempfile.mkdtemp(dir=self.tmp))
self.log = self.dir / "handler.log"
def file(self, name, data, mode=0o755):
path = self.dir / name
path.write_bytes(data)
path.chmod(mode)
return path
def x86(self, name="tool", mode=0o755):
path = self.dir / name
shutil.copyfile(shutil.which("true"), path)
path.chmod(mode)
return path
def run_program(self, how, target, *args, desktop=True):
env = {"PATH": "%s:/usr/bin:/bin" % self.dir, "LD_PRELOAD": str(self.library), "HANDLER_LOG": str(self.log)}
if desktop:
env["BL_DESKTOP"] = "1"
done = subprocess.run([str(self.program), how, str(target)] + list(args), env=env,
stdout=subprocess.PIPE, text=True, timeout=30)
handed = self.log.read_text().splitlines() if self.log.exists() else None
return done.returncode, done.stdout.strip(), handed
def test_an_x86_program_goes_to_the_handler_before_it_is_executed(self):
tool = self.x86()
for how in ("execve", "execv"):
self.log.unlink(missing_ok=True)
self.assertEqual(self.run_program(how, tool, "a", "b c"), (7, "", ["--exec", str(tool), "a", "b c"]))
def test_outside_the_desktop_nothing_changes(self):
self.assertEqual(self.run_program("execve", self.x86(), desktop=False), (0, "", None))
def test_a_program_without_execute_permission_stays_refused(self):
tool = self.x86(mode=0o644)
self.assertEqual(self.run_program("execve", tool), (3, "errno %d" % errno.EACCES, None))
def test_a_native_program_the_kernel_cannot_run_keeps_its_error(self):
arm64 = bytearray(64)
arm64[0:4], arm64[4], arm64[5], arm64[18] = b"\x7fELF", 2, 1, 183
program = self.file("arm64", bytes(arm64))
self.assertEqual(self.run_program("execve", program), (3, "errno %d" % errno.ENOEXEC, None))
def test_a_script_runs_as_itself(self):
script = self.file("script.sh", b"#!/bin/sh\nexit 5\n")
self.assertEqual(self.run_program("execve", script), (5, "", None))
def test_a_windows_program_found_on_path_goes_to_the_handler(self):
program = self.file("setup.exe", b"MZ" + bytes(62))
for how in ("execvp", "execvpe"):
self.log.unlink(missing_ok=True)
self.assertEqual(self.run_program(how, "setup.exe", "/S"), (7, "", ["--exec", str(program), "/S"]))
def test_a_spawned_x86_program_goes_to_the_handler(self):
tool = self.x86()
self.assertEqual(self.run_program("spawn", tool, "x"), (0, "exit 7", ["--exec", str(tool), "x"]))
self.log.unlink()
self.assertEqual(self.run_program("spawnp", "tool", "y"), (0, "exit 7", ["--exec", str(tool), "y"]))
def test_access_keeps_the_kernels_answer_off_noexec_storage(self):
self.assertEqual(self.run_program("access", self.x86(mode=0o644)), (0, "access -1 %d" % errno.EACCES, None))
self.assertEqual(self.run_program("access", self.x86()), (0, "access 0 0", None))
def test_a_program_on_noexec_storage_passes_access(self):
storage = next((d for d in (os.environ.get("XDG_RUNTIME_DIR"), "/dev/shm", "/run/user/%d" % os.getuid())
if d and os.path.isdir(d) and os.access(d, os.W_OK) and os.statvfs(d).f_flag & os.ST_NOEXEC), None)
if storage is None:
self.skipTest("no writable noexec mount here")
folder = Path(tempfile.mkdtemp(dir=storage))
self.addCleanup(shutil.rmtree, folder, True)
tool = folder / "tool"
shutil.copyfile(shutil.which("true"), tool)
notes = folder / "notes.txt"
notes.write_text("hello\n")
self.assertEqual(self.run_program("access", tool), (0, "access 0 0", None))
self.assertEqual(self.run_program("access", notes), (0, "access -1 %d" % errno.EACCES, None))
self.assertEqual(self.run_program("access", tool, desktop=False), (0, "access -1 %d" % errno.EACCES, None))
def test_a_spawned_native_script_is_left_alone(self):
self.file("script.sh", b"#!/bin/sh\nexit 5\n")
self.assertEqual(self.run_program("spawnp", "script.sh"), (0, "exit 5", None))
if __name__ == "__main__":
unittest.main()