From ac24f76e4afb309585e4122991f66b201fc9be79 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Thu, 1 Oct 2026 22:34:51 +0900 Subject: test/sandbox: migrate tests This benefits even more than the cmd/sharefs test suite, the slow python-based test script was a major bottleneck. Replacing the nix-represented test cases with compound literals also significantly increases readability. Signed-off-by: Ophestra --- test/flake.nix | 55 ----- test/interactive/configuration.nix | 68 ------ test/interactive/hakurei.nix | 47 ---- test/interactive/raceattr.nix | 30 --- test/interactive/trace.nix | 35 --- test/interactive/vm.nix | 56 ----- test/internal/sandbox/assert.go | 56 ----- test/internal/sandbox/ptrace.go | 87 -------- test/internal/testsuite/proc.go | 348 +++++++++++++++++++++++++++++ test/internal/testsuite/proc_test.go | 33 +++ test/internal/testsuite/ptrace.go | 136 +++++++++++ test/internal/testsuite/ptrace_test.go | 13 ++ test/internal/testsuite/testsuite.go | 103 +++++++++ test/internal/testsuite/testsuite_guard.go | 15 ++ test/sandbox/main.go | 267 ++++++++++++++++++++++ test/sandbox/seccomp.patch | 18 ++ test/sandbox/sum_amd64.go | 7 + test/sandbox/tool/main.go | 60 +---- test/sharefs/main.go | 6 +- 19 files changed, 942 insertions(+), 498 deletions(-) delete mode 100644 test/interactive/configuration.nix delete mode 100644 test/interactive/hakurei.nix delete mode 100644 test/interactive/raceattr.nix delete mode 100644 test/interactive/trace.nix delete mode 100644 test/interactive/vm.nix delete mode 100644 test/internal/sandbox/ptrace.go create mode 100644 test/internal/testsuite/proc.go create mode 100644 test/internal/testsuite/proc_test.go create mode 100644 test/internal/testsuite/ptrace.go create mode 100644 test/internal/testsuite/ptrace_test.go create mode 100644 test/internal/testsuite/testsuite_guard.go create mode 100644 test/sandbox/main.go create mode 100644 test/sandbox/seccomp.patch create mode 100644 test/sandbox/sum_amd64.go (limited to 'test') diff --git a/test/flake.nix b/test/flake.nix index b106ec7a..ff1f6237 100644 --- a/test/flake.nix +++ b/test/flake.nix @@ -49,12 +49,6 @@ withRace = true; }; - sandbox = callPackage ./sandbox { inherit self; }; - sandbox-race = callPackage ./sandbox { - inherit self; - withRace = true; - }; - formatting = runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt ]; } '' cd ${./.} @@ -155,55 +149,6 @@ }; withPackage = pkgs.mkShell { buildInputs = [ hakurei ] ++ hakurei.targetPkgs; }; - vm = - let - nixos = nixpkgs.lib.nixosSystem { - inherit system; - modules = [ - { - environment = { - systemPackages = [ - (pkgs.buildFHSEnv { - pname = "hakurei-fhs"; - inherit (hakurei) version; - targetPkgs = _: hakurei.targetPkgs; - extraOutputsToInstall = [ "dev" ]; - profile = '' - export PKG_CONFIG_PATH="/usr/share/pkgconfig:$PKG_CONFIG_PATH" - ''; - }) - ]; - - hakurei = - let - # this is used for interactive vm testing during development, where tests might be broken - package = self.packages.${pkgs.stdenv.hostPlatform.system}.hakurei.override { - buildGo127Module = previousArgs: pkgs.pkgsStatic.buildGo127Module (previousArgs // { doCheck = false; }); - }; - in - { - inherit package; - hsuPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.hsu.override { hakurei = package; }; - }; - }; - } - - ./interactive/configuration.nix - ./interactive/vm.nix - ./interactive/hakurei.nix - ./interactive/trace.nix - ./interactive/raceattr.nix - - self.nixosModules.hakurei - home-manager.nixosModules.home-manager - ]; - }; - in - pkgs.mkShell { - buildInputs = [ nixos.config.system.build.vm ]; - shellHook = "exec run-nixos-vm $@"; - }; - generateDoc = let inherit (pkgs) lib; diff --git a/test/interactive/configuration.nix b/test/interactive/configuration.nix deleted file mode 100644 index 9d1bddd8..00000000 --- a/test/interactive/configuration.nix +++ /dev/null @@ -1,68 +0,0 @@ -{ pkgs, ... }: -{ - system.stateVersion = "23.05"; - - users.users = { - alice = { - isNormalUser = true; - description = "Alice Foobar"; - password = "foobar"; - uid = 1000; - extraGroups = [ "wheel" ]; - }; - untrusted = { - isNormalUser = true; - description = "Untrusted user"; - password = "foobar"; - uid = 1001; - }; - }; - - home-manager.users.alice.home.stateVersion = "24.11"; - - security = { - sudo.wheelNeedsPassword = false; - rtkit.enable = true; - pam.loginLimits = [ - { - domain = "@users"; - item = "rtprio"; - type = "-"; - value = 1; - } - ]; - }; - - services = { - getty.autologinUser = "alice"; - pipewire = { - enable = true; - alsa.enable = true; - alsa.support32Bit = true; - pulse.enable = true; - jack.enable = true; - }; - }; - - environment.variables = { - SWAYSOCK = "/tmp/sway-ipc.sock"; - WLR_RENDERER = "pixman"; - }; - - programs = { - sway.enable = true; - - 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') > ~/.config/sway/config - - sway --validate - systemd-cat --identifier=session sway && touch /tmp/sway-exit-ok - fi - ''; - }; -} diff --git a/test/interactive/hakurei.nix b/test/interactive/hakurei.nix deleted file mode 100644 index ef5d0ada..00000000 --- a/test/interactive/hakurei.nix +++ /dev/null @@ -1,47 +0,0 @@ -{ pkgs, ... }: -{ - environment.hakurei = rec { - enable = true; - stateDir = "/var/lib/hakurei"; - sharefs.source = "${stateDir}/sdcard"; - users.alice = 0; - apps = { - "cat.gensokyo.extern.foot.noEnablements" = { - name = "ne-foot"; - identity = 1; - shareUid = true; - verbose = true; - share = pkgs.foot; - packages = [ pkgs.foot ]; - command = "foot"; - enablements = { - dbus = false; - pipewire = false; - }; - }; - - "cat.gensokyo.extern.foot.badDaemon" = { - name = "bd-foot"; - identity = 1; - shareUid = true; - verbose = true; - share = pkgs.foot; - packages = [ pkgs.foot ]; - command = "foot"; - enablements = { - dbus = false; - }; - extraPaths = [ - { - type = "daemon"; - dst = "/proc/nonexistent"; - path = "/usr/bin/env"; - args = [ "false" ]; - } - ]; - }; - }; - - extraHomeConfig.home.stateVersion = "23.05"; - }; -} diff --git a/test/interactive/raceattr.nix b/test/interactive/raceattr.nix deleted file mode 100644 index de54f4cf..00000000 --- a/test/interactive/raceattr.nix +++ /dev/null @@ -1,30 +0,0 @@ -{ lib, pkgs, ... }: -{ - security.wrappers.raceattr = - let - inherit (pkgs) buildGoModule; - in - { - setuid = true; - owner = "root"; - group = "root"; - source = "${ - (buildGoModule rec { - name = "raceattr"; - pname = name; - tags = [ "raceattr" ]; - - src = builtins.path { - name = "${pname}-src"; - path = lib.cleanSource ../sharefs; - filter = path: type: (type == "directory") || (type == "regular" && lib.hasSuffix ".go" path); - }; - vendorHash = null; - - preBuild = '' - go mod init hakurei.app/raceattr >& /dev/null - ''; - }) - }/bin/raceattr"; - }; -} diff --git a/test/interactive/trace.nix b/test/interactive/trace.nix deleted file mode 100644 index b9ef872c..00000000 --- a/test/interactive/trace.nix +++ /dev/null @@ -1,35 +0,0 @@ -{ lib, pkgs, ... }: -let - tracing = name: "\"/sys/kernel/debug/tracing/${name}\""; -in -{ - environment.systemPackages = [ - (pkgs.writeShellScriptBin "hakurei-set-up-tracing" '' - set -e - echo "$1" > ${tracing "set_graph_function"} - echo function_graph > ${tracing "current_tracer"} - echo funcgraph-tail > ${tracing "trace_options"} - echo funcgraph-retval > ${tracing "trace_options"} - echo nofuncgraph-cpu > ${tracing "trace_options"} - echo nofuncgraph-overhead > ${tracing "trace_options"} - echo nofuncgraph-duration > ${tracing "trace_options"} - '') - (pkgs.writeShellScriptBin "hakurei-print-trace" "exec cat ${tracing "trace"}") - (pkgs.writeShellScriptBin "hakurei-consume-trace" "exec cat ${tracing "trace_pipe"}") - ]; - - boot.kernelPatches = [ - { - name = "funcgraph-retval"; - patch = null; - structuredExtraConfig = with lib.kernel; { - FUNCTION_GRAPH_RETVAL = yes; - - RUST = lib.mkForce unset; - DRM_NOVA = lib.mkForce unset; - DRM_PANIC_SCREEN_QR_CODE = lib.mkForce unset; - NOVA_CORE = lib.mkForce unset; - }; - } - ]; -} diff --git a/test/interactive/vm.nix b/test/interactive/vm.nix deleted file mode 100644 index 522878e9..00000000 --- a/test/interactive/vm.nix +++ /dev/null @@ -1,56 +0,0 @@ -{ - virtualisation.vmVariant.virtualisation = { - memorySize = 4096; - qemu.options = [ - "-vga none -device virtio-gpu-pci" - "-smp 8" - ]; - - mountHostNixStore = true; - writableStore = true; - writableStoreUseTmpfs = false; - - sharedDirectories = { - cwd = { - target = "/mnt/.ro-cwd"; - source = ''"$OLDPWD"''; - securityModel = "none"; - }; - }; - - fileSystems = { - "/mnt/.ro-cwd".options = [ - "ro" - "noatime" - ]; - "/mnt/cwd".overlay = { - lowerdir = [ "/mnt/.ro-cwd" ]; - upperdir = "/tmp/.cwd/upper"; - workdir = "/tmp/.cwd/work"; - }; - - "/mnt/src".overlay = { - lowerdir = [ ../.. ]; - upperdir = "/tmp/.src/upper"; - workdir = "/tmp/.src/work"; - }; - }; - }; - - systemd.services = { - logrotate-checkconf.enable = false; - hakurei-src-fix-ownership = { - wantedBy = [ "multi-user.target" ]; - wants = [ "mnt-src.mount" ]; - after = [ "mnt-src.mount" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - script = '' - chown -R alice:users /mnt/src/ - chmod -R +w /mnt/src/ - ''; - }; - }; -} diff --git a/test/internal/sandbox/assert.go b/test/internal/sandbox/assert.go index e6fe926b..b50a9a54 100644 --- a/test/internal/sandbox/assert.go +++ b/test/internal/sandbox/assert.go @@ -9,8 +9,6 @@ package sandbox import ( - "crypto/sha512" - "encoding/hex" "encoding/json" "errors" "io/fs" @@ -198,60 +196,6 @@ func (t *T) MustCheck(want *TestCase) { } } -func MustCheckFilter(pid int, want string) { - err := CheckFilter(pid, want) - if err == nil { - return - } - - perr, ok := errors.AsType[*ptraceError](err) - if !ok { - fatalf("%s", err) - } - switch perr.op { - case "PTRACE_ATTACH": - fatalf("cannot attach to process %d: %v", pid, err) - case "PTRACE_SECCOMP_GET_FILTER": - if perr.errno == syscall.ENOENT { - fatalf("seccomp filter not installed for process %d", pid) - } - fatalf("cannot get filter: %v", err) - default: - fatalf("cannot check filter: %v", err) - } - - *(*int)(nil) = 0 // not reached -} - -func CheckFilter(pid int, want string) error { - if err := ptraceAttach(pid); err != nil { - return err - } - defer func() { - if err := ptraceDetach(pid); err != nil { - printf("cannot detach from process %d: %v", pid, err) - } - }() - - h := sha512.New() - - if buf, err := getFilter[[8]byte](pid, 0); err != nil { - return err - } else { - for _, b := range buf { - h.Write(b[:]) - } - } - - if got := hex.EncodeToString(h.Sum(nil)); got != want { - printf("[FAIL] %s", got) - return syscall.ENOTRECOVERABLE - } else { - printf("[ OK ] %s", got) - return nil - } -} - func mustDecode(wantFilePath string, v any) { if f, err := os.Open(wantFilePath); err != nil { fatalf("cannot open %q: %v", wantFilePath, err) diff --git a/test/internal/sandbox/ptrace.go b/test/internal/sandbox/ptrace.go deleted file mode 100644 index 8272ff5b..00000000 --- a/test/internal/sandbox/ptrace.go +++ /dev/null @@ -1,87 +0,0 @@ -//go:build testtool - -package sandbox - -import ( - "errors" - "fmt" - "syscall" - "unsafe" -) - -const ( - NULL = 0 - - PTRACE_ATTACH = 16 - PTRACE_DETACH = 17 - PTRACE_SECCOMP_GET_FILTER = 0x420c -) - -type ptraceError struct { - op string - errno syscall.Errno -} - -func (p *ptraceError) Error() string { return fmt.Sprintf("%s: %v", p.op, p.errno) } - -func (p *ptraceError) Unwrap() error { - if p.errno == 0 { - return nil - } - return p.errno -} - -func ptrace(op uintptr, pid, addr int, data unsafe.Pointer) (r uintptr, errno syscall.Errno) { - r, _, errno = syscall.Syscall6(syscall.SYS_PTRACE, op, uintptr(pid), uintptr(addr), uintptr(data), NULL, NULL) - return -} - -func ptraceAttach(pid int) error { - if _, errno := ptrace(PTRACE_ATTACH, pid, 0, nil); errno != 0 { - return &ptraceError{"PTRACE_ATTACH", errno} - } - - var status syscall.WaitStatus - for { - if _, err := syscall.Wait4(pid, &status, syscall.WALL, nil); err != nil { - if errors.Is(err, syscall.EINTR) { - continue - } - fatalf("cannot waitpid: %v", err) - } - break - } - - return nil -} - -func ptraceDetach(pid int) error { - if _, errno := ptrace(PTRACE_DETACH, pid, 0, nil); errno != 0 { - return &ptraceError{"PTRACE_DETACH", errno} - } - return nil -} - -type sockFilter struct { /* Filter block */ - code uint16 /* Actual filter code */ - jt uint8 /* Jump true */ - jf uint8 /* Jump false */ - k uint32 /* Generic multiuse field */ -} - -func getFilter[T comparable](pid, index int) ([]T, error) { - if s := unsafe.Sizeof(*new(T)); s != 8 { - panic(fmt.Sprintf("invalid filter block size %d", s)) - } - - var buf []T - if n, errno := ptrace(PTRACE_SECCOMP_GET_FILTER, pid, index, nil); errno != 0 { - return nil, &ptraceError{"PTRACE_SECCOMP_GET_FILTER", errno} - } else { - buf = make([]T, n) - } - if _, errno := ptrace(PTRACE_SECCOMP_GET_FILTER, pid, index, unsafe.Pointer(&buf[0])); errno != 0 { - return nil, &ptraceError{"PTRACE_SECCOMP_GET_FILTER", errno} - } - return buf, nil -} diff --git a/test/internal/testsuite/proc.go b/test/internal/testsuite/proc.go new file mode 100644 index 00000000..731566eb --- /dev/null +++ b/test/internal/testsuite/proc.go @@ -0,0 +1,348 @@ +package testsuite + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "unsafe" + + "hakurei.app/fhs" +) + +// Stat represents status information read from /proc/pid/stat. +type Stat struct { + // The process ID. + PID int + // The filename of the executable, with parenthesis stripped. + Comm string + // One of the following characters, indicating process state: + // + // R Running + // + // S Sleeping in an interruptible wait + // + // D Waiting in uninterruptible disk sleep + // + // Z Zombie + // + // T Stopped (on a signal) or (before Linux + // 2.6.33) trace stopped + // + // t Tracing stop (Linux 2.6.33 onward) + // + // W Paging (only before Linux 2.6.0) + // + // X Dead (from Linux 2.6.0 onward) + // + // x Dead (Linux 2.6.33 to 3.13 only) + // + // K Wakekill (Linux 2.6.33 to 3.13 only) + // + // W Waking (Linux 2.6.33 to 3.13 only) + // + // P Parked (Linux 3.9 to 3.13 only) + // + // I Idle (Linux 4.14 onward) + State byte + // The process ID of the parent of this process. + PPID int + // The process group ID of the process. + PGRP int + // The session ID of the process. + Session int + // The controlling terminal of the process. + TTYNR int + // The ID of the foreground process group of the controlling terminal of the + // process. + TPGID int + // The kernel flags word of the process. For bit meanings, see the PF_* + // defines in the Linux kernel source file include/linux/sched.h. + Flags uint + // The number of minor faults the process has made which have not required + // loading a memory page from disk. + MinFlt uint + // The number of minor faults that the process's waited-for children have + // made. + CMinFlt uint + // The number of major faults the process has made which have required + // loading a memory page from disk. + MajFlt uint + // The number of major faults that the process's waited-for children have + // made. + CMajFlt uint + // Amount of time that this process has been scheduled in user mode, + // measured in clock ticks. + UTime uint + // Amount of time that this process has been scheduled in kernel mode, + // measured in clock ticks. + STime uint + // Amount of time that this process's waited-for children have been + // scheduled in user mode, measured in clock ticks. + CUTime int + // Amount of time that this process's waited-for children have been + // scheduled in kernel mode, measured in clock ticks. + CSTime int + // For processes running a real-time scheduling policy, this is the negated + // scheduling priority, minus one. + Priority int + // The nice value, a value in the range 19 (low priority) to -20 (high + // priority). + Nice int + // Number of threads in this process. + NumThreads int + + // unmaintained field: itrealvalue + + // The time the process started after system boot. Since Linux 2.6, the + // value is expressed in clock ticks. + StartTime uint64 + // Virtual memory size in bytes. + VSize uint + // Resident set size in pages. + RSS int + // Soft limit in bytes on the rss of the process. + RSSLim uint64 + // The address above which program text can run. + StartCode uint64 + // The address below which program text can run. + EndCode uint64 + // The address of the start (i.e., bottom) of the stack. + StartStack uint64 + // The current value of ESP (stack pointer), as found in the kernel stack + // page for the process. + KSTKESP uint64 + // The current EIP (instruction pointer). + KSTKEIP uint64 + + // obsolete fields: signal, blocked, sigignore, sigcatch + + // This is the "channel" in which the process is waiting. It is the address + // of a location in the kernel where the process is sleeping. + WChan uint64 + + // unmaintained fields: nswap, cnswap + + // Signal to be sent to parent when we die. + ExitSignal int + // CPU number last executed on. + Processor int + // Real-time scheduling priority, a number in the range 1 to 99 for processes + // scheduled under a real-time policy, or 0, for non-real-time processes. + RTPriority uint + // Scheduling policy (see sched_setscheduler(2)). Decode using the SCHED_* + // constants in linux/sched.h. + Policy uint + // Aggregated block I/O delays, measured in clock ticks (centiseconds). + DelayAcctBlkIOTicks uint64 + // Guest time of the process (time spent running a virtual CPU for a guest + // operating system), measured in clock ticks. + GuestTime int + // Guest time of the process's children, measured in clock ticks. + CGuestTime int +} + +// Executable is like [os.Executable], but for the process referred to by s. +func (s *Stat) Executable() (string, error) { + path, err := os.Readlink(filepath.Join(fhs.Proc, strconv.Itoa(s.PID), "exe")) + + // When the executable has been deleted then Readlink returns a + // path appended with " (deleted)". + return strings.TrimSuffix(path, " (deleted)"), err +} + +// Stat populates stat with the proc filesystem entry referred to by s. +func (s *Stat) Stat(stat *syscall.Stat_t) (err error) { + err = syscall.Stat(filepath.Join(fhs.Proc, strconv.Itoa(s.PID)), stat) + if err != nil { + err = os.NewSyscallError("stat", err) + } + return +} + +// Args reads arguments of the process referred to by s. +func (s *Stat) Args() ([]string, error) { + p, err := os.ReadFile(filepath.Join(fhs.Proc, strconv.Itoa(s.PID), "cmdline")) + if err != nil { + return nil, err + } + a := bytes.Split(p, []byte{0}) + if len(a) > 0 && len(a[len(a)-1]) == 0 { + a = a[:len(a)-1] + } + + args := make([]string, len(a)) + for i, arg := range a { + args[i] = unsafe.String(unsafe.SliceData(arg), len(arg)) + } + return args, nil +} + +// ErrBadDelimiters is returned by [Stat.UnmarshalText] if one or both bytes of +// the comm delimiter pair were missing or misplaced. +var ErrBadDelimiters = errors.New("missing comm delimiters") + +// UnmarshalText populates the structure pointed to by s from text. +func (s *Stat) UnmarshalText(text []byte) (err error) { + var ( + discard uint64 + _uint64 = &discard + _int64 = (*int64)(unsafe.Pointer(&discard)) + + ld = bytes.Index(text, []byte("(")) + rd = bytes.LastIndex(text, []byte(")")) + ) + + if ld <= 0 || rd < 0 { + return ErrBadDelimiters + } + + if s.PID, err = strconv.Atoi( + unsafe.String(unsafe.SliceData(text), ld-1), + ); err != nil { + return + } + + s.Comm = string(text[ld+1 : rd]) + + var ( + n int + + state string + ) + n, err = fmt.Fscan( + bytes.NewBuffer(text[rd+2:]), + &state, + &s.PPID, + &s.PGRP, + &s.Session, + &s.TTYNR, + &s.TPGID, + &s.Flags, + &s.MinFlt, + &s.CMinFlt, + &s.MajFlt, + &s.CMajFlt, + &s.UTime, + &s.STime, + &s.CUTime, + &s.CSTime, + &s.Priority, + &s.Nice, + &s.NumThreads, + _int64, + &s.StartTime, + &s.VSize, + &s.RSS, + &s.RSSLim, + &s.StartCode, + &s.EndCode, + &s.StartStack, + &s.KSTKESP, + &s.KSTKEIP, + _uint64, + _uint64, + _uint64, + _uint64, + &s.WChan, + _uint64, + _uint64, + &s.ExitSignal, + &s.Processor, + &s.RTPriority, + &s.Policy, + &s.DelayAcctBlkIOTicks, + &s.GuestTime, + &s.CGuestTime, + ) + if err != nil { + err = fmt.Errorf("field %d: %w", n, err) + } else if len(state) != 1 { + err = fmt.Errorf("invalid state %q", state) + } else { + s.State = state[0] + } + return +} + +// A StatScanner continuously scans the proc filesystem for process status +// information in /proc/pid/stat. +type StatScanner struct { + // Current entry. + stat Stat + // Cached top-level /proc entries. + dents []os.DirEntry + // Current progress through dents. + i int + // Whether the previous call to Scan had repopulated dents. + wrapped bool + // First stored error: a non-nil err disables the scanner. + err error +} + +// Scan reads a process status information entry. It returns false if an +// unrecoverable error is encountered, after which Scan no longer scans new +// entries. +func (s *StatScanner) Scan() bool { + if s.err != nil { + return false + } + + if s.wrapped = s.i == len(s.dents); s.wrapped { + if s.dents, s.err = os.ReadDir(fhs.Proc); s.err != nil { + return false + } + s.i = 0 + if len(s.dents) == 0 { + s.err = syscall.ENOTRECOVERABLE + return false + } + } + + for s.i < len(s.dents) { + dent := s.dents[s.i] + s.i++ + if !dent.IsDir() { + continue + } + + pid, err := strconv.Atoi(dent.Name()) + if err != nil { + continue + } + + var p []byte + p, err = os.ReadFile(filepath.Join(fhs.Proc, dent.Name(), "stat")) + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ESRCH) { + continue + } + s.err = err + return false + } + + s.err = s.stat.UnmarshalText(p) + if s.err == nil && pid != s.stat.PID { + s.err = fmt.Errorf( + "bad status information: dent=%d, stat=%d", + pid, s.stat.PID, + ) + } + return s.err == nil + } + return s.Scan() +} + +// Stat returns the address of the [Stat] structure populated by the last call +// to Scan. +func (s *StatScanner) Stat() *Stat { return &s.stat } + +// Err returns the stored error value. +func (s *StatScanner) Err() error { return s.err } + +// Repopulated returns whether the last Scan call had re-read the proc filesystem. +func (s *StatScanner) Repopulated() bool { return s.wrapped } diff --git a/test/internal/testsuite/proc_test.go b/test/internal/testsuite/proc_test.go new file mode 100644 index 00000000..a8698e73 --- /dev/null +++ b/test/internal/testsuite/proc_test.go @@ -0,0 +1,33 @@ +package testsuite_test + +import ( + "testing" + + "hakurei.app/test/internal/testsuite" +) + +func BenchmarkStatScanner(b *testing.B) { + var s testsuite.StatScanner + + for b.Loop() { + if !s.Scan() { + b.Fatal(s.Err()) + } + } +} + +func BenchmarkStatScannerFull(b *testing.B) { + var s testsuite.StatScanner + + for b.Loop() { + for s.Scan() { + if s.Repopulated() { + break + } + } + + if err := s.Err(); err != nil { + b.Fatal(err) + } + } +} diff --git a/test/internal/testsuite/ptrace.go b/test/internal/testsuite/ptrace.go new file mode 100644 index 00000000..4fcf1508 --- /dev/null +++ b/test/internal/testsuite/ptrace.go @@ -0,0 +1,136 @@ +package testsuite + +import ( + "crypto/sha512" + "encoding/hex" + "errors" + "fmt" + "os" + "syscall" + "unsafe" +) + +const ( + // _PTRACE_ATTACH attaches to the process specified in pid. + _PTRACE_ATTACH = 16 + // _PTRACE_DETACH restarts the stopped tracee as for PTRACE_CONT, but first + // detaches from it. + _PTRACE_DETACH = 17 + + // _PTRACE_SECCOMP_GET_FILTER allows the tracer to dump the tracee's classic + // BPF filters. + _PTRACE_SECCOMP_GET_FILTER = 0x420c +) + +// ptrace wraps the ptrace syscall. +func ptrace( + op uintptr, + pid, addr int, + data unsafe.Pointer, +) (r uintptr, errno syscall.Errno) { + r, _, errno = syscall.Syscall6( + syscall.SYS_PTRACE, + op, + uintptr(pid), + uintptr(addr), + uintptr(data), + 0, 0, + ) + return +} + +// ptraceAttach attaches to the process referred to by pid. +func ptraceAttach(pid int) error { + if _, errno := ptrace(_PTRACE_ATTACH, pid, 0, nil); errno != 0 { + return os.NewSyscallError("PTRACE_ATTACH", errno) + } + + var status syscall.WaitStatus + for { + if _, err := syscall.Wait4( + pid, + &status, + syscall.WALL, + nil, + ); err != nil { + if errors.Is(err, syscall.EINTR) { + continue + } + return os.NewSyscallError("wait4", err) + } + break + } + + return nil +} + +// ptraceDetach detaches from the attached process referred to by pid. +func ptraceDetach(pid int) error { + if _, errno := ptrace(_PTRACE_DETACH, pid, 0, nil); errno != 0 { + return os.NewSyscallError("PTRACE_DETACH", errno) + } + return nil +} + +// getFilter dumps the specified tracee's cBPF filter at the specified index +// and returns the resulting payload. T must be eight bytes long and must not +// contain pointers. +func getFilter(pid, index int) ([]syscall.SockFilter, error) { + var buf []syscall.SockFilter + if n, errno := ptrace( + _PTRACE_SECCOMP_GET_FILTER, + pid, index, nil, + ); errno != 0 { + return nil, os.NewSyscallError("PTRACE_SECCOMP_GET_FILTER", errno) + } else { + buf = make([]syscall.SockFilter, n) + } + if _, errno := ptrace( + _PTRACE_SECCOMP_GET_FILTER, + pid, index, unsafe.Pointer(&buf[0]), + ); errno != 0 { + return nil, os.NewSyscallError("PTRACE_SECCOMP_GET_FILTER", errno) + } + return buf, nil +} + +// CheckFilter checks the process at pid to have its first filter's contents +// match the sha512 checksum specified in hexadecimal string representation. +func CheckFilter(pid, index int, sum string) (err error) { + if err = ptraceAttach(pid); err != nil { + return + } + defer func() { + if detachErr := ptraceDetach(pid); err == nil { + err = detachErr + } + }() + + var ( + buf []syscall.SockFilter + want []byte + ) + + if want, err = hex.DecodeString(sum); err != nil { + return + } + + h := sha512.New() + if buf, err = getFilter(pid, index); err != nil { + return + } else { + h.Write(unsafe.Slice( + (*byte)(unsafe.Pointer(&buf[0])), + uintptr(len(buf))*unsafe.Sizeof(buf[0]), + )) + } + + if got := h.Sum(nil); string(got) != string(want) { + return fmt.Errorf( + "bad filter\n\t got: %s\n\twant: %s", + hex.EncodeToString(got), + sum, + ) + } + return +} diff --git a/test/internal/testsuite/ptrace_test.go b/test/internal/testsuite/ptrace_test.go new file mode 100644 index 00000000..eaaed131 --- /dev/null +++ b/test/internal/testsuite/ptrace_test.go @@ -0,0 +1,13 @@ +package testsuite + +import ( + "syscall" + "testing" + "unsafe" +) + +func TestBlockSize(t *testing.T) { + if sz := unsafe.Sizeof(syscall.SockFilter{}); sz != 8 { + t.Fatalf("invalid filter block size %d", sz) + } +} diff --git a/test/internal/testsuite/testsuite.go b/test/internal/testsuite/testsuite.go index 4b70da34..a05fd783 100644 --- a/test/internal/testsuite/testsuite.go +++ b/test/internal/testsuite/testsuite.go @@ -2,14 +2,23 @@ package testsuite import ( + "context" + "errors" "log" "os" "os/exec" "os/signal" + "os/user" + "strconv" "syscall" + "testing" ) func init() { + if testing.Testing() { + return + } + log.SetFlags(0) log.SetPrefix("testsuite: ") @@ -25,6 +34,16 @@ func ReceiveSignals() { log.Fatalf("terminating on signal %s", <-s) } +// GetUser returns the [user.User] structure describing the user with uid 1000. +func GetUser() *user.User { + if u, err := user.LookupId("1000"); err != nil { + log.Fatal(err) + return nil + } else { + return u + } +} + // MustRun runs command and terminates the testsuite on error. func MustRun(command ...string) { cmd := exec.Command(command[0], command[1:]...) @@ -34,7 +53,91 @@ func MustRun(command ...string) { } } +// ErrUnexpectedSuccess is returned for processes expected to exit with a +// non-zero code, but failed to do so. +var ErrUnexpectedSuccess = errors.New("process unexpectedly exited with code 0") + +// MustFail runs command and terminates the testsuite if the program fails to +// start or exits with code 0. +func MustFail(command ...string) { + cmd := exec.Command(command[0], command[1:]...) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err == nil { + log.Fatal(ErrUnexpectedSuccess) + } else if e, ok := errors.AsType[*exec.ExitError](err); !ok { + log.Fatal(err) + } else if !e.Exited() { + log.Fatal(e) + } +} + // MustRunAs wraps [MustRun] for sudo. func MustRunAs(username string, command ...string) { MustRun(append([]string{"sudo", "-u", username}, command...)...) } + +// MustFailAs wraps [MustFail] for sudo. +func MustFailAs(username string, command ...string) { + MustFail(append([]string{"sudo", "-u", username}, command...)...) +} + +// MustStart starts cmd and returns a channel delivering its wait error. +func MustStart(cmd *exec.Cmd) (done <-chan error) { + if err := cmd.Start(); err != nil { + log.Fatal(err) + } + d := make(chan error) + go func() { d <- cmd.Wait() }() + return d +} + +// MustStartAs wraps [MustStart] for sudo. +func MustStartAs( + ctx context.Context, + username string, + files []*os.File, + command ...string, +) (proc *os.Process, done <-chan error) { + sudoArgs := []string{ + "-u", username, + } + if len(files) != 0 { + sudoArgs = append(sudoArgs, "-C", strconv.Itoa(len(files)+4)) + } + sudoArgs = append(sudoArgs, "--") + cmd := exec.CommandContext(ctx, "sudo", append(sudoArgs, command...)...) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + cmd.ExtraFiles = files + cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM} + return cmd.Process, MustStart(cmd) +} + +// MustCheckFilter is like [CheckFilter], but terminates the test suite if a +// non-nil error is returned. Otherwise, the tracee is terminated after it +// resumes. +func MustCheckFilter(pid int, sum string) { + // podman installs its own filter + if err := CheckFilter(pid, 1, sum); err != nil { + log.Fatal(err) + } else if err = syscall.Kill(pid, syscall.SIGTERM); err != nil { + log.Fatalf("cannot terminate tracee: %v", err) + } +} + +// FilterTerminated returns a non-nil error if err is not an [exec.ExitError] +// describing a process terminated by a syscall.SIGTERM signal. +func FilterTerminated(err error) error { + if err == nil { + return ErrUnexpectedSuccess + } + + e, ok := errors.AsType[*exec.ExitError](err) + if !ok { + return err + } + + if e.ExitCode() == 0x80+int(syscall.SIGTERM) { + return nil + } + return e +} diff --git a/test/internal/testsuite/testsuite_guard.go b/test/internal/testsuite/testsuite_guard.go new file mode 100644 index 00000000..6a34d11b --- /dev/null +++ b/test/internal/testsuite/testsuite_guard.go @@ -0,0 +1,15 @@ +//go:build !testsuite + +package testsuite + +import ( + "os" + "testing" +) + +func init() { + if !testing.Testing() { + println("package testsuite imported in non-testsuite program") + os.Exit(1) + } +} diff --git a/test/sandbox/main.go b/test/sandbox/main.go new file mode 100644 index 00000000..579dfa09 --- /dev/null +++ b/test/sandbox/main.go @@ -0,0 +1,267 @@ +//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 ( + "bufio" + "context" + "errors" + "log" + "os" + "path/filepath" + "slices" + "strconv" + "sync" + "sync/atomic" + "syscall" + "time" + + "hakurei.app/fhs" + "hakurei.app/hst" + + "hakurei.app/test/internal/testsuite" +) + +// 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, + username string, + files ...*os.File, +) (pid int, done <-chan error) { + _serial := strconv.FormatUint(serial, 10) + _, done = testsuite.MustStartAs( + ctx, username, files, + "hakurei", "exec", + "sleep", "infinity", _serial, + ) + + var ( + s testsuite.StatScanner + + stat syscall.Stat_t + ) + for s.Scan() { + select { + case err := <-done: + if err == nil { + log.Fatal("test process terminated unexpectedly") + } + log.Fatal(err) + default: + break + } + + if s.Stat().Comm != "sleep" { + continue + } + + if args, err := s.Stat().Args(); err != nil { + log.Fatal(err) + } else if !slices.Equal(args, []string{ + "sleep", + "infinity", + _serial, + }) { + continue + } + + if err := s.Stat().Stat(&stat); err != nil { + log.Fatal(err) + } + + id := hst.ToUser[uint32](0, 0) + if stat.Uid != id || stat.Gid != id { + continue + } + + break + } + if err := s.Err(); err != nil { + log.Fatal(err) + } + pid = s.Stat().PID + return +} + +func main() { + go testsuite.ReceiveSignals() + username := testsuite.GetUser().Username + + // the signal handler does not wait for termination + ctx := context.Background() + + var wg sync.WaitGroup + defer wg.Wait() + + var serial atomic.Uint64 + newSerial := func() uint64 { serial.Add(1); return serial.Load() } + + testsuite.MustRunAs( + username, "-i", + "hakurei", "exec", "capsh", "--print", + ) + wg.Go(func() { + defer log.Println("validated capabilities/securebits in user namespace") + + testsuite.MustRunAs( + username, "-i", + "hakurei", "exec", "capsh", "--has-no-new-privs", + ) + + for _, p := range []byte{'a', 'b', 'i', 'p'} { + testsuite.MustFailAs( + username, "-i", + "hakurei", "exec", "capsh", "--has-"+string(p)+"=CAP_SYS_ADMIN", + ) + } + testsuite.MustFailAs( + username, "-i", + "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(), username) + testsuite.MustCheckFilter(pid, pdSum) + 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(), username, 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) + } + }) + + const xdgRuntimeDir = "/var/run/user/1000" + if err := os.MkdirAll(xdgRuntimeDir, 0700); err != nil { + log.Fatal(err) + } else if err = os.Chown(xdgRuntimeDir, 1000, 1000); err != nil { + log.Fatal(err) + } + + var dbusEnv string + { + r, w, err := os.Pipe() + if err != nil { + log.Fatal(err) + } + + wg.Go(func() { + // this is never explicitly terminated + _, done := testsuite.MustStartAs( + ctx, username, []*os.File{w}, + "dbus-daemon", + "--print-address=3", + "--address=unix:path="+xdgRuntimeDir+"/dbus", + "--session", + "--nofork", + "--nopidfile", + ) + if _err := <-done; _err != nil { + log.Fatal(_err) + } + }) + + dbusEnv, err = bufio.NewReader(r).ReadString('\n') + if err != nil { + log.Fatal(err) + } + log.Printf("dbus listening on %s", dbusEnv) + dbusEnv = "DBUS_SESSION_BUS_ADDRESS=" + dbusEnv + + if err = r.Close(); err != nil { + log.Fatal(err) + } + } + + const ( + xdgRuntimeEnv = "XDG_RUNTIME_DIR=" + xdgRuntimeDir + swayEnv = "SWAYSOCK=" + xdgRuntimeDir + "/sway" + ) + wg.Go(func() { + // this is terminated via swaymsg + _, done := testsuite.MustStartAs( + ctx, username, nil, "env", + "WLR_BACKENDS=headless", + xdgRuntimeEnv, + swayEnv, + dbusEnv, + "sway", + ) + if err := <-done; err != nil { + log.Fatal(err) + } + }) + { + for range time.NewTicker(50 * time.Millisecond).C { + fi, err := os.Lstat(xdgRuntimeDir + "/sway") + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + log.Fatal(err) + } + if fi.Mode()&os.ModeSocket == 0 { + log.Fatalf("unexpected sway socket %s", fi.Mode()) + } + break + } + log.Printf("sway available via %s", swayEnv) + } + + defer func() { + testsuite.MustFailAs(username, swayEnv, "swaymsg", "exit") + }() + +} diff --git a/test/sandbox/seccomp.patch b/test/sandbox/seccomp.patch new file mode 100644 index 00000000..ddabc71e --- /dev/null +++ b/test/sandbox/seccomp.patch @@ -0,0 +1,18 @@ +diff --git a/kernel/seccomp.c b/kernel/seccomp.c +index 25f62867a16d..7b63ccc8daf4 100644 +--- a/kernel/seccomp.c ++++ b/kernel/seccomp.c +@@ -2216,8 +2216,12 @@ long seccomp_get_filter(struct task_struct *task, unsigned long filter_off, + struct seccomp_filter *filter; + struct sock_fprog_kern *fprog; + long ret; ++ struct user_namespace *user_ns = current_user_ns(); + +- if (!capable(CAP_SYS_ADMIN) || ++ if (in_userns(user_ns, task_cred_xxx(task, user_ns))) { ++ if (!ns_capable(user_ns, CAP_SYS_ADMIN)) ++ return -EACCES; ++ } else if (!capable(CAP_SYS_ADMIN) || + current->seccomp.mode != SECCOMP_MODE_DISABLED) { + return -EACCES; + } diff --git a/test/sandbox/sum_amd64.go b/test/sandbox/sum_amd64.go new file mode 100644 index 00000000..da03f12b --- /dev/null +++ b/test/sandbox/sum_amd64.go @@ -0,0 +1,7 @@ +//go:build testsuite + +package main + +const ( + pdSum = "c698b081ff957afe17a6d94374537d37f2a63f6f9dd75da7546542407a9e32476ebda3312ba7785d7f618542bcfaf27ca27dcc2dddba852069d28bcfe8cad39a" +) diff --git a/test/sandbox/tool/main.go b/test/sandbox/tool/main.go index 889142d4..490c3c5e 100644 --- a/test/sandbox/tool/main.go +++ b/test/sandbox/tool/main.go @@ -4,12 +4,9 @@ package main import ( "flag" - "fmt" "log" "os" "os/signal" - "strconv" - "strings" "syscall" "hakurei.app/test/internal/sandbox" @@ -47,60 +44,5 @@ func main() { return } - switch args[0] { - case "filter": - if len(args) != 2 { - log.Fatal("invalid argument") - } - - if pid, err := strconv.Atoi(strings.TrimSpace(args[1])); err != nil { - log.Fatalf("%s", err) - } else if pid < 1 { - log.Fatalf("%d out of range", pid) - } else { - sandbox.MustCheckFilter(pid, flagBpfHash) - if err = syscall.Kill(pid, syscall.SIGINT); err != nil { - log.Fatalf("cannot signal check process: %v", err) - } - } - - case "hash": // this eases the pain of passing the hash to python - fmt.Print(flagBpfHash) - - case "fd": - if len(args) != 2 { - log.Fatal("invalid argument") - } - prefix := fmt.Sprintf("/proc/%s/fd/", args[1]) - - 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(prefix + ent.Name()); err != nil { - log.Fatal(err.Error()) - } - log.Printf("[FAIL] extra fd %d -> %s", fd, d) - } - } - if fail { - log.Fatal("[FAIL] file descriptors leaked") - } - - default: - log.Fatal("invalid argument") - } + log.Fatal("invalid argument") } diff --git a/test/sharefs/main.go b/test/sharefs/main.go index 536a61b3..c2b6cd41 100644 --- a/test/sharefs/main.go +++ b/test/sharefs/main.go @@ -51,16 +51,12 @@ func checkBadOpts(username, opts, want string) { func main() { go testsuite.ReceiveSignals() + username := testsuite.GetUser().Username if err := os.Mkdir("result", 0755); err != nil { log.Fatal(err) } - if len(os.Args) != 2 { - log.Fatal("expecting 1 argument") - } - username := os.Args[1] - done := make(chan struct{}) go func() { defer close(done) -- cgit v1.3.1