From a9e2749f6654d0aa07b274a45c9177d10323f80a Mon Sep 17 00:00:00 2001 From: Ophestra Date: Tue, 6 Oct 2026 22:23:20 +0900 Subject: internal/testsuite: move from test This structure is a lot less clumsy than the old nix-centric layout. Signed-off-by: Ophestra --- cmd/hakurei/testsuite/configuration.nix | 252 +++++++++++++ cmd/hakurei/testsuite/default.nix | 81 ++++ cmd/hakurei/testsuite/flake.lock | 49 +++ cmd/hakurei/testsuite/flake.nix | 75 ++++ cmd/hakurei/testsuite/hsu.nix | 23 ++ cmd/hakurei/testsuite/nixos.nix | 407 ++++++++++++++++++++ cmd/hakurei/testsuite/options.nix | 364 ++++++++++++++++++ cmd/hakurei/testsuite/package.nix | 145 ++++++++ cmd/hakurei/testsuite/sandbox/main.go | 409 +++++++++++++++++++++ cmd/hakurei/testsuite/sandbox/testdata/device.go | 134 +++++++ cmd/hakurei/testsuite/sandbox/testdata/mapuid.go | 124 +++++++ cmd/hakurei/testsuite/sandbox/testdata/pdlike.go | 141 +++++++ cmd/hakurei/testsuite/sandbox/testdata/simple.go | 140 +++++++ cmd/hakurei/testsuite/sandbox/testdata/sum.go | 22 ++ .../testsuite/sandbox/testdata/sum_amd64.go | 9 + .../testsuite/sandbox/testdata/sum_arm64.go | 9 + cmd/hakurei/testsuite/sandbox/testdata/testdata.go | 125 +++++++ cmd/hakurei/testsuite/sandbox/testdata/tty.go | 145 ++++++++ cmd/hakurei/testsuite/sandbox/tester/main.go | 224 +++++++++++ cmd/hakurei/testsuite/test.py | 315 ++++++++++++++++ cmd/sharefs/testsuite/main.go | 119 ++++++ cmd/sharefs/testsuite/raceattr.go | 122 ++++++ 22 files changed, 3434 insertions(+) create mode 100644 cmd/hakurei/testsuite/configuration.nix create mode 100644 cmd/hakurei/testsuite/default.nix create mode 100644 cmd/hakurei/testsuite/flake.lock create mode 100644 cmd/hakurei/testsuite/flake.nix create mode 100644 cmd/hakurei/testsuite/hsu.nix create mode 100644 cmd/hakurei/testsuite/nixos.nix create mode 100644 cmd/hakurei/testsuite/options.nix create mode 100644 cmd/hakurei/testsuite/package.nix create mode 100644 cmd/hakurei/testsuite/sandbox/main.go create mode 100644 cmd/hakurei/testsuite/sandbox/testdata/device.go create mode 100644 cmd/hakurei/testsuite/sandbox/testdata/mapuid.go create mode 100644 cmd/hakurei/testsuite/sandbox/testdata/pdlike.go create mode 100644 cmd/hakurei/testsuite/sandbox/testdata/simple.go create mode 100644 cmd/hakurei/testsuite/sandbox/testdata/sum.go create mode 100644 cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go create mode 100644 cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go create mode 100644 cmd/hakurei/testsuite/sandbox/testdata/testdata.go create mode 100644 cmd/hakurei/testsuite/sandbox/testdata/tty.go create mode 100644 cmd/hakurei/testsuite/sandbox/tester/main.go create mode 100644 cmd/hakurei/testsuite/test.py create mode 100644 cmd/sharefs/testsuite/main.go create mode 100644 cmd/sharefs/testsuite/raceattr.go (limited to 'cmd') 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 +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()) + } +} -- cgit v1.3.1