351 lines
13 KiB
Go
351 lines
13 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package wine
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func write_helper_file(t *testing.T, path, data string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(data), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestProtonLoaderBypassesProxy(t *testing.T) {
|
|
root := t.TempDir()
|
|
loader := filepath.Join(root, "Proton", "files", "bin", "wine")
|
|
preloader := filepath.Join(root, "Proton", "files", "lib", "wine", "i386-unix", "wine64-preloader")
|
|
proxy := filepath.Join(root, "proxy", "wine")
|
|
write_helper_file(t, loader, "loader")
|
|
write_helper_file(t, preloader, "preloader")
|
|
write_helper_file(t, proxy, "#!/bin/sh\n# PROTONTRICKS_STEAM_RUNTIME\n")
|
|
for _, configured := range []string{"", proxy, loader} {
|
|
if got := wine_loader(preloader, configured); got != loader {
|
|
t.Fatalf("loader = %q, want %q", got, loader)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContainerHelperPublishesImmutableCompleteSets(t *testing.T) {
|
|
root := t.TempDir()
|
|
t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state"))
|
|
helper := filepath.Join(root, "installed", "wine-access.exe")
|
|
names := []string{"wine-access.exe", "wine-access.exe.so", "wine-access-menu.dll", "wine-access-menu32.dll", "wine-access-menu-host32.exe"}
|
|
for _, name := range names {
|
|
write_helper_file(t, filepath.Join(filepath.Dir(helper), name), name)
|
|
}
|
|
environment := map[string]string{"PRESSURE_VESSEL_RUNTIME": "runtime"}
|
|
original, err := stage_helper(helper, environment)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if original == helper {
|
|
t.Fatal("helper was not staged")
|
|
}
|
|
for _, name := range names {
|
|
data, err := os.ReadFile(filepath.Join(filepath.Dir(original), name))
|
|
if err != nil || string(data) != name {
|
|
t.Fatalf("incomplete set: %s: %v", name, err)
|
|
}
|
|
}
|
|
write_helper_file(t, helper+".so", "new sidecar")
|
|
updated, err := stage_helper(helper, environment)
|
|
if err != nil || updated == original {
|
|
t.Fatalf("updated set = %q, %v", updated, err)
|
|
}
|
|
data, _ := os.ReadFile(original + ".so")
|
|
if string(data) != "wine-access.exe.so" {
|
|
t.Fatal("old sidecar overwritten")
|
|
}
|
|
write_helper_file(t, updated+".so", "tampered")
|
|
if _, err := stage_helper(helper, environment); err == nil {
|
|
t.Fatal("accepted modified staging")
|
|
}
|
|
if err := os.Remove(helper + ".so"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := stage_helper(helper, environment); err == nil {
|
|
t.Fatal("accepted incomplete source set")
|
|
}
|
|
if got, err := stage_helper(helper, nil); err != nil || got != helper {
|
|
t.Fatalf("ordinary Wine = %q, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestListedProgramDoesNotForceWholesaleMode(t *testing.T) {
|
|
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "")
|
|
// A listed program is gated natively by the helper. Its presence must
|
|
// never add the prefix-wide --no-dialog-reader, or ordinary programs in
|
|
// the same prefix lose dialog reading.
|
|
listed := Settings{ExcludedPrograms: []string{"Game.EXE", "game.exe"}}
|
|
if wholesale_controller_only(listed) {
|
|
t.Fatal("listed programs selected wholesale mode")
|
|
}
|
|
arguments := helper_arguments(wholesale_controller_only(listed), listed)
|
|
if len(arguments) != 2 || arguments[0] != "--exclude-hook-program" || arguments[1] != "game.exe" {
|
|
t.Fatalf("mixed-prefix arguments = %v", arguments)
|
|
}
|
|
global := Settings{ControllerOnly: true, ExcludedPrograms: []string{"Game.exe"}}
|
|
if !wholesale_controller_only(global) {
|
|
t.Fatal("global controller-only switch ignored")
|
|
}
|
|
arguments = helper_arguments(true, global)
|
|
if arguments[len(arguments)-1] != "--no-dialog-reader" {
|
|
t.Fatalf("global arguments = %v", arguments)
|
|
}
|
|
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "1")
|
|
if !wholesale_controller_only(Settings{}) {
|
|
t.Fatal("environment override ignored")
|
|
}
|
|
}
|
|
|
|
func TestHelperArgumentsValidateProgramNames(t *testing.T) {
|
|
settings := Settings{ExcludedPrograms: []string{
|
|
"Game.exe", "Another Game.exe", "Café.exe", "game.EXE",
|
|
"Game\t.exe", "Game\x01.exe", "Game\x1f.exe", `C:\Game.exe`,
|
|
strings.Repeat("é", 126) + ".exe",
|
|
}}
|
|
want := []string{
|
|
"--exclude-hook-program", "another game.exe",
|
|
"--exclude-hook-program", "café.exe",
|
|
"--exclude-hook-program", "game.exe",
|
|
}
|
|
if got := helper_arguments(false, settings); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("validated helper arguments = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProtonInfrastructureDoesNotKeepHelperAlive(t *testing.T) {
|
|
for _, program := range []string{`C:\windows\system32\steam.exe`, `C:\windows\syswow64\steam.exe`, "xalia.exe", "conhost.exe"} {
|
|
if applicationProcess(program) {
|
|
t.Fatalf("infrastructure counted as app: %s", program)
|
|
}
|
|
}
|
|
for _, program := range []string{`C:\Program Files\Steam\steam.exe`, "Game.exe", "explorer.exe"} {
|
|
if !applicationProcess(program) {
|
|
t.Fatalf("app ignored: %s", program)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLaunchForwardsProtonSyncAndKeepsReaderBus(t *testing.T) {
|
|
root := t.TempDir()
|
|
output := filepath.Join(root, "environment")
|
|
helper := filepath.Join(root, "helper")
|
|
write_helper_file(t, helper, "#!/bin/sh\nenv > \"$TEST_WINE_ENV\"\n")
|
|
t.Setenv("TEST_WINE_ENV", output)
|
|
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/reader/bus")
|
|
manager := New(nil)
|
|
manager.helper = helper
|
|
child := &process{}
|
|
source := prefix{path: root, environment: map[string]string{"WINEESYNC": "1", "WINEFSYNC": "1", "XAUTHORITY": "/fixture/auth", "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/pressure-vessel/bus"}}
|
|
if err := manager.launch(child, source, time.Now(), Settings{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-child.done:
|
|
case <-time.After(3 * time.Second):
|
|
stop(child)
|
|
t.Fatal("helper did not exit")
|
|
}
|
|
data, err := os.ReadFile(output)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, value := range []string{"WINEESYNC=1\n", "WINEFSYNC=1\n", "XAUTHORITY=/fixture/auth\n", "DBUS_SESSION_BUS_ADDRESS=unix:path=/reader/bus\n"} {
|
|
if !bytes.Contains(data, []byte(value)) {
|
|
t.Fatalf("missing %q", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDiscoverMixedPrefixKeepsOrdinaryProgramsReadable(t *testing.T) {
|
|
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "")
|
|
root := t.TempDir()
|
|
procRoot := filepath.Join(root, "proc")
|
|
loader := filepath.Join(root, "wine")
|
|
write_helper_file(t, loader, "loader")
|
|
makeProcess := func(pid, program string) {
|
|
t.Helper()
|
|
base := filepath.Join(procRoot, pid)
|
|
write_helper_file(t, filepath.Join(base, "cmdline"), program+"\x00")
|
|
write_helper_file(t, filepath.Join(base, "environ"), "WINEPREFIX="+root+"\x00")
|
|
if err := os.Symlink(loader, filepath.Join(base, "exe")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
makeProcess("101", "launcher.exe")
|
|
helper := filepath.Join(root, "helper")
|
|
write_helper_file(t, helper, "#!/bin/sh\nexec sleep 30\n")
|
|
current := Settings{ExcludedPrograms: []string{"GAME.exe"}}
|
|
manager := New(nil)
|
|
manager.helper, manager.procRoot = helper, procRoot
|
|
manager.SettingsSource = func() Settings { return current }
|
|
active := map[string]*process{}
|
|
defer func() {
|
|
for _, child := range active {
|
|
stop(child)
|
|
}
|
|
}()
|
|
// Launcher only: the exclusion flag is already armed, in normal mode.
|
|
manager.poll(active)
|
|
child := active[root]
|
|
if child == nil || child.cmd == nil || child.controllerOnly {
|
|
t.Fatal("launcher helper did not start normally")
|
|
}
|
|
if !child.exclusions["game.exe"] {
|
|
t.Fatal("launcher helper lacks the armed exclusion flag")
|
|
}
|
|
originalPID := child.cmd.Process.Pid
|
|
// The listed game and an ordinary program appear. The helper already
|
|
// carries the flag, so nothing restarts and nothing goes wholesale:
|
|
// the ordinary program keeps dialog reading by construction, since
|
|
// --no-dialog-reader was never passed.
|
|
makeProcess("102", "Game.exe")
|
|
makeProcess("103", "notepad.exe")
|
|
discovered := discover(procRoot, uint32(os.Getuid()))
|
|
if len(discovered[root].programs) != 3 {
|
|
t.Fatalf("programs = %v", discovered[root].programs)
|
|
}
|
|
manager.poll(active)
|
|
if child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid != originalPID {
|
|
t.Fatal("listed game restarted or flipped the helper wholesale")
|
|
}
|
|
// The listed game disappears: still no restart and no mode change.
|
|
if err := os.RemoveAll(filepath.Join(procRoot, "102")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manager.poll(active)
|
|
if child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid != originalPID {
|
|
t.Fatal("helper restarted when the listed game disappeared")
|
|
}
|
|
// A listing for a program that is not running waits for the next
|
|
// natural spawn: no restart for prospective protection.
|
|
current.ExcludedPrograms = []string{"GAME.exe", "Absent.exe"}
|
|
manager.poll(active)
|
|
if child.cmd.Process.Pid != originalPID {
|
|
t.Fatal("helper restarted for a listed program that is not running")
|
|
}
|
|
// A newly listed program that is currently discovered but lacks its
|
|
// flag restarts the helper once, in the same mode, carrying the flag.
|
|
current.ExcludedPrograms = []string{"GAME.exe", "Notepad.exe"}
|
|
manager.poll(active)
|
|
if child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == originalPID {
|
|
t.Fatal("newly listed running program did not restart the helper in place")
|
|
}
|
|
if !child.exclusions["notepad.exe"] {
|
|
t.Fatal("restarted helper lacks the new exclusion flag")
|
|
}
|
|
pid := child.cmd.Process.Pid
|
|
manager.poll(active)
|
|
if child.cmd.Process.Pid != pid {
|
|
t.Fatal("helper restarted more than once for one listing")
|
|
}
|
|
// Removing a listing never downgrades or restarts a running helper.
|
|
current.ExcludedPrograms = []string{"GAME.exe"}
|
|
manager.poll(active)
|
|
if child.cmd.Process.Pid != pid {
|
|
t.Fatal("helper restarted when a listing was removed")
|
|
}
|
|
// The global switch still upgrades a running helper to wholesale, and
|
|
// clearing it never downgrades the running helper.
|
|
current.ControllerOnly = true
|
|
manager.poll(active)
|
|
if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == pid {
|
|
t.Fatal("global switch did not upgrade the helper to wholesale")
|
|
}
|
|
pid = child.cmd.Process.Pid
|
|
current.ControllerOnly = false
|
|
manager.poll(active)
|
|
if !child.controllerOnly || child.cmd.Process.Pid != pid {
|
|
t.Fatal("wholesale helper downgraded after the global switch cleared")
|
|
}
|
|
}
|
|
|
|
func TestUnconfirmedHelperRestartsWholesaleOnceAndIsRemembered(t *testing.T) {
|
|
t.Setenv("WINE_ACCESS_NO_DIALOG_READER", "")
|
|
root := t.TempDir()
|
|
procRoot := filepath.Join(root, "proc")
|
|
loader := filepath.Join(root, "wine")
|
|
write_helper_file(t, loader, "loader")
|
|
base := filepath.Join(procRoot, "101")
|
|
write_helper_file(t, filepath.Join(base, "cmdline"), "launcher.exe\x00")
|
|
write_helper_file(t, filepath.Join(base, "environ"), "WINEPREFIX="+root+"\x00")
|
|
if err := os.Symlink(loader, filepath.Join(base, "exe")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
helper := filepath.Join(root, "helper")
|
|
// An old helper that ignores the exclusion flags reports zero.
|
|
script := "#!/bin/sh\n" +
|
|
"echo 'wine-access: ready {\"reader\":\"org.example.Reader\",\"controls_enabled\":true,\"hook_exclusions\":0}' >&2\n" +
|
|
"exec sleep 30\n"
|
|
write_helper_file(t, helper, script)
|
|
manager := New(nil)
|
|
manager.helper, manager.procRoot = helper, procRoot
|
|
manager.SettingsSource = func() Settings {
|
|
return Settings{ExcludedPrograms: []string{"Game.exe"}}
|
|
}
|
|
active := map[string]*process{}
|
|
defer func() {
|
|
for _, child := range active {
|
|
stop(child)
|
|
}
|
|
}()
|
|
manager.poll(active)
|
|
child := active[root]
|
|
if child == nil || child.cmd == nil || child.controllerOnly {
|
|
t.Fatal("first helper did not start normally")
|
|
}
|
|
if got := strings.Join(child.cmd.Args[1:], " "); got != "--exclude-hook-program game.exe" {
|
|
t.Fatalf("first launch arguments = %q", got)
|
|
}
|
|
firstPID := child.cmd.Process.Pid
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for !child.readySeen.Load() && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !child.readySeen.Load() {
|
|
t.Fatal("manager did not consume the ready record")
|
|
}
|
|
manager.poll(active)
|
|
if !child.controllerOnly || child.cmd == nil || child.cmd.Process.Pid == firstPID {
|
|
t.Fatal("unconfirmed helper did not restart wholesale")
|
|
}
|
|
if got := strings.Join(child.cmd.Args[1:], " "); got != "--exclude-hook-program game.exe --no-dialog-reader" {
|
|
t.Fatalf("wholesale launch arguments = %q", got)
|
|
}
|
|
wholesalePID := child.cmd.Process.Pid
|
|
manager.poll(active)
|
|
if child.cmd.Process.Pid != wholesalePID {
|
|
t.Fatal("unconfirmed helper restarted wholesale more than once")
|
|
}
|
|
// The binary is remembered: a later spawn goes wholesale directly.
|
|
stop(child)
|
|
fresh := map[string]*process{}
|
|
defer func() {
|
|
for _, child := range fresh {
|
|
stop(child)
|
|
}
|
|
}()
|
|
manager.poll(fresh)
|
|
replacement := fresh[root]
|
|
if replacement == nil || replacement.cmd == nil || !replacement.controllerOnly {
|
|
t.Fatal("remembered helper did not start wholesale directly")
|
|
}
|
|
if got := strings.Join(replacement.cmd.Args[1:], " "); got != "--exclude-hook-program game.exe --no-dialog-reader" {
|
|
t.Fatalf("remembered launch arguments = %q", got)
|
|
}
|
|
}
|