aboutsummaryrefslogtreecommitdiffhomepage
path: root/cmd
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2026-10-06 22:23:20 +0900
committerOphestra <cat@gensokyo.uk>2026-10-06 23:09:19 +0900
commita9e2749f6654d0aa07b274a45c9177d10323f80a (patch)
tree291f5b23a67af036cbe5d374b3d3fb0240fc438c /cmd
parent19f36491f2e2a5029ac396c10408d653cad6c81b (diff)
internal/testsuite: move from test
This structure is a lot less clumsy than the old nix-centric layout. Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'cmd')
-rw-r--r--cmd/hakurei/testsuite/configuration.nix252
-rw-r--r--cmd/hakurei/testsuite/default.nix81
-rw-r--r--cmd/hakurei/testsuite/flake.lock49
-rw-r--r--cmd/hakurei/testsuite/flake.nix75
-rw-r--r--cmd/hakurei/testsuite/hsu.nix23
-rw-r--r--cmd/hakurei/testsuite/nixos.nix407
-rw-r--r--cmd/hakurei/testsuite/options.nix364
-rw-r--r--cmd/hakurei/testsuite/package.nix145
-rw-r--r--cmd/hakurei/testsuite/sandbox/main.go409
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/device.go134
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/mapuid.go124
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/pdlike.go141
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/simple.go140
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/sum.go22
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go9
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go9
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/testdata.go125
-rw-r--r--cmd/hakurei/testsuite/sandbox/testdata/tty.go145
-rw-r--r--cmd/hakurei/testsuite/sandbox/tester/main.go224
-rw-r--r--cmd/hakurei/testsuite/test.py315
-rw-r--r--cmd/sharefs/testsuite/main.go119
-rw-r--r--cmd/sharefs/testsuite/raceattr.go122
22 files changed, 3434 insertions, 0 deletions
diff --git a/cmd/hakurei/testsuite/configuration.nix b/cmd/hakurei/testsuite/configuration.nix
new file mode 100644
index 00000000..62d8239d
--- /dev/null
+++ b/cmd/hakurei/testsuite/configuration.nix
@@ -0,0 +1,252 @@
+{
+ lib,
+ pkgs,
+ config,
+ ...
+}:
+{
+ users.users = {
+ alice = {
+ isNormalUser = true;
+ description = "Alice Foobar";
+ password = "foobar";
+ uid = 1000;
+ };
+ untrusted = {
+ isNormalUser = true;
+ description = "Untrusted user";
+ password = "foobar";
+ uid = 1001;
+
+ # For deny unmapped uid test:
+ packages = [ config.environment.hakurei.package ];
+ };
+ };
+
+ home-manager.users.alice.home.stateVersion = "24.11";
+
+ # Automatically login on tty1 as a normal user:
+ services.getty.autologinUser = "alice";
+
+ security.pam.loginLimits = [
+ {
+ domain = "@users";
+ item = "rtprio";
+ type = "-";
+ value = 1;
+ }
+ ];
+
+ environment = {
+ systemPackages = with pkgs; [
+ # For D-Bus tests:
+ mako
+ libnotify
+ ];
+
+ variables = {
+ SWAYSOCK = "/tmp/sway-ipc.sock";
+ WLR_RENDERER = "pixman";
+ };
+
+ # To help with OCR:
+ etc."xdg/foot/foot.ini".text = lib.generators.toINI { } {
+ main = {
+ font = "inconsolata:size=14";
+ };
+ colors = rec {
+ foreground = "000000";
+ background = "ffffff";
+ regular2 = foreground;
+ };
+ };
+ };
+
+ fonts.packages = [ pkgs.inconsolata ];
+
+ # Automatically configure and start Sway when logging in on tty1:
+ programs.bash.loginShellInit = ''
+ if [ "$(tty)" = "/dev/tty1" ]; then
+ set -e
+
+ mkdir -p ~/.config/sway
+ (sed s/Mod4/Mod1/ /etc/sway/config &&
+ echo 'output * bg ${pkgs.nixos-artwork.wallpapers.simple-light-gray.gnomeFilePath} fill' &&
+ echo 'output Virtual-1 res 1680x1050') > ~/.config/sway/config
+
+ sway --validate
+ systemd-cat --identifier=session sway && touch /tmp/sway-exit-ok
+ fi
+ '';
+
+ programs.sway.enable = true;
+
+ # For PulseAudio tests:
+ security.rtkit.enable = true;
+ services.pipewire = {
+ enable = true;
+ alsa.enable = true;
+ alsa.support32Bit = true;
+ pulse.enable = true;
+ jack.enable = true;
+ };
+
+ virtualisation = {
+ # Hopefully reduces spurious test failures:
+ memorySize = if pkgs.stdenv.hostPlatform.is32bit then 2046 else 8192;
+
+ qemu.options = [
+ # Need to switch to a different GPU driver than the default one (-vga std) so that Sway can launch:
+ "-vga none -device virtio-gpu-pci"
+
+ # Increase Go test compiler performance:
+ "-smp 16"
+ ];
+ };
+
+ # Disk image is too small for some tests:
+ boot.tmp.useTmpfs = true;
+
+ environment.hakurei = {
+ enable = true;
+ stateDir = "/var/lib/hakurei";
+ users.alice = 0;
+
+ extraHomeConfig =
+ { config, ... }:
+ {
+ # To test merge deduplication:
+ options._hakurei.stateVersion = lib.mkOption { type = lib.types.str; };
+
+ config = {
+ home = { inherit (config._hakurei) stateVersion; };
+ _hakurei.stateVersion = "23.05";
+ };
+ };
+
+ commonPaths = [
+ {
+ type = "bind";
+ src = "/var/tmp";
+ write = true;
+ }
+ ];
+
+ apps = {
+ "cat.gensokyo.extern.bash.linger-timeout" = {
+ name = "hakurei-check-linger-timeout";
+ identity = 9999;
+ share = pkgs.bash;
+ packages = [ pkgs.bash ];
+ command = ''
+ sleep infinity & disown
+ exit
+ '';
+ wait_delay = 1;
+ enablements = {
+ wayland = false;
+ pipewire = false;
+ };
+ };
+
+ "cat.gensokyo.extern.foot.noEnablements" = {
+ name = "ne-foot";
+ identity = 1;
+ shareUid = true;
+ verbose = true;
+ share = pkgs.foot;
+ packages = with pkgs; [
+ foot
+
+ # For wayland-info:
+ wayland-utils
+ ];
+ command = "foot";
+ enablements = {
+ dbus = false;
+ pipewire = false;
+ };
+ };
+
+ "cat.gensokyo.extern.foot.noEnablements.immediate" = {
+ name = "ne-foot-immediate";
+ identity = 1;
+ shareUid = true;
+ verbose = true;
+ wait_delay = -1;
+ share = pkgs.foot;
+ packages = [ ];
+ command = "foot";
+ enablements = {
+ dbus = false;
+ pipewire = false;
+ };
+ };
+
+ "cat.gensokyo.extern.foot.pulseaudio" = {
+ name = "pa-foot";
+ identity = 2;
+ verbose = true;
+ share = pkgs.foot;
+ packages = [ pkgs.foot ];
+ command = "foot";
+ enablements.dbus = false;
+ };
+
+ "cat.gensokyo.extern.Alacritty.x11" = {
+ name = "x11-alacritty";
+ identity = 1;
+ shareUid = true;
+ verbose = true;
+ share = pkgs.alacritty;
+ packages = with pkgs; [
+ # For X11 terminal emulator:
+ alacritty
+
+ # For glinfo:
+ mesa-demos
+ ];
+ command = "alacritty";
+ enablements = {
+ wayland = false;
+ x11 = true;
+ dbus = false;
+ pipewire = false;
+ };
+ };
+
+ "cat.gensokyo.extern.foot.directWayland" = {
+ name = "da-foot";
+ identity = 4;
+ verbose = true;
+ insecureWayland = true;
+ share = pkgs.foot;
+ packages = with pkgs; [
+ foot
+
+ # For wayland-info:
+ wayland-utils
+ ];
+ command = "foot";
+ enablements = {
+ dbus = false;
+ pipewire = false;
+ };
+ };
+
+ "cat.gensokyo.extern.strace.wantFail" = {
+ name = "strace-failure";
+ identity = 5;
+ verbose = true;
+ share = pkgs.strace;
+ command = "strace true";
+ enablements = {
+ wayland = false;
+ x11 = false;
+ dbus = false;
+ pipewire = false;
+ };
+ };
+ };
+ };
+}
diff --git a/cmd/hakurei/testsuite/default.nix b/cmd/hakurei/testsuite/default.nix
new file mode 100644
index 00000000..81daa0a2
--- /dev/null
+++ b/cmd/hakurei/testsuite/default.nix
@@ -0,0 +1,81 @@
+{
+ lib,
+ testers,
+ buildFHSEnv,
+ writeShellScriptBin,
+
+ system,
+ self,
+ withRace ? false,
+}:
+
+testers.nixosTest {
+ name = "hakurei" + (if withRace then "-race" else "");
+ nodes.machine =
+ { options, pkgs, ... }:
+ let
+ fhs =
+ let
+ hakurei = options.environment.hakurei.package.default;
+ in
+ buildFHSEnv {
+ pname = "hakurei-fhs";
+ inherit (hakurei) version;
+ targetPkgs = _: hakurei.targetPkgs;
+ extraOutputsToInstall = [ "dev" ];
+ profile = ''
+ export PKG_CONFIG_PATH="/usr/share/pkgconfig:$PKG_CONFIG_PATH"
+ '';
+ };
+ in
+ {
+ environment.systemPackages = [
+ # For go tests:
+ (writeShellScriptBin "hakurei-test" ''
+ # Assert hst CGO_ENABLED=0: ${
+ with pkgs;
+ runCommand "hakurei-hst-cgo" { nativeBuildInputs = [ self.packages.${system}.hakurei.go ]; } ''
+ cp -r ${options.environment.hakurei.package.default.src} "$out"
+ chmod -R +w "$out"
+ cp ${writeText "hst_cgo_test.go" ''package hakurei_test;import("testing";"hakurei.app/hst");func TestTemplate(t *testing.T){hst.Template()}''} "$out/hst_cgo_test.go"
+ (cd "$out" && HOME="$(mktemp -d)" CGO_ENABLED=0 go test .)
+ ''
+ }
+
+ cd ${self.packages.${system}.hakurei.src}
+ ${fhs}/bin/hakurei-fhs -c \
+ 'CC="clang -O3 -Werror" go test --tags=noskip ${if withRace then "-race" else "-count 16"} ./...' \
+ &> /tmp/hakurei-test.log && \
+ touch /tmp/hakurei-test-ok
+ touch /tmp/hakurei-test-done
+ '')
+ ];
+
+ # Run with Go race detector:
+ environment.hakurei = lib.mkIf withRace rec {
+ # race detector does not support static linking
+ package = (pkgs.callPackage ./package.nix { }).overrideAttrs (previousAttrs: {
+ env = previousAttrs.env // {
+ GOFLAGS = previousAttrs.env.GOFLAGS + " -race";
+ };
+ });
+ hsuPackage = options.environment.hakurei.hsuPackage.default.override { hakurei = package; };
+ };
+
+ imports = [
+ ./configuration.nix
+
+ self.nixosModules.hakurei
+ self.inputs.home-manager.nixosModules.home-manager
+ ];
+ };
+
+ # adapted from nixos sway integration tests
+
+ # testScriptWithTypes:49: error: Cannot call function of unknown type
+ # (machine.succeed if succeed else machine.execute)(
+ # ^
+ # Found 1 error in 1 file (checked 1 source file)
+ skipTypeCheck = true;
+ testScript = builtins.readFile ./test.py;
+}
diff --git a/cmd/hakurei/testsuite/flake.lock b/cmd/hakurei/testsuite/flake.lock
new file mode 100644
index 00000000..5537506a
--- /dev/null
+++ b/cmd/hakurei/testsuite/flake.lock
@@ -0,0 +1,49 @@
+{
+ "nodes": {
+ "home-manager": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1787146702,
+ "narHash": "sha256-YbRcLdU/yK4gWsQg7V8WTKZHfXL33g8+wSFUX3wyevs=",
+ "owner": "nix-community",
+ "repo": "home-manager",
+ "rev": "173b7e8d40fdc8c296a9c99854314f17a3a1704c",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-community",
+ "ref": "release-26.05",
+ "repo": "home-manager",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1787101114,
+ "narHash": "sha256-gwrPcFf/rDjHPaVflbDZ040ZDmBTRj/7+s8ZmE2SaIM=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "b18a4b905f8d028dc4476412e6d6891728695379",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-26.05",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "home-manager": "home-manager",
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/cmd/hakurei/testsuite/flake.nix b/cmd/hakurei/testsuite/flake.nix
new file mode 100644
index 00000000..e1df8990
--- /dev/null
+++ b/cmd/hakurei/testsuite/flake.nix
@@ -0,0 +1,75 @@
+{
+ description = "hakurei container tool and nixos module";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
+
+ home-manager = {
+ url = "github:nix-community/home-manager/release-26.05";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs =
+ {
+ self,
+ nixpkgs,
+ home-manager,
+ }:
+ let
+ supportedSystems = [ "x86_64-linux" ];
+
+ forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
+ nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
+ in
+ {
+ nixosModules.hakurei = import ./nixos.nix self.packages;
+
+ checks = forAllSystems (
+ system:
+ let
+ pkgs = nixpkgsFor.${system};
+
+ inherit (pkgs) callPackage;
+ in
+ {
+ hakurei = callPackage ./. { inherit system self; };
+ race = callPackage ./. {
+ inherit system self;
+ withRace = true;
+ };
+ }
+ );
+
+ packages = forAllSystems (
+ system:
+ let
+ inherit (self.packages.${system}) hakurei hsu;
+ pkgs = nixpkgsFor.${system};
+ in
+ {
+ default = hakurei;
+ hakurei = pkgs.pkgsStatic.callPackage ./package.nix {
+ inherit (pkgs)
+ # passthru.buildInputs
+ go_1_27
+ clang
+
+ # nativeBuildInputs
+ pkg-config
+ wayland-scanner
+ makeBinaryWrapper
+
+ # appPackages
+ glibc
+ xdg-dbus-proxy
+
+ # for check
+ nettools
+ ;
+ };
+ hsu = pkgs.callPackage ./hsu.nix { inherit (self.packages.${system}) hakurei; };
+ }
+ );
+ };
+}
diff --git a/cmd/hakurei/testsuite/hsu.nix b/cmd/hakurei/testsuite/hsu.nix
new file mode 100644
index 00000000..d1ddcb77
--- /dev/null
+++ b/cmd/hakurei/testsuite/hsu.nix
@@ -0,0 +1,23 @@
+{
+ lib,
+ buildGoModule,
+ hakurei ? abort "hakurei package required",
+}:
+
+buildGoModule {
+ pname = "${hakurei.pname}-hsu";
+ inherit (hakurei) version;
+
+ src = ../../hsu;
+ inherit (hakurei) vendorHash;
+ env.CGO_ENABLED = 0;
+
+ preBuild = ''
+ go mod init hsu >& /dev/null
+ '';
+
+ ldflags = lib.attrsets.foldlAttrs (
+ ldflags: name: value:
+ ldflags ++ [ "-X main.${name}=${value}" ]
+ ) [ "-s -w" ] { hakureiPath = "${hakurei}/libexec/hakurei"; };
+}
diff --git a/cmd/hakurei/testsuite/nixos.nix b/cmd/hakurei/testsuite/nixos.nix
new file mode 100644
index 00000000..49bfffb6
--- /dev/null
+++ b/cmd/hakurei/testsuite/nixos.nix
@@ -0,0 +1,407 @@
+packages:
+{
+ lib,
+ pkgs,
+ config,
+ ...
+}:
+
+let
+ inherit (lib)
+ lists
+ attrsets
+ mkMerge
+ mkIf
+ mapAttrs
+ foldlAttrs
+ optional
+ optionals
+ ;
+
+ cfg = config.environment.hakurei;
+
+ # userid*userOffset + appStart + appid
+ getsubuid = userid: appid: userid * 100000 + 10000 + appid;
+ getsubname = userid: appid: "u${toString userid}_a${toString appid}";
+ getsubhome = userid: appid: "${cfg.stateDir}/u${toString userid}/a${toString appid}";
+
+ mountpoints = {
+ ${cfg.sharefs.name} = mkIf (cfg.sharefs.source != null) {
+ depends = [ cfg.sharefs.source ];
+ device = "sharefs";
+ fsType = "fuse.sharefs";
+ noCheck = true;
+ options = [
+ "rw"
+ "noexec"
+ "nosuid"
+ "nodev"
+ "noatime"
+ "allow_other"
+ "mkdir"
+ "source=${cfg.sharefs.source}"
+ "setuid=${toString config.users.users.${cfg.sharefs.user}.uid}"
+ "setgid=${toString config.users.groups.${cfg.sharefs.group}.gid}"
+ ];
+ };
+ };
+in
+
+{
+ imports = [ (import ./options.nix packages) ];
+
+ options = {
+ # Forward declare a dummy option for VM filesystems since the real one won't exist
+ # unless the VM module is actually imported.
+ virtualisation.fileSystems = lib.mkOption { };
+ };
+
+ config = mkIf cfg.enable {
+ assertions = [
+ (
+ let
+ conflictingApps = foldlAttrs (
+ acc: id: app:
+ (
+ acc
+ ++ foldlAttrs (
+ acc': id': app':
+ if id == id' || app.shareUid && app'.shareUid || app.identity != app'.identity then acc' else acc' ++ [ id ]
+ ) [ ] cfg.apps
+ )
+ ) [ ] cfg.apps;
+ in
+ {
+ assertion = (lists.length conflictingApps) == 0;
+ message = "the following hakurei apps have conflicting identities: " + (builtins.concatStringsSep ", " conflictingApps);
+ }
+ )
+ ];
+
+ security.wrappers.hsu = {
+ source = "${cfg.hsuPackage}/bin/hsu";
+ setuid = true;
+ owner = "root";
+ group = "root";
+ };
+
+ environment.etc.hsurc = {
+ mode = "0400";
+ text = foldlAttrs (
+ acc: username: fid:
+ "${toString config.users.users.${username}.uid} ${toString fid}\n" + acc
+ ) "" cfg.users;
+ };
+
+ environment.systemPackages = optional (cfg.sharefs.source != null) cfg.sharefs.package;
+ fileSystems = mountpoints;
+ virtualisation.fileSystems = mountpoints;
+
+ home-manager =
+ let
+ privPackages = mapAttrs (_: userid: {
+ home.packages = foldlAttrs (
+ acc: id: app:
+ [
+ (
+ let
+ extendDBusDefault = id: ext: {
+ filter = true;
+
+ talk = [ "org.freedesktop.Notifications" ] ++ ext.talk;
+ own = [
+ "${id}.*"
+ "org.mpris.MediaPlayer2.${id}.*"
+ ]
+ ++ ext.own;
+
+ inherit (ext) call broadcast;
+ };
+ dbusConfig =
+ let
+ default = {
+ talk = [ ];
+ own = [ ];
+ call = { };
+ broadcast = { };
+ };
+ in
+ {
+ session_bus = if app.dbus.session != null then (app.dbus.session (extendDBusDefault id)) else (extendDBusDefault id default);
+ system_bus = app.dbus.system;
+ };
+ command = if app.command == null then app.name else app.command;
+ script = if app.script == null then ("exec " + command + " $@") else app.script;
+ isGraphical = if app.gpu != null then app.gpu else app.enablements.wayland || app.enablements.x11;
+
+ conf = {
+ inherit id;
+ inherit (app) identity enablements;
+ inherit (dbusConfig) session_bus system_bus;
+ direct_wayland = app.insecureWayland;
+ sched_policy = app.schedPolicy;
+ sched_priority = app.schedPriority;
+ groups = app.groups ++ optional (cfg.sharefs.source != null) cfg.sharefs.group;
+
+ container = {
+ inherit (app)
+ wait_delay
+ devel
+ userns
+ device
+ tty
+ multiarch
+ env
+ ;
+ map_real_uid = app.mapRealUid;
+ host_net = app.hostNet;
+ host_abstract = app.hostAbstract;
+ share_runtime = app.shareRuntime;
+ share_tmpdir = app.shareTmpdir;
+
+ filesystem =
+ let
+ bind = src: {
+ type = "bind";
+ inherit src;
+ };
+ optBind = src: {
+ type = "bind";
+ inherit src;
+ optional = true;
+ };
+ optDevBind = src: {
+ type = "bind";
+ inherit src;
+ dev = true;
+ optional = true;
+ };
+ in
+ [
+ (bind "/bin")
+ (bind "/usr/bin")
+ (bind "/nix/store")
+ (optBind "/sys/block")
+ (optBind "/sys/bus")
+ (optBind "/sys/class")
+ (optBind "/sys/dev")
+ (optBind "/sys/devices")
+ ]
+ ++ optionals app.nix [
+ (bind "/nix/var")
+ ]
+ ++ optionals isGraphical [
+ (optDevBind "/dev/dri")
+ (optDevBind "/dev/nvidiactl")
+ (optDevBind "/dev/nvidia-modeset")
+ (optDevBind "/dev/nvidia-uvm")
+ (optDevBind "/dev/nvidia-uvm-tools")
+ (optDevBind "/dev/nvidia0")
+ ]
+ ++ optionals app.useCommonPaths cfg.commonPaths
+ ++ app.extraPaths
+ ++ [
+ {
+ type = "bind";
+ dst = "/etc/";
+ src = "/etc/";
+ special = true;
+ }
+ {
+ type = "link";
+ dst = "/run/current-system";
+ linkname = "/run/current-system";
+ dereference = true;
+ }
+ ]
+ ++ optionals (isGraphical && config.hardware.graphics.enable) (
+ [
+ {
+ type = "link";
+ dst = "/run/opengl-driver";
+ linkname = config.systemd.tmpfiles.settings.graphics-driver."/run/opengl-driver"."L+".argument;
+ }
+ ]
+ ++ optionals (app.multiarch && config.hardware.graphics.enable32Bit) [
+ {
+ type = "link";
+ dst = "/run/opengl-driver-32";
+ linkname = config.systemd.tmpfiles.settings.graphics-driver."/run/opengl-driver-32"."L+".argument;
+ }
+ ]
+ )
+ ++ [
+ {
+ type = "bind";
+ src = getsubhome userid app.identity;
+ write = true;
+ ensure = true;
+ }
+ ];
+
+ username = getsubname userid app.identity;
+ inherit (cfg) shell;
+ home = getsubhome userid app.identity;
+
+ path =
+ if app.path == null then
+ pkgs.writeScript "${app.name}-start" ''
+ #!${pkgs.zsh}${pkgs.zsh.shellPath}
+ ${script}
+ ''
+ else
+ app.path;
+ args = if app.args == null then [ "${app.name}-start" ] else app.args;
+ };
+ };
+
+ checkedConfig =
+ name: value:
+ let
+ file = pkgs.writeText name (builtins.toJSON value);
+ in
+ pkgs.runCommand "checked-${name}" { nativeBuildInputs = [ cfg.package ]; } ''
+ ln -vs ${file} "$out"
+ hakurei show --no-store ${file}
+ '';
+ in
+ pkgs.writeShellScriptBin app.name ''
+ exec hakurei${if app.verbose then " -v" else ""}${if app.insecureWayland then " --insecure" else ""} run ${checkedConfig "hakurei-app-${app.name}.json" conf} $@
+ ''
+ )
+ ]
+ ++ (
+ let
+ pkg = if app.share != null then app.share else pkgs.${app.name};
+ copy = source: "[ -d '${source}' ] && cp -Lrv '${source}' $out/share || true";
+ in
+ optional (app.enablements.wayland || app.enablements.x11) (
+ pkgs.runCommand "${app.name}-share" { } ''
+ mkdir -p $out/share
+ ${copy "${pkg}/share/applications"}
+ ${copy "${pkg}/share/pixmaps"}
+ ${copy "${pkg}/share/icons"}
+ ${copy "${pkg}/share/man"}
+
+ if test -d "$out/share/applications"; then
+ substituteInPlace $out/share/applications/* \
+ --replace-warn '${pkg}/bin/' "" \
+ --replace-warn '${pkg}/libexec/' ""
+ fi
+ ''
+ )
+ )
+ ++ acc
+ ) [ cfg.package ] cfg.apps;
+ }) cfg.users;
+ in
+ {
+ useUserPackages = false; # prevent users.users entries from being added
+
+ users =
+ mkMerge
+ (foldlAttrs
+ (
+ acc: _: fid:
+ foldlAttrs
+ (
+ acc: _: app:
+ (
+ let
+ key = getsubname fid app.identity;
+ in
+ {
+ usernames = acc.usernames // {
+ ${key} = true;
+ };
+ merge = acc.merge ++ [
+ {
+ ${key} = mkMerge (
+ [
+ app.extraConfig
+ { home.packages = app.packages; }
+ ]
+ ++ lib.optional (!attrsets.hasAttrByPath [ key ] acc.usernames) cfg.extraHomeConfig
+ );
+ }
+ ];
+ }
+ )
+ )
+ {
+ inherit (acc) usernames;
+ merge = acc.merge ++ [ { ${getsubname fid 0} = cfg.extraHomeConfig; } ];
+ }
+ cfg.apps
+ )
+ {
+ usernames = { };
+ merge = [ privPackages ];
+ }
+ cfg.users
+ ).merge;
+ };
+
+ users =
+ let
+ getuser = userid: appid: {
+ isSystemUser = true;
+ createHome = true;
+ description = "Hakurei subordinate user ${toString appid} (u${toString userid})";
+ group = getsubname userid appid;
+ home = getsubhome userid appid;
+ uid = getsubuid userid appid;
+ };
+ getgroup = userid: appid: { gid = getsubuid userid appid; };
+ in
+ {
+ users = mkMerge (
+ foldlAttrs
+ (
+ acc: username: fid:
+ acc
+ ++
+ foldlAttrs
+ (
+ acc': _: app:
+ acc' ++ [ { ${getsubname fid app.identity} = getuser fid app.identity; } ]
+ )
+ [
+ {
+ ${getsubname fid 0} = getuser fid 0;
+ ${username}.extraGroups = [ cfg.sharefs.group ];
+ }
+ ]
+ cfg.apps
+ )
+ (optional (cfg.sharefs.source != null) {
+ ${cfg.sharefs.user} = {
+ uid = lib.mkDefault 1023;
+ inherit (cfg.sharefs) group;
+ isSystemUser = true;
+ home = cfg.sharefs.source;
+ };
+ })
+ cfg.users
+ );
+
+ groups = mkMerge (
+ foldlAttrs
+ (
+ acc: _: fid:
+ acc
+ ++ foldlAttrs (
+ acc': _: app:
+ acc' ++ [ { ${getsubname fid app.identity} = getgroup fid app.identity; } ]
+ ) [ { ${getsubname fid 0} = getgroup fid 0; } ] cfg.apps
+ )
+ (optional (cfg.sharefs.source != null) {
+ ${cfg.sharefs.group} = {
+ gid = lib.mkDefault 1023;
+ };
+ })
+ cfg.users
+ );
+ };
+ };
+}
diff --git a/cmd/hakurei/testsuite/options.nix b/cmd/hakurei/testsuite/options.nix
new file mode 100644
index 00000000..f624b6f5
--- /dev/null
+++ b/cmd/hakurei/testsuite/options.nix
@@ -0,0 +1,364 @@
+packages:
+{
+ lib,
+ pkgs,
+ config,
+ ...
+}:
+
+let
+ inherit (lib) types mkOption mkEnableOption;
+
+ cfg = config.environment.hakurei;
+in
+
+{
+ options = {
+ environment.hakurei = {
+ enable = mkEnableOption "hakurei";
+
+ package = mkOption {
+ type = types.package;
+ default = packages.${pkgs.stdenv.hostPlatform.system}.hakurei;
+ description = "The hakurei package to use.";
+ };
+
+ hsuPackage = mkOption {
+ type = types.package;
+ default = packages.${pkgs.stdenv.hostPlatform.system}.hsu;
+ description = "The hsu package to use.";
+ };
+
+ users = mkOption {
+ type =
+ let
+ inherit (types) attrsOf ints;
+ in
+ attrsOf (ints.between 0 99);
+ description = ''
+ Users allowed to spawn hakurei apps and their corresponding hakurei identity.
+ '';
+ };
+
+ extraHomeConfig = mkOption {
+ type = types.anything;
+ description = ''
+ Extra home-manager configuration to merge with all target users.
+ '';
+ };
+
+ sharefs = {
+ package = mkOption {
+ type = types.package;
+ default = pkgs.linkFarm "sharefs" {
+ "bin/sharefs" = "${cfg.package}/libexec/sharefs";
+ "bin/mount.fuse.sharefs" = "${cfg.package}/libexec/sharefs";
+ };
+ description = "The sharefs package to use.";
+ };
+
+ user = mkOption {
+ type = types.str;
+ default = "sharefs";
+ description = ''
+ Name of the user to run the sharefs daemon as.
+ '';
+ };
+
+ group = mkOption {
+ type = types.str;
+ default = "sharefs";
+ description = ''
+ Name of the group to run the sharefs daemon as.
+ '';
+ };
+
+ name = mkOption {
+ type = types.str;
+ default = "/sdcard";
+ description = ''
+ Host path to mount sharefs on.
+ '';
+ };
+
+ source = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = ''
+ Writable backing directory. Setting this to null disables sharefs.
+ '';
+ };
+ };
+
+ apps = mkOption {
+ type =
+ let
+ inherit (types)
+ int
+ ints
+ str
+ bool
+ enum
+ package
+ anything
+ submodule
+ listOf
+ attrsOf
+ nullOr
+ functionTo
+ ;
+ in
+ attrsOf (submodule {
+ options = {
+ name = mkOption {
+ type = str;
+ description = ''
+ Name of the app's launcher script.
+ '';
+ };
+
+ verbose = mkEnableOption "launchers with verbose output";
+
+ identity = mkOption {
+ type = ints.between 1 9999;
+ description = ''
+ Application identity. Identity 0 is reserved for system services.
+ '';
+ };
+ shareUid = mkEnableOption "sharing identity with another application";
+
+ packages = mkOption {
+ type = listOf package;
+ default = [ ];
+ description = ''
+ List of extra packages to install via home-manager.
+ '';
+ };
+
+ extraConfig = mkOption {
+ type = anything;
+ default = { };
+ description = ''
+ Extra home-manager configuration.
+ '';
+ };
+
+ path = mkOption {
+ type = nullOr str;
+ default = null;
+ description = ''
+ Custom executable path.
+ Setting this to null will default to the start script.
+ '';
+ };
+
+ args = mkOption {
+ type = nullOr (listOf str);
+ default = null;
+ description = ''
+ Custom args.
+ Setting this to null will default to script name.
+ '';
+ };
+
+ script = mkOption {
+ type = nullOr str;
+ default = null;
+ description = ''
+ Application launch script.
+ '';
+ };
+
+ command = mkOption {
+ type = nullOr str;
+ default = null;
+ description = ''
+ Command to run as the target user.
+ Setting this to null will default command to launcher name.
+ Has no effect when script is set.
+ '';
+ };
+
+ groups = mkOption {
+ type = listOf str;
+ default = [ ];
+ description = ''
+ List of groups to inherit from the privileged user.
+ '';
+ };
+
+ shareRuntime = mkEnableOption "sharing of XDG_RUNTIME_DIR between containers under the same identity";
+ shareTmpdir = mkEnableOption "sharing of TMPDIR between containers under the same identity";
+
+ dbus = {
+ session = mkOption {
+ type = nullOr (functionTo anything);
+ default = null;
+ description = ''
+ D-Bus session bus custom configuration.
+ Setting this to null will enable built-in defaults.
+ '';
+ };
+
+ system = mkOption {
+ type = nullOr anything;
+ default = null;
+ description = ''
+ D-Bus system bus custom configuration.
+ Setting this to null will disable the system bus proxy.
+ '';
+ };
+ };
+
+ env = mkOption {
+ type = nullOr (attrsOf str);
+ default = null;
+ description = ''
+ Environment variables to set for the initial process in the sandbox.
+ '';
+ };
+
+ wait_delay = mkOption {
+ type = nullOr int;
+ default = null;
+ description = ''
+ Duration to wait for after interrupting a container's initial process in nanoseconds.
+ A negative value causes the container to be terminated immediately on cancellation.
+ Setting this to null defaults to five seconds.
+ '';
+ };
+
+ devel = mkEnableOption "debugging-related kernel interfaces";
+ userns = mkEnableOption "user namespace creation";
+ tty = mkEnableOption "access to the controlling terminal";
+ multiarch = mkEnableOption "multiarch kernel-level support";
+
+ hostNet = mkEnableOption "share host net namespace" // {
+ default = true;
+ };
+ hostAbstract = mkEnableOption "share abstract unix socket scope";
+
+ schedPolicy = mkOption {
+ type = nullOr (enum [
+ "fifo"
+ "rr"
+ "batch"
+ "idle"
+ "deadline"
+ "ext"
+ ]);
+ default = null;
+ description = ''
+ Scheduling policy to set for the container.
+ The zero value retains the current scheduling policy.
+ '';
+ };
+ schedPriority = mkOption {
+ type = nullOr (ints.between 1 99);
+ default = null;
+ description = ''
+ Scheduling priority to set for the container.
+ '';
+ };
+
+ nix = mkEnableOption "nix daemon access";
+ mapRealUid = mkEnableOption "mapping to priv-user uid";
+ device = mkEnableOption "access to all devices";
+ insecureWayland = mkEnableOption "direct access to the Wayland socket";
+
+ gpu = mkOption {
+ type = nullOr bool;
+ default = null;
+ description = ''
+ Target process GPU and driver access.
+ Setting this to null will enable GPU whenever X or Wayland is enabled.
+ '';
+ };
+
+ useCommonPaths = mkEnableOption "common extra paths" // {
+ default = true;
+ };
+
+ extraPaths = mkOption {
+ type = listOf (attrsOf anything);
+ default = [ ];
+ description = ''
+ Extra paths to make available to the container.
+ '';
+ };
+
+ enablements = {
+ wayland = mkOption {
+ type = nullOr bool;
+ default = true;
+ description = ''
+ Whether to share the Wayland server via security-context-v1.
+ '';
+ };
+
+ x11 = mkOption {
+ type = nullOr bool;
+ default = false;
+ description = ''
+ Whether to share the X11 socket and allow connection.
+ '';
+ };
+
+ dbus = mkOption {
+ type = nullOr bool;
+ default = true;
+ description = ''
+ Whether to proxy D-Bus.
+ '';
+ };
+
+ pipewire = mkOption {
+ type = nullOr bool;
+ default = true;
+ description = ''
+ Whether to share the PipeWire server via pipewire-pulse on a SecurityContext socket.
+ '';
+ };
+ };
+
+ share = mkOption {
+ type = nullOr package;
+ default = null;
+ description = ''
+ Package containing share files.
+ Setting this to null will default package name to wrapper name.
+ '';
+ };
+ };
+ });
+ default = { };
+ description = ''
+ Declaratively configured hakurei apps.
+ '';
+ };
+
+ commonPaths = mkOption {
+ type = types.listOf (types.attrsOf types.anything);
+ default = [ ];
+ description = ''
+ Common extra paths to make available to the container.
+ '';
+ };
+
+ shell = mkOption {
+ type = types.str;
+ default = "/run/current-system/sw/bin/bash";
+ description = ''
+ Absolute path to preferred shell.
+ '';
+ };
+
+ stateDir = mkOption {
+ type = types.str;
+ description = ''
+ The state directory where app home directories are stored.
+ '';
+ };
+ };
+ };
+}
diff --git a/cmd/hakurei/testsuite/package.nix b/cmd/hakurei/testsuite/package.nix
new file mode 100644
index 00000000..12196cf6
--- /dev/null
+++ b/cmd/hakurei/testsuite/package.nix
@@ -0,0 +1,145 @@
+{
+ lib,
+ stdenv,
+ buildGo127Module,
+ makeBinaryWrapper,
+ xdg-dbus-proxy,
+ pkg-config,
+ libffi,
+ libseccomp,
+ acl,
+ wayland,
+ wayland-protocols,
+ wayland-scanner,
+
+ libxcb,
+ libxau,
+ libxdmcp,
+
+ # for sharefs
+ fuse3,
+
+ # for passthru.buildInputs
+ go_1_27,
+ clang,
+ xorgproto,
+
+ # for check
+ util-linux,
+ nettools,
+
+ glibc, # for ldd
+ withStatic ? stdenv.hostPlatform.isStatic,
+}:
+
+buildGo127Module rec {
+ pname = "hakurei";
+ version = with lib.strings; removePrefix "v" (trim (builtins.readFile ../../dist/VERSION));
+
+ srcFiltered = builtins.path {
+ name = "${pname}-src";
+ path = lib.cleanSource ../../../.;
+ filter = path: type: !(type == "regular" && (lib.hasSuffix ".nix" path || lib.hasSuffix ".py" path)) && !(type == "directory" && lib.hasSuffix "/test" path) && !(type == "directory" && lib.hasSuffix "/cmd/hsu" path);
+ };
+ vendorHash = null;
+
+ src = stdenv.mkDerivation {
+ name = "${pname}-src-full";
+ inherit version;
+ enableParallelBuilding = true;
+ src = srcFiltered;
+
+ buildInputs = [
+ wayland
+ wayland-protocols
+ ];
+
+ nativeBuildInputs = [
+ go_1_27
+ pkg-config
+ wayland-scanner
+ ];
+
+ buildPhase = "GOCACHE=$(mktemp -d) go generate ./...";
+ installPhase = "cp -r . $out";
+ };
+
+ ldflags =
+ lib.attrsets.foldlAttrs
+ (
+ ldflags: name: value:
+ ldflags ++ [ "-X hakurei.app/internal/info.${name}=${value}" ]
+ )
+ (
+ [ "-s -w" ]
+ ++ lib.optionals withStatic [
+ "-linkmode external"
+ "-extldflags \"-static\""
+ ]
+ )
+ {
+ buildVersion = "v${version}";
+ hakureiPath = "${placeholder "out"}/libexec/hakurei";
+ hsuPath = "/run/wrappers/bin/hsu";
+ };
+
+ env = {
+ # use clang instead of gcc
+ CC = "clang -O3 -Werror";
+ };
+
+ buildInputs = [
+ libffi
+ libseccomp
+ fuse3
+ acl
+ wayland
+ util-linux
+
+ libxcb
+ libxau
+ libxdmcp
+ ];
+
+ nativeBuildInputs = [
+ pkg-config
+ makeBinaryWrapper
+
+ # for container example
+ nettools
+ ];
+
+ postInstall =
+ let
+ appPackages = [
+ glibc
+ xdg-dbus-proxy
+ ];
+ in
+ ''
+ install -D --target-directory=$out/share/zsh/site-functions cmd/dist/comp/*
+
+ mkdir "$out/libexec"
+ mv "$out"/bin/* "$out/libexec/"
+
+ makeBinaryWrapper "$out/libexec/hakurei" "$out/bin/hakurei" \
+ --inherit-argv0 --prefix PATH : ${lib.makeBinPath appPackages}
+ '';
+
+ passthru = {
+ go = go_1_27;
+
+ targetPkgs = [
+ go_1_27
+ clang
+ xorgproto
+ util-linux
+
+ # for go generate
+ wayland-protocols
+ wayland-scanner
+ ]
+ ++ buildInputs
+ ++ nativeBuildInputs;
+ };
+}
diff --git a/cmd/hakurei/testsuite/sandbox/main.go b/cmd/hakurei/testsuite/sandbox/main.go
new file mode 100644
index 00000000..1d3d4516
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/main.go
@@ -0,0 +1,409 @@
+//go:build testsuite
+
+// The sandbox test program runs cmd/hakurei with configurations simulating
+// several common workloads and inspects the resulting container states.
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+
+ "hakurei.app/check"
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/store"
+ "hakurei.app/internal/testsuite"
+
+ "hakurei.app/cmd/hakurei/testsuite/sandbox/testdata"
+)
+
+// mustScanFor continuously scans the proc filesystem and calls f for each entry
+// visited.
+func mustScanFor(f func(ps *testsuite.StatScanner) bool) int {
+ var ps testsuite.StatScanner
+
+ for ps.Scan() {
+ if f(&ps) {
+ break
+ }
+ }
+ if err := ps.Err(); err != nil {
+ log.Fatal(err)
+ }
+ return ps.Stat().PID
+}
+
+// mustStart starts a hakurei container and returns the pid of a process within
+// the container. This process must be terminated by the caller.
+func mustStart(
+ ctx context.Context,
+ serial uint64,
+ cred *syscall.Credential,
+ files ...*os.File,
+) (pid int, done <-chan error) {
+ _serial := strconv.FormatUint(serial, 10)
+ _, done = testsuite.MustStartWith(
+ ctx, cred, nil, files,
+ "hakurei", "exec",
+ "sleep", "infinity", _serial,
+ )
+
+ var stat syscall.Stat_t
+ pid = mustScanFor(func(s *testsuite.StatScanner) bool {
+ select {
+ case err := <-done:
+ if err == nil {
+ log.Fatal("test process terminated unexpectedly")
+ }
+ log.Fatal(err)
+ default:
+ break
+ }
+
+ if s.Stat().Comm != "sleep" {
+ return false
+ }
+
+ if args, err := s.Stat().Args(); err != nil {
+ if testsuite.IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ } else if !slices.Equal(args, []string{
+ "sleep",
+ "infinity",
+ _serial,
+ }) {
+ return false
+ }
+
+ if err := s.Stat().Stat(&stat); err != nil {
+ if testsuite.IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ }
+
+ id := hst.ToUser[uint32](0, 0)
+ if stat.Uid != id || stat.Gid != id {
+ return false
+ }
+
+ return true
+ })
+ return
+}
+
+func main() {
+ go testsuite.ReceiveSignals()
+
+ // the signal handler does not wait for termination
+ ctx := context.Background()
+
+ cred := syscall.Credential{Uid: 1000, Gid: 100}
+ if err := os.MkdirAll("/opt/test-helper/bin", 0755); err != nil {
+ log.Fatal(err)
+ }
+
+ var testToolDone <-chan error
+ {
+ cmd := exec.Command(
+ "go", "build",
+ "-o", "/opt/test-helper/bin",
+ "-tags=tester",
+ "-trimpath",
+ "./cmd/hakurei/testsuite/sandbox/tester",
+ )
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ testToolDone = testsuite.MustStart(cmd)
+ }
+
+ var wg sync.WaitGroup
+ defer wg.Wait()
+
+ var serial atomic.Uint64
+ newSerial := func() uint64 { serial.Add(1); return serial.Load() }
+
+ testsuite.MustRun(
+ &cred, nil,
+ "hakurei", "exec", "capsh", "--print",
+ )
+ wg.Go(func() {
+ defer log.Println("validated capabilities/securebits in user namespace")
+
+ testsuite.MustRun(
+ &cred, nil,
+ "hakurei", "exec", "capsh", "--has-no-new-privs",
+ )
+
+ for _, p := range []byte{'a', 'b', 'i', 'p'} {
+ testsuite.MustFail(
+ &cred, nil,
+ "hakurei", "exec", "capsh", "--has-"+string(p)+"=CAP_SYS_ADMIN",
+ )
+ }
+ testsuite.MustFail(
+ &cred, nil,
+ "hakurei", "exec", "umount", "-R", "/dev",
+ )
+ })
+
+ wg.Go(func() {
+ defer log.Println("validated pd seccomp outcome")
+
+ c, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ pid, done := mustStart(c, newSerial(), &cred)
+ testsuite.MustCheckFilter(pid, testdata.SumPD)
+ if err := testsuite.FilterTerminated(<-done); err != nil {
+ log.Fatal(err)
+ }
+ })
+
+ wg.Go(func() {
+ defer log.Println("validated fd leak")
+
+ c, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ pid, done := mustStart(c, newSerial(), &cred, os.Stdin, os.Stdout, os.Stderr)
+ prefix := filepath.Join(fhs.Proc, strconv.Itoa(pid), "fd")
+
+ var fail bool
+ if entries, err := os.ReadDir(prefix); err != nil {
+ log.Fatal(err.Error())
+ } else {
+ for _, ent := range entries {
+ var fd int
+ if fd, err = strconv.Atoi(ent.Name()); err != nil {
+ log.Fatal(err.Error())
+ }
+
+ // skip standard streams
+ if fd <= 2 {
+ continue
+ }
+ fail = true
+
+ var d string
+ if d, err = os.Readlink(filepath.Join(
+ prefix,
+ ent.Name(),
+ )); err != nil {
+ log.Fatal(err.Error())
+ }
+ log.Printf("extra fd %d -> %s", fd, d)
+ }
+ }
+ if fail {
+ log.Fatal("file descriptors leaked")
+ }
+
+ if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
+ log.Fatalf("cannot terminate anchor: %v", err)
+ } else if err = testsuite.FilterTerminated(<-done); err != nil {
+ log.Fatal(err)
+ }
+ })
+
+ if err := os.MkdirAll(testsuite.XDGRuntimeDir, 0700); err != nil {
+ log.Fatal(err)
+ } else if err = os.Chown(testsuite.XDGRuntimeDir, 1000, 1000); err != nil {
+ log.Fatal(err)
+ }
+
+ var swg sync.WaitGroup
+ defer swg.Wait()
+ dbusEnv := testsuite.MustStartSessionBus(&cred)
+ testsuite.MustStartSway(&swg, &cred, dbusEnv)
+ defer testsuite.TerminateSway(&cred)
+ testsuite.MustStartPipeWire(&cred, dbusEnv)
+
+ if err := <-testToolDone; err != nil {
+ log.Fatal(err)
+ }
+ log.Println("created test helper")
+
+ s := store.New(check.MustAbs("/tmp/hakurei.0/state"))
+ for name, tc := range testdata.All() {
+ wg.Go(func() {
+ cmd := exec.Command(
+ "script", "/dev/null",
+ "-E", "always",
+ "-qec",
+ "hakurei run "+
+ "--identifier-fd=5"+
+ " 4 1>&3",
+ )
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGTERM,
+ Credential: &cred,
+ }
+ var output bytes.Buffer
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, &output, &output
+ cmd.Env = []string{
+ "PATH=" + os.Getenv("PATH"),
+ "TERM=xterm",
+ testsuite.XDGRuntimeEnv,
+ testsuite.WaylandEnv,
+ "DISPLAY=:0",
+ dbusEnv,
+ }
+
+ var err error
+ var notify, _notify, _conf, conf, ident, _ident *os.File
+ if notify, _notify, err = os.Pipe(); err != nil {
+ log.Fatal(err)
+ }
+ cmd.ExtraFiles = append(cmd.ExtraFiles, _notify)
+ if _conf, conf, err = os.Pipe(); err != nil {
+ log.Fatal(err)
+ }
+ cmd.ExtraFiles = append(cmd.ExtraFiles, _conf)
+ if ident, _ident, err = os.Pipe(); err != nil {
+ log.Fatal(err)
+ }
+ cmd.ExtraFiles = append(cmd.ExtraFiles, _ident)
+
+ done := testsuite.MustStart(cmd)
+ wg.Go(func() {
+ _err := <-done
+ log.Printf("completed test case %s\n%s", name, output.String())
+ if _err != nil {
+ log.Fatalf("test case %s: %v", name, _err)
+ }
+ })
+
+ if err = json.NewEncoder(conf).Encode(&tc.Hakurei); err != nil {
+ log.Fatal(err)
+ } else if err = conf.Close(); err != nil {
+ log.Fatal(err)
+ }
+
+ var id hst.ID
+ if _, err = io.ReadFull(ident, id[:]); err != nil {
+ log.Fatal(err)
+ } else if err = ident.Close(); err != nil {
+ log.Fatal(err)
+ }
+
+ if _, err = io.ReadFull(notify, make([]byte, 8)); err != nil {
+ log.Fatal(err)
+ } else if err = notify.Close(); err != nil {
+ log.Fatal(err)
+ }
+
+ var (
+ ok bool
+ p hst.State
+ )
+ entries, copyError := s.All()
+ for entry := range entries {
+ if entry.ID == id {
+ ok = true
+ if _, err = entry.Load(&p, nil); err != nil {
+ log.Fatal(err)
+ }
+ break
+ }
+ }
+ if err = copyError(); err != nil {
+ log.Fatal(err)
+ }
+ if !ok {
+ log.Fatalf("instance %s is not present in store", id)
+ }
+
+ var stat syscall.Stat_t
+ pid := mustScanFor(func(ps *testsuite.StatScanner) bool {
+ select {
+ case err = <-done:
+ if err == nil {
+ log.Fatal("test process terminated unexpectedly")
+ }
+ log.Fatal(err)
+ default:
+ break
+ }
+
+ if ps.Stat().Comm != "test-helper" {
+ return false
+ }
+
+ var args []string
+ if args, err = ps.Stat().Args(); err != nil {
+ if testsuite.IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ } else if !slices.Equal(args, tc.Hakurei.Container.Args) {
+ return false
+ }
+
+ if err = ps.Stat().Stat(&stat); err != nil {
+ if testsuite.IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ }
+
+ uid := hst.ToUser[uint32](0, uint32(tc.Hakurei.Identity))
+ if stat.Uid != uid || stat.Gid != uid {
+ return false
+ }
+
+ var t []byte
+ if t, err = os.ReadFile(filepath.Join(
+ fhs.Proc,
+ strconv.Itoa(ps.Stat().PPID),
+ "stat",
+ )); err != nil {
+ if testsuite.IsNotExist(err) {
+ return false
+ }
+ log.Fatal(err)
+ }
+
+ var _stat testsuite.Stat
+ if err = _stat.UnmarshalText(t); err != nil {
+ log.Fatal(err)
+ }
+ if _stat.PPID != p.ShimPID {
+ return false
+ }
+
+ return true
+ })
+
+ testsuite.MustCheckFilter(
+ pid,
+ tc.Sum,
+ )
+ })
+ }
+
+ wg.Wait()
+
+ if dents, err := os.ReadDir("/tmp"); err != nil {
+ log.Fatal(err)
+ } else {
+ for _, dent := range dents {
+ if name := dent.Name(); strings.HasPrefix(name, ".hakurei-shim-") {
+ log.Fatalf("leftover shim work dir %q", name)
+ }
+ }
+ }
+}
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/device.go b/cmd/hakurei/testsuite/sandbox/testdata/device.go
new file mode 100644
index 00000000..5de9f550
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/device.go
@@ -0,0 +1,134 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "os"
+ "syscall"
+
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+var _ = TestCase{
+ Hakurei: hst.Config{
+ ID: "app.hakurei.sample.device",
+ Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus | hst.EX11),
+ Identity: 4,
+
+ Container: &hst.ContainerConfig{
+ Hostname: "hakurei-sample-device",
+
+ Filesystem: []hst.FilesystemConfigJSON{
+ fcLinker,
+ fcLib,
+ fcTestHelper,
+ },
+
+ Username: "u0_a4",
+ Shell: fhs.AbsUsrBin.Append("bash"),
+ Home: hst.AbsPrivateTmp,
+ Path: absTestHelper,
+ Args: []string{"tester", "device"},
+
+ Flags: hst.FDevice | hst.FShareTmpdir,
+ },
+ },
+
+ // 0, PresetStrict
+ Sum: sumSimple,
+
+ Env: []string{
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus",
+ "DISPLAY=unix:/tmp/.X11-unix/X0",
+ "HOME=/.hakurei",
+ "SHELL=/usr/bin/bash",
+ "TERM=xterm",
+ "USER=u0_a4",
+ "WAYLAND_DISPLAY=wayland-0",
+ "XDG_RUNTIME_DIR=/run/user/65534",
+ "XDG_SESSION_CLASS=user",
+ "XDG_SESSION_TYPE=wayland",
+ "PULSE_SERVER=unix:/run/user/65534/pulse/native",
+ },
+
+ FS: &testsuite.FS{Dir: dir{
+ ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{
+ "test-helper": {Mode: 0755},
+ }},
+
+ // unstable host dev
+ "dev": {Mode: os.ModeDir | 0755},
+
+ "etc": {Mode: os.ModeDir | 0755, Dir: dir{
+ "passwd": {Mode: 0600,
+ Data: new("u0_a4:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")},
+ "group": {Mode: 0600,
+ Data: new("hakurei:x:65534:\n")},
+ }},
+
+ "lib64": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777},
+ }},
+
+ "run": {Mode: os.ModeDir | 0755, Dir: dir{
+ "user": {Mode: os.ModeDir | 0755, Dir: dir{
+ "65534": {Mode: os.ModeDir | 0700, Dir: dir{
+ "bus": {Mode: os.ModeSocket | 0775},
+ "wayland-0": {Mode: os.ModeSocket | 070},
+ "pulse": {Mode: os.ModeDir | 0700, Dir: dir{
+ "native": {Mode: os.ModeSocket | 0777},
+ }},
+ }},
+ }},
+ }},
+
+ "tmp": {Mode: os.ModeDir | 0770, Dir: dir{
+ ".X11-unix": {Mode: os.ModeDir | 0755, Dir: dir{
+ "X0": {Mode: os.ModeSocket | 0775},
+ }},
+ }},
+
+ "lib": {Mode: os.ModeDir | 0755},
+ "proc": {Mode: os.ModeDir | 0555},
+ }},
+
+ Mount: []*mountinfo.Entry{
+ r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110003,gid=110003,inode64"),
+ r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"),
+ r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110003,gid=110003,inode64"),
+
+ // host /dev in testing environment
+ r("/", "/dev", "rw,nosuid", "tmpfs", "tmpfs", ignore),
+ r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,gid=100004,mode=620,ptmxmode=666"),
+ r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
+ r("/kvm", "/dev/kvm", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/fuse", "/dev/fuse", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/", "/dev/shm", "rw,nosuid,nodev,noexec,relatime", "tmpfs", "shm", ignore),
+ r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+
+ r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110003,gid=110003,inode64"),
+ r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110003,gid=110003,inode64"),
+ r("/tmp/hakurei.0/tmpdir/4", "/tmp", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110003,gid=110003,inode64"),
+ r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110003,gid=110003,inode64"),
+ r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/tmp/.X11-unix", "/tmp/.X11-unix", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ },
+
+ Seccomp: true,
+
+ TrySocket: "/tmp/.X11-unix/X0",
+ ErrnoAbstract: syscall.ECONNREFUSED,
+}.register("device")
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/mapuid.go b/cmd/hakurei/testsuite/sandbox/testdata/mapuid.go
new file mode 100644
index 00000000..218b035d
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/mapuid.go
@@ -0,0 +1,124 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "os"
+ "syscall"
+
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+var _ = TestCase{
+ Hakurei: hst.Config{
+ ID: "app.hakurei.sample.mapuid",
+ Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus),
+ Identity: 3,
+
+ Container: &hst.ContainerConfig{
+ Hostname: "hakurei-sample-mapuid",
+
+ Filesystem: []hst.FilesystemConfigJSON{
+ fcLinker,
+ fcLib,
+ fcTestHelper,
+ },
+
+ Username: "u0_a3",
+ Shell: fhs.AbsUsrBin.Append("bash"),
+ Home: hst.AbsPrivateTmp,
+ Path: absTestHelper,
+ Args: []string{"tester", "mapuid"},
+
+ Flags: hst.FMapRealUID | hst.FShareRuntime | hst.FShareTmpdir,
+ },
+ },
+
+ // 0, PresetStrict
+ Sum: sumSimple,
+
+ Env: []string{
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus",
+ "HOME=/.hakurei",
+ "SHELL=/usr/bin/bash",
+ "TERM=xterm",
+ "USER=u0_a3",
+ "WAYLAND_DISPLAY=wayland-0",
+ "XDG_RUNTIME_DIR=/run/user/1000",
+ "XDG_SESSION_CLASS=user",
+ "XDG_SESSION_TYPE=wayland",
+ "PULSE_SERVER=unix:/run/user/1000/pulse/native",
+ },
+
+ FS: &testsuite.FS{Dir: dir{
+ ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{
+ "test-helper": {Mode: 0755},
+ }},
+
+ // unstable host dev
+ "dev": {Mode: os.ModeDir | 0755},
+
+ "etc": {Mode: os.ModeDir | 0755, Dir: dir{
+ "passwd": {Mode: 0600,
+ Data: new("u0_a3:x:1000:100:Hakurei:/.hakurei:/usr/bin/bash\n")},
+ "group": {Mode: 0600,
+ Data: new("hakurei:x:100:\n")},
+ }},
+
+ "lib64": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777},
+ }},
+
+ "run": {Mode: os.ModeDir | 0755, Dir: dir{
+ "user": {Mode: os.ModeDir | 0755, Dir: dir{
+ "1000": {Mode: os.ModeDir | 0770, Dir: dir{
+ "bus": {Mode: os.ModeSocket | 0775},
+ "wayland-0": {Mode: os.ModeSocket | 070},
+ "pulse": {Mode: os.ModeDir | 0700, Dir: dir{
+ "native": {Mode: os.ModeSocket | 0777},
+ }},
+ }},
+ }},
+ }},
+
+ "tmp": {Mode: os.ModeDir | 0770, Dir: dir{}},
+
+ "lib": {Mode: os.ModeDir | 0755},
+ "proc": {Mode: os.ModeDir | 0555},
+ }},
+
+ Mount: []*mountinfo.Entry{
+ r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110002,gid=110002,inode64"),
+ r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"),
+ r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110002,gid=110002,inode64"),
+ r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110002,gid=110002,inode64"),
+ r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"),
+ r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
+ r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110002,gid=110002,inode64"),
+ r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110002,gid=110002,inode64"),
+ r("/tmp/hakurei.0/runtime/3", "/run/user/1000", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/tmp/hakurei.0/tmpdir/3", "/tmp", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110002,gid=110002,inode64"),
+ r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110002,gid=110002,inode64"),
+ r(ignore, "/run/user/1000/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/1000/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/1000/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ },
+
+ Seccomp: true,
+
+ TrySocket: "/tmp/.X11-unix/X0",
+ ErrnoAbstract: syscall.ECONNREFUSED,
+ ErrnoPathname: syscall.ENOENT,
+}.register("mapuid")
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/pdlike.go b/cmd/hakurei/testsuite/sandbox/testdata/pdlike.go
new file mode 100644
index 00000000..5b58ed38
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/pdlike.go
@@ -0,0 +1,141 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "os"
+ "syscall"
+
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+var _ = TestCase{
+ Hakurei: hst.Config{
+ ID: "app.hakurei.sample.pdlike",
+ Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus),
+ Identity: 5,
+
+ Container: &hst.ContainerConfig{
+ Hostname: "hakurei-sample-pdlike",
+
+ Filesystem: []hst.FilesystemConfigJSON{
+ fcLinker,
+ fcLib,
+ fcTestHelper,
+ },
+
+ Username: "u0_a5",
+ Shell: fhs.AbsUsrBin.Append("bash"),
+ Home: hst.AbsPrivateTmp,
+ Path: absTestHelper,
+ Args: []string{"tester", "pdlike"},
+
+ Flags: hst.FHostNet | hst.FTty | hst.FUserns | hst.FShareRuntime | hst.FShareTmpdir,
+ },
+ },
+
+ // 0, PresetExt | PresetDenyDevel
+ Sum: SumPD,
+
+ Env: []string{
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus",
+ "HOME=/.hakurei",
+ "SHELL=/usr/bin/bash",
+ "TERM=xterm",
+ "USER=u0_a5",
+ "WAYLAND_DISPLAY=wayland-0",
+ "XDG_RUNTIME_DIR=/run/user/65534",
+ "XDG_SESSION_CLASS=user",
+ "XDG_SESSION_TYPE=wayland",
+ "PULSE_SERVER=unix:/run/user/65534/pulse/native",
+ },
+
+ FS: &testsuite.FS{Dir: dir{
+ ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{
+ "test-helper": {Mode: 0755},
+ }},
+
+ "dev": {Mode: os.ModeDir | 0755, Dir: dir{
+ "core": {Mode: os.ModeSymlink | 0777},
+ "fd": {Mode: os.ModeSymlink | 0777},
+ "full": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "mqueue": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "null": {Mode: os.ModeDevice | os.ModeCharDevice | 0666, Data: new("")},
+ "ptmx": {Mode: os.ModeSymlink | 0777},
+ "pts": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ptmx": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+ "random": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "shm": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "stderr": {Mode: os.ModeSymlink | 0777},
+ "stdin": {Mode: os.ModeSymlink | 0777},
+ "stdout": {Mode: os.ModeSymlink | 0777},
+ "tty": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "urandom": {Mode: os.ModeDevice | os.ModeCharDevice | 0444},
+ "zero": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+
+ "etc": {Mode: os.ModeDir | 0755, Dir: dir{
+ "passwd": {Mode: 0600,
+ Data: new("u0_a5:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")},
+ "group": {Mode: 0600,
+ Data: new("hakurei:x:65534:\n")},
+ }},
+
+ "lib64": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777},
+ }},
+
+ "run": {Mode: os.ModeDir | 0755, Dir: dir{
+ "user": {Mode: os.ModeDir | 0755, Dir: dir{
+ "65534": {Mode: os.ModeDir | 0770, Dir: dir{
+ "bus": {Mode: os.ModeSocket | 0775},
+ "wayland-0": {Mode: os.ModeSocket | 070},
+ "pulse": {Mode: os.ModeDir | 0700, Dir: dir{
+ "native": {Mode: os.ModeSocket | 0777},
+ }},
+ }},
+ }},
+ }},
+
+ "tmp": {Mode: os.ModeDir | 0770, Dir: dir{}},
+
+ "lib": {Mode: os.ModeDir | 0755},
+ "proc": {Mode: os.ModeDir | 0555},
+ }},
+
+ Mount: []*mountinfo.Entry{
+ r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110004,gid=110004,inode64"),
+ r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"),
+ r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110004,gid=110004,inode64"),
+ r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110004,gid=110004,inode64"),
+ r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"),
+ r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
+ r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110004,gid=110004,inode64"),
+ r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110004,gid=110004,inode64"),
+ r("/tmp/hakurei.0/runtime/5", "/run/user/65534", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/tmp/hakurei.0/tmpdir/5", "/tmp", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110004,gid=110004,inode64"),
+ r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110004,gid=110004,inode64"),
+ r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ },
+
+ Seccomp: true,
+
+ TrySocket: "/tmp/.X11-unix/X0",
+ ErrnoAbstract: syscall.EPERM,
+ ErrnoPathname: syscall.ENOENT,
+}.register("pdlike")
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/simple.go b/cmd/hakurei/testsuite/sandbox/testdata/simple.go
new file mode 100644
index 00000000..edb7c350
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/simple.go
@@ -0,0 +1,140 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "os"
+ "syscall"
+
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+var _ = TestCase{
+ Hakurei: hst.Config{
+ ID: "app.hakurei.sample.simple",
+ Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus),
+ Identity: 1,
+
+ Container: &hst.ContainerConfig{
+ Hostname: "hakurei-sample-simple",
+ Env: map[string]string{"HAKUREI_SAMPLE": "1"},
+
+ Filesystem: []hst.FilesystemConfigJSON{
+ fcLinker,
+ fcLib,
+ fcTestHelper,
+ },
+
+ Username: "u0_a1",
+ Shell: fhs.AbsUsrBin.Append("bash"),
+ Home: hst.AbsPrivateTmp,
+ Path: absTestHelper,
+ Args: []string{"tester", "simple"},
+ },
+ },
+
+ // 0, PresetStrict
+ Sum: sumSimple,
+
+ Env: []string{
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus",
+ "HAKUREI_SAMPLE=1",
+ "HOME=/.hakurei",
+ "SHELL=/usr/bin/bash",
+ "TERM=xterm",
+ "USER=u0_a1",
+ "WAYLAND_DISPLAY=wayland-0",
+ "XDG_RUNTIME_DIR=/run/user/65534",
+ "XDG_SESSION_CLASS=user",
+ "XDG_SESSION_TYPE=wayland",
+ "PULSE_SERVER=unix:/run/user/65534/pulse/native",
+ },
+
+ FS: &testsuite.FS{Dir: dir{
+ ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{
+ "test-helper": {Mode: 0755},
+ }},
+
+ "dev": {Mode: os.ModeDir | 0755, Dir: dir{
+ "core": {Mode: os.ModeSymlink | 0777},
+ "fd": {Mode: os.ModeSymlink | 0777},
+ "full": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "mqueue": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "null": {Mode: os.ModeDevice | os.ModeCharDevice | 0666, Data: new("")},
+ "ptmx": {Mode: os.ModeSymlink | 0777},
+ "pts": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ptmx": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+ "random": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "shm": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "stderr": {Mode: os.ModeSymlink | 0777},
+ "stdin": {Mode: os.ModeSymlink | 0777},
+ "stdout": {Mode: os.ModeSymlink | 0777},
+ "tty": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "urandom": {Mode: os.ModeDevice | os.ModeCharDevice | 0444},
+ "zero": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+
+ "etc": {Mode: os.ModeDir | 0755, Dir: dir{
+ "passwd": {Mode: 0600,
+ Data: new("u0_a1:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")},
+ "group": {Mode: 0600,
+ Data: new("hakurei:x:65534:\n")},
+ }},
+
+ "lib64": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777},
+ }},
+
+ "run": {Mode: os.ModeDir | 0755, Dir: dir{
+ "user": {Mode: os.ModeDir | 0755, Dir: dir{
+ "65534": {Mode: os.ModeDir | 0700, Dir: dir{
+ "bus": {Mode: os.ModeSocket | 0775},
+ "wayland-0": {Mode: os.ModeSocket | 070},
+ "pulse": {Mode: os.ModeDir | 0700, Dir: dir{
+ "native": {Mode: os.ModeSocket | 0777},
+ }},
+ }},
+ }},
+ }},
+
+ "tmp": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+
+ "lib": {Mode: os.ModeDir | 0755},
+ "proc": {Mode: os.ModeDir | 0555},
+ }},
+
+ Mount: []*mountinfo.Entry{
+ r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110000,gid=110000,inode64"),
+ r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"),
+ r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110000,gid=110000,inode64"),
+ r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110000,gid=110000,inode64"),
+ r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"),
+ r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
+ r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110000,gid=110000,inode64"),
+ r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110000,gid=110000,inode64"),
+ r("/", "/tmp", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110000,gid=110000,inode64"),
+ r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110000,gid=110000,inode64"),
+ r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110000,gid=110000,inode64"),
+ r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ },
+
+ Seccomp: true,
+
+ TrySocket: "/tmp/.X11-unix/X0",
+ ErrnoAbstract: syscall.ECONNREFUSED,
+ ErrnoPathname: syscall.ENOENT,
+}.register("simple")
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/sum.go b/cmd/hakurei/testsuite/sandbox/testdata/sum.go
new file mode 100644
index 00000000..e4e8643a
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/sum.go
@@ -0,0 +1,22 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "crypto/sha512"
+ "encoding/base64"
+ "strconv"
+)
+
+// sum decodes s as [base64.StdEncoding] and panics if it is invalid or
+// unexpectedly sized.
+func sum(s string) [sha512.Size]byte {
+ p, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ panic(err)
+ }
+ if len(p) != sha512.Size {
+ panic("unexpected checksum sized " + strconv.Itoa(len(p)))
+ }
+ return ([sha512.Size]byte)(p)
+}
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go b/cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go
new file mode 100644
index 00000000..bd751105
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go
@@ -0,0 +1,9 @@
+//go:build testsuite || tester
+
+package testdata
+
+var (
+ SumPD = sum("xpiwgf+Vev4XptlDdFN9N/KmP2+d112nVGVCQHqeMkduvaMxK6d4XX9hhUK8+vJ8on3MLd26hSBp0ovP6MrTmg==")
+ sumSimple = sum("6IApjfK9Z1HQBA/CG8DtTAD5XcDXulBsJE2LjPaGbbqO9KMylvKHtmzMwdeOlwJll/hMx97BVz4UiWD701zXNQ==")
+ sumTTY = sum("C3YAdHbByeJdv2dMKf32CaFlanAGPkkydlThtTYK09oG4aPjK/gOlhxVFq2D1Lnn6b3odqk3l+J2J9JVXCWFiw==")
+)
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go b/cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go
new file mode 100644
index 00000000..1691828f
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go
@@ -0,0 +1,9 @@
+//go:build testsuite || tester
+
+package testdata
+
+var (
+ SumPD = sum("QzzpuREoLW3MgCkxn7ebgWtg1aeV7I/JQ0TdAnYU1o8CMWapG7iB+q7u3Sbj2JR04UHlppqX6TuJhMqPFJmZgA==")
+ sumSimple = sum("eTGFOKPchRMUtr2W8Q1YYayyqn4Ty43gYZ0PanZwnWfwHvP9Z+GVhisC+XEeW3abxNHrT8DfxBpyPInJaKkylw==")
+ sumTTY = sum("zx9NyHQ2uo7JXSaLZjpjl7sLSlrGTYVX5sxSnYsPb2Xa06krYu0p2F7unG3eEmd1ek0PhgMuikXKG86t+jTPXg==")
+)
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/testdata.go b/cmd/hakurei/testsuite/sandbox/testdata/testdata.go
new file mode 100644
index 00000000..bdb5178e
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/testdata.go
@@ -0,0 +1,125 @@
+//go:build testsuite || tester
+
+// Package testdata holds sandbox inspection test cases.
+package testdata
+
+import (
+ "crypto/sha512"
+ "iter"
+ "log"
+ "strconv"
+ "syscall"
+
+ "hakurei.app/check"
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+// A TestCase represents a named test case that may be requested by the caller.
+type TestCase struct {
+ // Configuration of the inspected container.
+ Hakurei hst.Config
+ // Checksum of expected seccomp filter program.
+ Sum [sha512.Size]byte
+
+ // Expected environment. Skipped if nil.
+ Env []string `json:"env,omitempty"`
+ // Expected root filesystem. Skipped if nil.
+ FS *testsuite.FS `json:"fs,omitempty"`
+ // Expected mountinfo records. Skipped if nil.
+ Mount []*mountinfo.Entry `json:"mount,omitempty"`
+ // Whether to run seccomp checks.
+ Seccomp bool `json:"seccomp,omitempty"`
+
+ // Name of pathname and abstract sockets to attempt.
+ TrySocket string `json:"try_socket,omitempty"`
+ // Errno to expect attempting to reach the abstract socket.
+ ErrnoAbstract syscall.Errno `json:"errno_abstract,omitempty"`
+ // Errno to expect attempting to reach the pathname socket.
+ ErrnoPathname syscall.Errno `json:"errno_pathname,omitempty"`
+}
+
+// testCases hold all named test cases.
+var testCases map[string]TestCase
+
+// fc returns c wrapped in its JSON adapter.
+func fc(c hst.FilesystemConfig) hst.FilesystemConfigJSON {
+ return hst.FilesystemConfigJSON{
+ FilesystemConfig: c,
+ }
+}
+
+// ignore is the magic string for a mountinfo field to be ignored.
+const ignore = "//ignore"
+
+type dir = map[string]*testsuite.FS
+
+// r returns the address of a [mountinfo.Entry].
+func r(
+ root, target, vfsOptstr string,
+ fsType, source, fsOptstr string,
+) *mountinfo.Entry {
+ return &mountinfo.Entry{
+ ID: -1,
+ Parent: -1,
+ Root: root,
+ Target: target,
+ VfsOptstr: vfsOptstr,
+ FsType: fsType,
+ Source: source,
+ FsOptstr: fsOptstr,
+ }
+}
+
+var (
+ // fcLinker is the dynamic linker symlink.
+ fcLinker = fc(&hst.FSLink{
+ Target: fhs.AbsRoot.Append("lib64", "ld-linux-x86-64.so.2"),
+ Linkname: "../lib/x86_64-linux-gnu/ld-linux-x86-64.so.2",
+ })
+ // fcLib is the dynamic library bind mount.
+ fcLib = fc(&hst.FSBind{Source: fhs.AbsRoot.Append("lib")})
+
+ // absTestHelper is the absolute pathname of the test helper program.
+ absTestHelper = hst.AbsPrivateTmp.Append("test-helper")
+ // fcTestHelper is the test helper bind mount.
+ fcTestHelper = fc(&hst.FSBind{
+ Target: absTestHelper,
+ Source: check.MustAbs("/opt/test-helper/bin/tester"),
+ })
+)
+
+// register adds a test case to testCases.
+func (c TestCase) register(name string) (_ struct{}) {
+ if testCases == nil {
+ testCases = make(map[string]TestCase)
+ }
+
+ if _, ok := testCases[name]; ok {
+ panic("attempting to register " + strconv.Quote(name) + " twice")
+ }
+ testCases[name] = c
+ return
+}
+
+// Get returns the named test case, or terminates the program if name is invalid.
+func Get(name string) TestCase {
+ tc, ok := testCases[name]
+ if !ok {
+ log.Fatalf("invalid test case %q", name)
+ }
+ return tc
+}
+
+// All returns an iterator over all named test cases.
+func All() iter.Seq2[string, TestCase] {
+ return func(yield func(string, TestCase) bool) {
+ for name, tc := range testCases {
+ if !yield(name, tc) {
+ return
+ }
+ }
+ }
+}
diff --git a/cmd/hakurei/testsuite/sandbox/testdata/tty.go b/cmd/hakurei/testsuite/sandbox/testdata/tty.go
new file mode 100644
index 00000000..2a3ba76e
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/testdata/tty.go
@@ -0,0 +1,145 @@
+//go:build testsuite || tester
+
+package testdata
+
+import (
+ "os"
+
+ "hakurei.app/fhs"
+ "hakurei.app/hst"
+ "hakurei.app/internal/testsuite"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+var _ = TestCase{
+ Hakurei: hst.Config{
+ ID: "app.hakurei.sample.tty",
+ Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus | hst.EX11),
+ Identity: 2,
+
+ Container: &hst.ContainerConfig{
+ Hostname: "hakurei-sample-tty",
+
+ Filesystem: []hst.FilesystemConfigJSON{
+ fcLinker,
+ fcLib,
+ fcTestHelper,
+ },
+
+ Username: "u0_a2",
+ Shell: fhs.AbsUsrBin.Append("bash"),
+ Home: hst.AbsPrivateTmp,
+ Path: absTestHelper,
+ Args: []string{"tester", "tty"},
+
+ Flags: hst.FHostNet | hst.FHostAbstract |
+ hst.FTty | hst.FShareRuntime,
+ },
+ },
+
+ // 0, PresetExt | PresetDenyNS | PresetDenyDevel
+ Sum: sumTTY,
+
+ Env: []string{
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus",
+ "DISPLAY=:0",
+ "HOME=/.hakurei",
+ "SHELL=/usr/bin/bash",
+ "TERM=xterm",
+ "USER=u0_a2",
+ "WAYLAND_DISPLAY=wayland-0",
+ "XDG_RUNTIME_DIR=/run/user/65534",
+ "XDG_SESSION_CLASS=user",
+ "XDG_SESSION_TYPE=wayland",
+ "PULSE_SERVER=unix:/run/user/65534/pulse/native",
+ },
+
+ FS: &testsuite.FS{Dir: dir{
+ ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{
+ "test-helper": {Mode: 0755},
+ }},
+
+ "dev": {Mode: os.ModeDir | 0755, Dir: dir{
+ "core": {Mode: os.ModeSymlink | 0777},
+ "fd": {Mode: os.ModeSymlink | 0777},
+ "full": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "mqueue": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "null": {Mode: os.ModeDevice | os.ModeCharDevice | 0666, Data: new("")},
+ "ptmx": {Mode: os.ModeSymlink | 0777},
+ "pts": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ptmx": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+ "random": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "shm": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}},
+ "stderr": {Mode: os.ModeSymlink | 0777},
+ "stdin": {Mode: os.ModeSymlink | 0777},
+ "stdout": {Mode: os.ModeSymlink | 0777},
+ "tty": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ "urandom": {Mode: os.ModeDevice | os.ModeCharDevice | 0444},
+ "zero": {Mode: os.ModeDevice | os.ModeCharDevice | 0666},
+ }},
+
+ "etc": {Mode: os.ModeDir | 0755, Dir: dir{
+ "passwd": {Mode: 0600,
+ Data: new("u0_a2:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")},
+ "group": {Mode: 0600,
+ Data: new("hakurei:x:65534:\n")},
+ }},
+
+ "lib64": {Mode: os.ModeDir | 0755, Dir: dir{
+ "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777},
+ }},
+
+ "run": {Mode: os.ModeDir | 0755, Dir: dir{
+ "user": {Mode: os.ModeDir | 0755, Dir: dir{
+ "65534": {Mode: os.ModeDir | 0770, Dir: dir{
+ "bus": {Mode: os.ModeSocket | 0775},
+ "wayland-0": {Mode: os.ModeSocket | 070},
+ "pulse": {Mode: os.ModeDir | 0700, Dir: dir{
+ "native": {Mode: os.ModeSocket | 0777},
+ }},
+ }},
+ }},
+ }},
+
+ "tmp": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{
+ ".X11-unix": {Mode: os.ModeDir | 0755, Dir: dir{
+ "X0": {Mode: os.ModeSocket | 0775},
+ }},
+ }},
+
+ "lib": {Mode: os.ModeDir | 0755},
+ "proc": {Mode: os.ModeDir | 0555},
+ }},
+
+ Mount: []*mountinfo.Entry{
+ r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110001,gid=110001,inode64"),
+ r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"),
+ r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110001,gid=110001,inode64"),
+ r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110001,gid=110001,inode64"),
+ r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore),
+ r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"),
+ r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
+ r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110001,gid=110001,inode64"),
+ r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110001,gid=110001,inode64"),
+ r("/tmp/hakurei.0/runtime/2", "/run/user/65534", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/", "/tmp", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110001,gid=110001,inode64"),
+ r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110001,gid=110001,inode64"),
+ r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110001,gid=110001,inode64"),
+ r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/tmp/.X11-unix", "/tmp/.X11-unix", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore),
+ },
+
+ Seccomp: true,
+
+ TrySocket: "/tmp/.X11-unix/X0",
+}.register("tty")
diff --git a/cmd/hakurei/testsuite/sandbox/tester/main.go b/cmd/hakurei/testsuite/sandbox/tester/main.go
new file mode 100644
index 00000000..452712d9
--- /dev/null
+++ b/cmd/hakurei/testsuite/sandbox/tester/main.go
@@ -0,0 +1,224 @@
+//go:build tester
+
+// The sandbox tester runs within a cmd/hakurei container and validates its
+// state. Since the test environment is relatively predictable, the tester can
+// make various assumptions about the host.
+package main
+
+import (
+ "errors"
+ "log"
+ "net"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+
+ "hakurei.app/cmd/hakurei/testsuite/sandbox/testdata"
+ "hakurei.app/internal/testsuite/mountinfo"
+)
+
+//#include <sys/quota.h>
+import "C"
+
+// mustAbs returns s, or terminates the program if s is not absolute.
+func mustAbs(s string) string {
+ if !filepath.IsAbs(s) {
+ log.Fatalf("%q is not absolute", s)
+ }
+ return s
+}
+
+func main() {
+ log.SetFlags(0)
+ log.SetPrefix("tester: ")
+
+ if len(os.Args) != 2 {
+ log.Fatal("tester requires 1 argument")
+ }
+ want := testdata.Get(os.Args[1])
+ log.SetPrefix("tester: " + os.Args[1] + " ")
+
+ checkWritableDirPaths := []string{
+ "/dev/shm",
+ "/tmp",
+ os.Getenv("XDG_RUNTIME_DIR"),
+ }
+ for _, a := range checkWritableDirPaths {
+ pathname := filepath.Join(mustAbs(a), ".hakurei-check")
+ if err := os.WriteFile(pathname, make([]byte, 1<<8), 0600); err != nil {
+ log.Fatalf("[FAIL] %s", err)
+ } else if err = os.Remove(pathname); err != nil {
+ log.Fatalf("[FAIL] %s", err)
+ } else {
+ log.Printf("[ OK ] %s is writable", a)
+ }
+ }
+
+ if want.Env != nil {
+ var (
+ fail bool
+ i int
+ got string
+ )
+ for i, got = range os.Environ() {
+ if i == len(want.Env) {
+ log.Fatalf("got more than %d environment variables", len(want.Env))
+ }
+ if got != want.Env[i] {
+ fail = true
+ log.Printf("[FAIL] %s", got)
+ } else {
+ log.Printf("[ OK ] %s", got)
+ }
+ }
+
+ i++
+ if i != len(want.Env) {
+ log.Fatalf("got %d environment variables, want %d", i, len(want.Env))
+ }
+
+ if fail {
+ log.Fatalf("[FAIL] some environment variables did not match")
+ }
+ } else {
+ log.Printf("[SKIP] skipping environ check")
+ }
+
+ if want.FS != nil {
+ if err := want.FS.Compare(log.Printf, ".", os.DirFS("/")); err != nil {
+ log.Fatalf("%v", err)
+ }
+ } else {
+ log.Printf("[SKIP] skipping fs check")
+ }
+
+ if want.Mount != nil {
+ var fail bool
+
+ m, err := mountinfo.Open("")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ i := 0
+ var ent mountinfo.Entry
+ for m.Next() {
+ m.Copy(&ent)
+
+ if i == len(want.Mount) {
+ log.Fatalf("got more than %d entries", i)
+ }
+ if !ent.EqualWithIgnore(want.Mount[i], "//ignore") {
+ fail = true
+ log.Printf("[FAIL] %s", &ent)
+ } else {
+ log.Printf("[ OK ] %s", &ent)
+ }
+
+ i++
+ }
+ if err = m.Err(); err != nil {
+ log.Fatalf("%v", err)
+ }
+
+ if i != len(want.Mount) {
+ log.Fatalf("got %d entries, want %d", i, len(want.Mount))
+ }
+
+ if fail {
+ log.Fatalf("[FAIL] some mount points did not match")
+ }
+ } else {
+ log.Printf("[SKIP] skipping mounts check")
+ }
+
+ if want.Seccomp {
+ const NULL = 0
+
+ for _, tc := range []struct {
+ name string
+ errno syscall.Errno
+
+ trap, a1, a2, a3, a4, a5, a6 uintptr
+ }{
+ {"syslog", syscall.EPERM, syscall.SYS_SYSLOG, 0, NULL, NULL, NULL, NULL, NULL},
+ {"acct", syscall.EPERM, syscall.SYS_ACCT, 0, NULL, NULL, NULL, NULL, NULL},
+ {"quotactl", syscall.EPERM, syscall.SYS_QUOTACTL, C.Q_GETQUOTA, NULL, uintptr(os.Getuid()), NULL, NULL, NULL},
+ {"add_key", syscall.EPERM, syscall.SYS_ADD_KEY, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"keyctl", syscall.EPERM, syscall.SYS_KEYCTL, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"request_key", syscall.EPERM, syscall.SYS_REQUEST_KEY, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"move_pages", syscall.EPERM, syscall.SYS_MOVE_PAGES, uintptr(os.Getpid()), NULL, NULL, NULL, NULL, NULL},
+ {"mbind", syscall.EPERM, syscall.SYS_MBIND, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"get_mempolicy", syscall.EPERM, syscall.SYS_GET_MEMPOLICY, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"set_mempolicy", syscall.EPERM, syscall.SYS_SET_MEMPOLICY, NULL, NULL, NULL, NULL, NULL, NULL},
+ {"migrate_pages", syscall.EPERM, syscall.SYS_MIGRATE_PAGES, NULL, NULL, NULL, NULL, NULL, NULL},
+ } {
+ if _, _, errno := syscall.Syscall6(tc.trap, tc.a1, tc.a2, tc.a3, tc.a4, tc.a5, tc.a6); errno != tc.errno {
+ log.Fatalf("[FAIL] %s: %v, want %v", tc.name, errno, tc.errno)
+ }
+ log.Printf("[ OK ] %s: %v", tc.name, tc.errno)
+ }
+ } else {
+ log.Printf("[SKIP] skipping seccomp check")
+ }
+
+ if want.TrySocket != "" {
+ retry:
+ abstractConn, abstractErr := net.Dial("unix", "@"+want.TrySocket)
+ pathnameConn, pathnameErr := net.Dial("unix", want.TrySocket)
+ ok := true
+
+ if abstractErr == nil {
+ if err := abstractConn.Close(); err != nil {
+ ok = false
+ log.Printf("Close: %v", err)
+ }
+ }
+ if pathnameErr == nil {
+ if err := pathnameConn.Close(); err != nil {
+ ok = false
+ log.Printf("Close: %v", err)
+ }
+ }
+
+ if errors.Is(
+ abstractErr,
+ syscall.EAGAIN,
+ ) || errors.Is(
+ pathnameErr,
+ syscall.EAGAIN,
+ ) {
+ goto retry
+ }
+
+ abstractWantErr := error(want.ErrnoAbstract)
+ pathnameWantErr := error(want.ErrnoPathname)
+ if want.ErrnoAbstract == 0 {
+ abstractWantErr = nil
+ }
+ if want.ErrnoPathname == 0 {
+ pathnameWantErr = nil
+ }
+
+ if !errors.Is(abstractErr, abstractWantErr) {
+ ok = false
+ log.Printf("abstractErr: %v, want %v", abstractErr, abstractWantErr)
+ }
+ if !errors.Is(pathnameErr, pathnameWantErr) {
+ ok = false
+ log.Printf("pathnameErr: %v, want %v", pathnameErr, pathnameWantErr)
+ }
+
+ if !ok {
+ os.Exit(1)
+ }
+ }
+
+ s := make(chan os.Signal, 1)
+ signal.Notify(s, syscall.SIGTERM)
+ if _, err := os.Stdout.Write(make([]byte, 8)); err != nil {
+ log.Fatalf("cannot notify testsuite: %v", err)
+ }
+ <-s
+}
diff --git a/cmd/hakurei/testsuite/test.py b/cmd/hakurei/testsuite/test.py
new file mode 100644
index 00000000..98f08275
--- /dev/null
+++ b/cmd/hakurei/testsuite/test.py
@@ -0,0 +1,315 @@
+import json
+import shlex
+
+q = shlex.quote
+NODE_GROUPS = ["nodes", "floating_nodes"]
+
+
+def swaymsg(command: str = "", succeed=True, type="command"):
+ assert command != "" or type != "command", "Must specify command or type"
+ shell = q(f"swaymsg -t {q(type)} -- {q(command)}")
+ with machine.nested(f"sending swaymsg {shell!r}" + " (allowed to fail)" * (not succeed)):
+ ret = (machine.succeed if succeed else machine.execute)(
+ f"su - alice -c {shell}"
+ )
+
+ # execute also returns a status code, but disregard.
+ if not succeed:
+ _, ret = ret
+
+ if not succeed and not ret:
+ return None
+
+ parsed = json.loads(ret)
+ return parsed
+
+
+def walk(tree):
+ yield tree
+ for group in NODE_GROUPS:
+ for node in tree.get(group, []):
+ yield from walk(node)
+
+
+def wait_for_window(pattern):
+ def func(last_chance):
+ nodes = (node["name"] for node in walk(swaymsg(type="get_tree")))
+
+ if last_chance:
+ nodes = list(nodes)
+ machine.log(f"Last call! Current list of windows: {nodes}")
+
+ return any(pattern in name for name in nodes)
+
+ retry(func)
+
+
+def collect_state_ui(name):
+ swaymsg(f"exec hakurei ps > '/tmp/{name}.ps'")
+ machine.wait_for_file(f"/tmp/{name}.ps")
+ machine.copy_from_vm(f"/tmp/{name}.ps", "")
+ swaymsg(f"exec hakurei --json ps > '/tmp/{name}.json'")
+ machine.wait_for_file(f"/tmp/{name}.json")
+ machine.copy_from_vm(f"/tmp/{name}.json", "")
+ machine.screenshot(name)
+
+
+def check_state(name, enablements):
+ instances = json.loads(machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei --json ps"))
+ if len(instances) != 1:
+ raise Exception(f"unexpected state length {len(instances)}")
+ instance = instances[0]
+
+ command = f"{name}-start"
+ if not (instance['container']['path'].startswith("/nix/store/")) or not (instance['container']['path'].endswith(command)):
+ raise Exception(f"unexpected path {instance['path']}")
+
+ if len(instance['container']['args']) != 1 or instance['container']['args'][0] != command:
+ raise Exception(f"unexpected args {instance['args']}")
+
+ if instance['enablements'] != enablements:
+ raise Exception(f"unexpected enablements {instance['enablements']['enablements']}")
+
+
+def hakurei(command):
+ swaymsg(f"exec hakurei {command}")
+
+
+start_all()
+machine.wait_for_unit("multi-user.target")
+
+# To check hakurei's version:
+print(machine.succeed("sudo -u alice -i hakurei version"))
+
+# Wait for Sway to complete startup:
+machine.wait_for_file("/run/user/1000/wayland-1")
+machine.wait_for_file("/tmp/sway-ipc.sock")
+
+# Run hakurei Go tests outside of nix build in the background:
+swaymsg("exec hakurei-test")
+
+# Deny unmapped uid:
+denyOutput = machine.fail("sudo -u untrusted -i hakurei exec &>/dev/stdout")
+print(denyOutput)
+denyOutputVerbose = machine.fail("sudo -u untrusted -i hakurei -v exec &>/dev/stdout")
+print(denyOutputVerbose)
+
+# Direct hsu call:
+userid = machine.succeed("sudo -u alice -i hsu")
+if userid != "0":
+ raise Exception(f"unexpected userid: {userid}")
+
+# Verify hsu fault behaviour:
+if denyOutput != "hsu: uid 1001 is not in the hsurc file\n":
+ raise Exception(f"unexpected deny output:\n{denyOutput}")
+if denyOutputVerbose != "hsu: uid 1001 is not in the hsurc file\nhakurei: *cannot retrieve user id from setuid wrapper: current user is not in the hsurc file\n":
+ raise Exception(f"unexpected deny verbose output:\n{denyOutputVerbose}")
+
+# Verify timeout behaviour:
+machine.succeed('sudo -u alice -i hakurei-check-linger-timeout > /var/tmp/linger-stdout 2> /var/tmp/linger-stderr || (cat /var/tmp/linger-stderr; false)')
+linger_stdout = machine.succeed("cat /var/tmp/linger-stdout")
+linger_stderr = machine.succeed("cat /var/tmp/linger-stderr")
+if linger_stdout != "":
+ raise Exception(f"unexpected stdout: {linger_stdout}")
+if linger_stderr != "init: timeout exceeded waiting for lingering processes\n":
+ raise Exception(f"unexpected stderr: {linger_stderr}")
+
+check_offset = 0
+
+
+def hakurei_identity(offset):
+ return 1+check_offset+offset
+
+
+# Start hakurei permissive defaults outside Wayland session:
+print(machine.succeed("sudo -u alice -i hakurei -v exec -a 0 touch /tmp/pd-bare-ok"))
+machine.wait_for_file("/tmp/hakurei.0/tmpdir/0/pd-bare-ok")
+
+# Verify silent output permissive defaults:
+output = machine.succeed("sudo -u alice -i hakurei exec -a 0 true &>/dev/stdout")
+if output != "":
+ raise Exception(f"unexpected output\n{output}")
+
+# Verify silent output permissive defaults signal:
+def silent_output_interrupt(flags):
+ swaymsg("exec foot")
+ wait_for_window("alice@machine")
+ # identity 0 does not have home-manager
+ machine.send_chars(f"exec hakurei exec {flags}-a 0 sh -c 'export PATH=/run/current-system/sw/bin:$PATH && touch /tmp/pd-silent-ready && sleep infinity' &>/tmp/pd-silent\n")
+ machine.wait_for_file("/tmp/hakurei.0/tmpdir/0/pd-silent-ready")
+ machine.succeed("rm /tmp/hakurei.0/tmpdir/0/pd-silent-ready")
+ machine.send_key("ctrl-c")
+ machine.wait_until_fails("pgrep foot")
+ machine.wait_until_fails(f"pgrep -u alice -f 'hakurei exec {flags}-a 0 '")
+ output = machine.succeed("cat /tmp/pd-silent && rm /tmp/pd-silent")
+ if output != "":
+ raise Exception(f"unexpected output\n{output}")
+
+
+silent_output_interrupt("")
+silent_output_interrupt("--dbus ") # this one is especially painful as it maintains a helper
+silent_output_interrupt("--wayland -X --dbus --pulse ")
+
+# Verify graceful failure on bad Wayland display name:
+print(machine.fail("sudo -u alice -i hakurei -v exec --wayland true"))
+
+# Start hakurei permissive defaults within Wayland session:
+hakurei('-v exec --wayland --dbus --dbus-log notify-send -a "NixOS Tests" "Test notification" "Notification from within sandbox." && touch /tmp/dbus-ok')
+machine.wait_for_file("/tmp/dbus-ok")
+collect_state_ui("dbus_notify_exited")
+# not in pid namespace, verify termination
+machine.wait_until_fails("pgrep xdg-dbus-proxy")
+machine.succeed("pkill -9 mako")
+
+# Check revert type selection:
+hakurei("-v exec --wayland -X --dbus --pulse -u p0 foot && touch /tmp/p0-exit-ok")
+wait_for_window("p0@machine")
+print(machine.succeed("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000"))
+hakurei("-v exec --wayland -X --dbus --pulse -u p1 foot && touch /tmp/p1-exit-ok")
+wait_for_window("p1@machine")
+print(machine.succeed("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000"))
+machine.send_chars("exit\n")
+machine.wait_for_file("/tmp/p1-exit-ok")
+# Verify acl is kept alive:
+print(machine.succeed("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000"))
+machine.send_chars("exit\n")
+machine.wait_for_file("/tmp/p0-exit-ok")
+machine.fail("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000")
+
+# Check invalid identifier fd behaviour:
+machine.fail('echo \'{"container":{"shell":"/proc/nonexistent","home":"/proc/nonexistent","path":"/proc/nonexistent"}}\' | sudo -u alice -i hakurei -v run --identifier-fd 32767 - 2>&1 | tee > /tmp/invalid-identifier-fd')
+machine.wait_for_file("/tmp/invalid-identifier-fd")
+print(machine.succeed('grep "^hakurei: cannot write identifier: bad file descriptor$" /tmp/invalid-identifier-fd'))
+
+# Check interrupt shim behaviour:
+swaymsg("exec sh -c 'ne-foot; echo -n $? > /tmp/monitor-exit-code'")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.succeed("pkill -INT -f 'hakurei -v run '")
+machine.wait_until_fails("pgrep foot")
+machine.wait_for_file("/tmp/monitor-exit-code")
+interrupt_exit_code = int(machine.succeed("cat /tmp/monitor-exit-code"))
+if interrupt_exit_code != 230:
+ raise Exception(f"unexpected exit code {interrupt_exit_code}")
+
+# Check interrupt shim behaviour immediate termination:
+swaymsg("exec sh -c 'ne-foot-immediate; echo -n $? > /tmp/monitor-exit-code'")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.succeed("pkill -INT -f 'hakurei -v run '")
+machine.wait_until_fails("pgrep foot")
+machine.wait_for_file("/tmp/monitor-exit-code")
+interrupt_exit_code = int(machine.succeed("cat /tmp/monitor-exit-code"))
+if interrupt_exit_code != 254:
+ raise Exception(f"unexpected exit code {interrupt_exit_code}")
+
+# Check shim SIGCONT from unexpected process behaviour:
+swaymsg("exec sh -c 'ne-foot &> /tmp/shim-cont-unexpected-pid'")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.succeed("pkill -CONT -f 'hakurei shim'")
+machine.succeed("pkill -INT -f 'hakurei -v run '")
+machine.wait_until_fails("pgrep foot")
+machine.wait_for_file("/tmp/shim-cont-unexpected-pid")
+print(machine.succeed('grep "shim: got SIGCONT from unexpected process$" /tmp/shim-cont-unexpected-pid'))
+
+# Check setscheduler:
+sched_unset = int(machine.succeed("sudo -u alice -i hakurei -v exec cat /proc/self/sched | grep '^policy' | tr -d ' ' | cut -d ':' -f 2"))
+if sched_unset != 0:
+ raise Exception(f"unexpected unset policy: {sched_unset}")
+sched_idle = int(machine.succeed("sudo -u alice -i hakurei -v exec --policy=idle cat /proc/self/sched | grep '^policy' | tr -d ' ' | cut -d ':' -f 2"))
+if sched_idle != 5:
+ raise Exception(f"unexpected idle policy: {sched_idle}")
+sched_rr = int(machine.succeed("sudo -u alice -i hakurei -v exec --policy=rr cat /proc/self/sched | grep '^policy' | tr -d ' ' | cut -d ':' -f 2"))
+if sched_rr != 2:
+ raise Exception(f"unexpected round-robin policy: {sched_idle}")
+
+# Start app (foot) with Wayland enablement:
+swaymsg("exec ne-foot")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.send_chars("clear; wayland-info && touch /var/tmp/client-ok\n")
+machine.wait_for_file("/var/tmp/client-ok")
+collect_state_ui("foot_wayland")
+check_state("ne-foot", {"wayland": True})
+# Verify lack of acl on XDG_RUNTIME_DIR:
+machine.fail(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(0) + 10000}")
+machine.send_chars("exit\n")
+machine.wait_until_fails("pgrep foot")
+machine.fail(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(0) + 10000}")
+
+# Test pipewire-pulse:
+swaymsg("exec pa-foot")
+wait_for_window(f"u0_a{hakurei_identity(1)}@machine")
+machine.send_chars("clear; pactl info && touch /var/tmp/pulse-ok\n")
+machine.wait_for_file("/var/tmp/pulse-ok")
+collect_state_ui("pulse_wayland")
+check_state("pa-foot", {"wayland": True, "pipewire": True})
+machine.fail("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +")
+machine.send_chars("exit\n")
+machine.wait_until_fails("pgrep foot")
+machine.wait_until_fails("pgrep -x hakurei")
+machine.succeed("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +")
+# Test PipeWire SecurityContext:
+machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v exec --pulse pactl info")
+machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v exec --pulse pactl set-sink-mute @DEFAULT_SINK@ toggle")
+# Test PipeWire direct access:
+machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 pw-dump")
+machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v exec --pipewire pw-dump")
+
+# Test XWayland (foot does not support X):
+swaymsg("exec x11-alacritty")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.send_chars("clear; glinfo && touch /var/tmp/x11-ok\n")
+machine.wait_for_file("/var/tmp/x11-ok")
+collect_state_ui("alacritty_x11")
+check_state("x11-alacritty", {"x11": True})
+machine.send_chars("exit\n")
+machine.wait_until_fails("pgrep alacritty")
+
+# Start app (foot) with direct Wayland access:
+swaymsg("exec da-foot")
+wait_for_window(f"u0_a{hakurei_identity(3)}@machine")
+machine.send_chars("clear; wayland-info && touch /var/tmp/direct-ok\n")
+collect_state_ui("foot_direct")
+machine.wait_for_file("/var/tmp/direct-ok")
+check_state("da-foot", {"wayland": True})
+# Verify acl on XDG_RUNTIME_DIR:
+print(machine.succeed(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(3) + 10000}"))
+machine.send_chars("exit\n")
+machine.wait_until_fails("pgrep foot")
+# Verify acl cleanup on XDG_RUNTIME_DIR:
+machine.wait_until_fails(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(3) + 10000}")
+
+# Test syscall filter:
+print(machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 strace-failure"))
+
+# Start app (foot) with Wayland enablement from a terminal:
+swaymsg("exec foot $SHELL -c '(ne-foot) & disown && exec $SHELL'")
+wait_for_window(f"u0_a{hakurei_identity(0)}@machine")
+machine.send_chars("clear; wayland-info && touch /var/tmp/term-ok\n")
+machine.wait_for_file("/var/tmp/term-ok")
+machine.send_key("alt-h")
+machine.send_chars("clear; hakurei show $(hakurei ps --short) && touch /tmp/ps-show-ok && exec cat\n")
+machine.wait_for_file("/tmp/ps-show-ok")
+collect_state_ui("foot_wayland_term")
+check_state("ne-foot", {"wayland": True})
+machine.send_key("alt-l")
+machine.send_chars("exit\n")
+wait_for_window("alice@machine")
+machine.send_key("ctrl-c")
+machine.wait_until_fails("pgrep foot")
+
+# Exit Sway and verify process exit status 0:
+machine.wait_until_fails("pgrep -x hakurei")
+swaymsg("exit", succeed=False)
+machine.wait_for_file("/tmp/sway-exit-ok")
+
+# Print hakurei share and rundir contents:
+print(machine.succeed("find /tmp/hakurei.0 "
+ + "-path '/tmp/hakurei.0/runtime/*/*' -prune -o "
+ + "-path '/tmp/hakurei.0/tmpdir/*/*' -prune -o "
+ + "-print"))
+print(machine.succeed("find /run/user/1000/hakurei"))
+machine.succeed("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +")
+
+# Verify go test status:
+machine.wait_for_file("/tmp/hakurei-test-done")
+print(machine.succeed("cat /tmp/hakurei-test.log"))
+machine.wait_for_file("/tmp/hakurei-test-ok")
diff --git a/cmd/sharefs/testsuite/main.go b/cmd/sharefs/testsuite/main.go
new file mode 100644
index 00000000..167875a1
--- /dev/null
+++ b/cmd/sharefs/testsuite/main.go
@@ -0,0 +1,119 @@
+//go:build testsuite
+
+// The sharefs test program checks cli behaviour and exercises the filesystem
+// implemented by cmd/sharefs using fs_mark.
+package main
+
+import (
+ "errors"
+ "log"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+
+ "hakurei.app/internal/testsuite"
+)
+
+// checkBadOpts invokes cmd/sharefs with the specified options and compares
+// the resulting error message.
+func checkBadOpts(cred *syscall.Credential, opts, want string) {
+ var buf strings.Builder
+ buf.Grow(len(want))
+
+ cmd := exec.Command(
+ "sharefs",
+ "-f",
+ "-o", "source=/etc,"+opts,
+ "/mnt",
+ )
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Pdeathsig: syscall.SIGKILL,
+ Credential: cred,
+ }
+ cmd.Stderr = &buf
+ err := cmd.Run()
+ if err == nil {
+ log.Fatalf("opts=%q, unexpected success", opts)
+ }
+ if e, ok := errors.AsType[*exec.ExitError](err); !ok {
+ log.Fatal(err)
+ } else if !e.Exited() {
+ log.Fatal(e)
+ }
+
+ if got := buf.String(); got != want {
+ log.Fatalf("opts=%q\n\t got:%q\n\twant:%q", opts, got, want)
+ }
+}
+
+func main() {
+ go testsuite.ReceiveSignals()
+
+ cred := syscall.Credential{Uid: 1000, Gid: 100}
+ if err := os.Mkdir("result", 0755); err != nil {
+ log.Fatal(err)
+ }
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+
+ testsuite.MustRun(
+ nil, nil,
+ "fs_mark",
+ "-v",
+ "-d", "/sdcard/fs_mark",
+ "-l", "result/fs_mark.log",
+ )
+ }()
+
+ log.Println("checking malformed setuid/setgid representation")
+ checkBadOpts(&cred, "setuid=ff", "sharefs: invalid value for option setuid\n")
+ checkBadOpts(&cred, "setgid=ff", "sharefs: invalid value for option setgid\n")
+
+ log.Println("checking bounds check for setuid/setgid")
+ checkBadOpts(&cred, "setuid=0", "sharefs: invalid value for option setuid\n")
+ checkBadOpts(&cred, "setgid=0", "sharefs: invalid value for option setgid\n")
+ checkBadOpts(&cred, "setuid=-1", "sharefs: invalid value for option setuid\n")
+ checkBadOpts(&cred, "setgid=-1", "sharefs: invalid value for option setgid\n")
+
+ log.Println("checking non-root setuid/setgid")
+ checkBadOpts(&cred, "setuid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
+ checkBadOpts(&cred, "setgid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
+ checkBadOpts(&cred, "setuid=1023,setgid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n")
+ checkBadOpts(&cred, "mkdir", "sharefs: mkdir has no effect when not starting as root\n")
+
+ log.Println("checking root without setuid/setgid")
+ checkBadOpts(nil, "allow_other", "sharefs: setuid and setgid must not be 0\n")
+ checkBadOpts(nil, "setuid=1023", "sharefs: setuid and setgid must not be 0\n")
+ checkBadOpts(nil, "setgid=1023", "sharefs: setuid and setgid must not be 0\n")
+
+ log.Println("verifying mount point")
+ if err := os.Remove("/mnt"); err != nil {
+ log.Fatal(err)
+ }
+
+ log.Println("checking unprivileged mount/unmount")
+ testsuite.MustRun(&cred, nil, "mkdir", "/tmp/sdcard", "/tmp/persistent")
+ testsuite.MustRun(&cred, nil, "sharefs", "-o", "source=/tmp/persistent", "/tmp/sdcard")
+ testsuite.MustRun(&cred, nil, "touch", "/tmp/sdcard/check")
+ testsuite.MustRun(&cred, nil, "umount", "/tmp/sdcard")
+ testsuite.MustRun(&cred, nil, "rm", "/tmp/persistent/check")
+ testsuite.MustRun(&cred, nil, "rmdir", "/tmp/sdcard", "/tmp/persistent")
+
+ log.Println("waiting for fs_mark to complete")
+ <-done
+
+ const backingDir = "/var/lib/sdcard"
+ sharefsCred := syscall.Credential{Uid: 1023, Gid: 1023}
+ log.Println("checking permissions")
+ testsuite.MustRun(&sharefsCred, nil, "touch", backingDir+"/fs_mark/.check")
+ testsuite.MustRun(&sharefsCred, nil, "rm", backingDir+"/fs_mark/.check")
+ testsuite.MustRun(&cred, nil, "rm", "-rf", "/sdcard/fs_mark")
+ if _, err := os.ReadDir(backingDir + "/fs_mark"); err == nil {
+ log.Fatal("fs_mark directory was not removed")
+ } else if !errors.Is(err, os.ErrNotExist) {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/sharefs/testsuite/raceattr.go b/cmd/sharefs/testsuite/raceattr.go
new file mode 100644
index 00000000..412cb2b3
--- /dev/null
+++ b/cmd/sharefs/testsuite/raceattr.go
@@ -0,0 +1,122 @@
+//go:build raceattr
+
+// The raceattr program reproduces vfs inode file attribute race.
+//
+// Even though libfuse high-level API presents the address of a struct stat
+// alongside struct fuse_context, file attributes are actually inherent to the
+// inode, instead of the specific call from userspace. The kernel implementation
+// in fs/fuse/xattr.c appears to make stale data in the inode (set by a previous
+// call) impossible or very unlikely to reach userspace via the stat family of
+// syscalls. However, when using default_permissions to have the VFS check
+// permissions, this race still happens, despite the resulting struct stat being
+// correct when overriding the check via capabilities otherwise.
+//
+// This program reproduces the failure, but because of its continuous nature, it
+// is provided independent of the vm integration test suite.
+package main
+
+import (
+ "context"
+ "flag"
+ "log"
+ "os"
+ "os/signal"
+ "runtime"
+ "sync"
+ "sync/atomic"
+ "syscall"
+)
+
+func newStatAs(
+ ctx context.Context, cancel context.CancelFunc,
+ n *atomic.Uint64, ok *atomic.Bool,
+ uid uint32, pathname string,
+ continuous bool,
+) func() {
+ return func() {
+ runtime.LockOSThread()
+ defer cancel()
+
+ if _, _, errno := syscall.Syscall(
+ syscall.SYS_SETUID, uintptr(uid),
+ 0, 0,
+ ); errno != 0 {
+ cancel()
+ log.Printf("cannot set uid to %d: %s", uid, errno)
+ }
+
+ var stat syscall.Stat_t
+ for {
+ if ctx.Err() != nil {
+ return
+ }
+
+ if err := syscall.Lstat(pathname, &stat); err != nil {
+ // SHAREFS_PERM_DIR not world executable, or
+ // SHAREFS_PERM_REG not world readable
+ if !continuous {
+ cancel()
+ }
+ ok.Store(true)
+ log.Printf("uid %d: %v", uid, err)
+ } else if stat.Uid != uid {
+ // appears to be unreachable
+ if !continuous {
+ cancel()
+ }
+ ok.Store(true)
+ log.Printf("got uid %d instead of %d", stat.Uid, uid)
+ }
+ n.Add(1)
+ }
+ }
+}
+
+func main() {
+ log.SetFlags(0)
+ log.SetPrefix("raceattr: ")
+
+ p := flag.String("target", "/sdcard/raceattr", "pathname of test file")
+ u0 := flag.Int("uid0", 1<<10-1, "first uid")
+ u1 := flag.Int("uid1", 1<<10-2, "second uid")
+ count := flag.Int("count", 1, "threads per uid")
+ continuous := flag.Bool("continuous", false, "keep running even after reproduce")
+ flag.Parse()
+
+ if os.Geteuid() != 0 {
+ log.Fatal("this program must run as root")
+ }
+
+ ctx, cancel := signal.NotifyContext(
+ context.Background(),
+ syscall.SIGINT,
+ syscall.SIGTERM,
+ syscall.SIGHUP,
+ )
+
+ if err := os.WriteFile(*p, nil, 0); err != nil {
+ log.Fatal(err)
+ }
+
+ var (
+ wg sync.WaitGroup
+
+ n atomic.Uint64
+ ok atomic.Bool
+ )
+
+ if *count < 1 {
+ *count = 1
+ }
+ for range *count {
+ wg.Go(newStatAs(ctx, cancel, &n, &ok, uint32(*u0), *p, *continuous))
+ if *u1 >= 0 {
+ wg.Go(newStatAs(ctx, cancel, &n, &ok, uint32(*u1), *p, *continuous))
+ }
+ }
+
+ wg.Wait()
+ if !*continuous && ok.Load() {
+ log.Printf("reproduced after %d calls", n.Load())
+ }
+}