Files

355 lines
12 KiB
Go

package wine
import (
"context"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func TestDiscoverWinePrefixesAndExcludeManagedHelper(t *testing.T) {
root := t.TempDir()
loader := filepath.Join(root, "wine-preloader")
if err := os.WriteFile(loader, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
prefixPath := filepath.Join(root, "prefix")
if err := os.Mkdir(prefixPath, 0700); err != nil {
t.Fatal(err)
}
procRoot := filepath.Join(root, "proc")
if err := os.Mkdir(procRoot, 0700); err != nil {
t.Fatal(err)
}
makeProcess := func(pid int, exe, environment string) {
t.Helper()
path := filepath.Join(procRoot, strconv.Itoa(pid))
if err := os.Mkdir(path, 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(exe, filepath.Join(path, "exe")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "environ"), []byte(environment), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "cmdline"), []byte("C:\\app\\application.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
}
makeProcess(101, loader, "WINEPREFIX="+prefixPath+"\x00DISPLAY=:7\x00")
makeProcess(102, loader, "WINEPREFIX="+prefixPath+"\x00"+managedKey+"=1\x00")
makeProcess(103, "/bin/sleep", "WINEPREFIX="+prefixPath+"\x00")
makeProcess(104, loader, "WINEPREFIX="+filepath.Join(root, "idle-prefix")+"\x00")
if err := os.WriteFile(filepath.Join(procRoot, "104", "cmdline"), []byte("C:\\windows\\system32\\winedevice.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
got := discover(procRoot, uint32(os.Getuid()))
if len(got) != 1 {
t.Fatalf("prefixes = %+v, want one unmanaged Wine prefix", got)
}
selected := got[prefixPath]
if selected.path != prefixPath || selected.environment["DISPLAY"] != ":7" {
t.Fatalf("prefix = %+v", selected)
}
if selected.loader != "" {
t.Fatalf("missing derived wine loader should fall back to launcher default, got %q", selected.loader)
}
}
func TestDiscoverDefaultWinePrefix(t *testing.T) {
t.Setenv("HOME", t.TempDir())
for _, test := range []struct {
name, extra, want string
}{
{name: "unset prefix", want: "home/.wine"},
{name: "empty prefix", extra: "WINEPREFIX=\x00", want: "home/.wine"},
{name: "explicit prefix", extra: "WINEPREFIX={root}/custom\x00STEAM_COMPAT_DATA_PATH={root}/steam\x00", want: "custom"},
{name: "Steam prefix", extra: "STEAM_COMPAT_DATA_PATH={root}/steam\x00", want: "steam/pfx"},
{name: "managed helper", extra: managedKey + "=1\x00"},
{name: "missing home", extra: "HOME=\x00"},
{name: "relative home", extra: "HOME=relative\x00"},
} {
t.Run(test.name, func(t *testing.T) {
root := t.TempDir()
loader := filepath.Join(root, "wine")
if err := os.WriteFile(loader, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
proc := filepath.Join(root, "200")
if err := os.Mkdir(proc, 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(loader, filepath.Join(proc, "exe")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("application.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
environment := "HOME=" + filepath.Join(root, "home") + "\x00DISPLAY=:7\x00" +
strings.ReplaceAll(test.extra, "{root}", root)
if err := os.WriteFile(filepath.Join(proc, "environ"), []byte(environment), 0600); err != nil {
t.Fatal(err)
}
got := discover(root, uint32(os.Getuid()))
if test.want == "" {
if len(got) != 0 {
t.Fatalf("prefixes = %+v, want none", got)
}
return
}
want := filepath.Join(root, test.want)
if len(got) != 1 {
t.Fatalf("prefixes = %+v, want only %q", got, want)
}
selected := got[want]
if selected.path != want || selected.loader != loader || selected.environment["DISPLAY"] != ":7" {
t.Fatalf("prefix = %+v, want %q with its loader and display", selected, want)
}
})
}
}
func TestHelperPathPrefersConfiguredThenLocalHelper(t *testing.T) {
root := t.TempDir()
t.Setenv("HOME", root)
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", "")
legacy := filepath.Join(root, ".local", "libexec", "cthulhu", "wine", "cthulhu-wine-access.exe")
if err := os.MkdirAll(filepath.Dir(legacy), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(legacy, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
if got := helperPath(); got != legacy {
t.Fatalf("helperPath() = %q, want existing user helper %q", got, legacy)
}
local := filepath.Join(root, ".local", "libexec", "wine-access", "wine-access.exe")
if err := os.MkdirAll(filepath.Dir(local), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(local, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
if got := helperPath(); got != local {
t.Fatalf("helperPath() = %q, want %q", got, local)
}
configured := filepath.Join(root, "configured-helper")
if err := os.WriteFile(configured, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", configured)
if got := helperPath(); got != configured {
t.Fatalf("helperPath() = %q, want %q", got, configured)
}
}
func TestFindHelperPrecedenceAndExecutableFiltering(t *testing.T) {
root := t.TempDir()
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", "")
t.Setenv("PATH", root)
candidates := []string{
filepath.Join(root, "user", "wine-access.exe"),
filepath.Join(root, "usr-local", "wine-access.exe"),
filepath.Join(root, "usr", "wine-access.exe"),
}
for _, path := range candidates {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
}
if got := findHelper(candidates); got != "" {
t.Fatalf("missing helpers = %q, want empty", got)
}
legacy := filepath.Join(root, "cthulhu-wine-access.exe")
pathHelper := filepath.Join(root, "wine-access.exe")
for _, path := range []string{legacy, pathHelper, candidates[2], candidates[1], candidates[0]} {
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
if got := findHelper(candidates); got != path {
t.Fatalf("findHelper() = %q, want %q", got, path)
}
}
if err := os.Chmod(candidates[0], 0600); err != nil {
t.Fatal(err)
}
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", candidates[0])
if got := findHelper(candidates); got != candidates[1] {
t.Fatalf("non-executable override/user helper = %q, want %q", got, candidates[1])
}
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", root)
if got := findHelper(candidates); got != candidates[1] {
t.Fatalf("directory override = %q, want %q", got, candidates[1])
}
t.Setenv("CTHULHU_WINE_ACCESS_HELPER", pathHelper)
if got := findHelper(candidates); got != pathHelper {
t.Fatalf("executable override = %q, want %q", got, pathHelper)
}
}
func TestManagerLaunchesAndStopsHelperForActivePrefix(t *testing.T) {
root := t.TempDir()
procRoot := filepath.Join(root, "proc")
if err := os.Mkdir(procRoot, 0700); err != nil {
t.Fatal(err)
}
loader := filepath.Join(root, "wine")
if err := os.WriteFile(loader, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
prefixPath := filepath.Join(root, "prefix")
if err := os.Mkdir(prefixPath, 0700); err != nil {
t.Fatal(err)
}
proc := filepath.Join(procRoot, "300")
if err := os.Mkdir(proc, 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(loader, filepath.Join(proc, "exe")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("application.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "environ"), []byte("WINEPREFIX="+prefixPath+"\x00WINELOADER="+loader+"\x00"), 0600); err != nil {
t.Fatal(err)
}
output := filepath.Join(root, "helper-output")
t.Setenv("CTHULHU_TEST_WINE_OUTPUT", output)
helper := filepath.Join(root, "helper")
script := "#!/bin/sh\nprintf '%s\\n' \"$WINEPREFIX\" \"$CTHULHU_WINE_ACCESS_MANAGED\" > \"$CTHULHU_TEST_WINE_OUTPUT\"\nexec sleep 30\n"
if err := os.WriteFile(helper, []byte(script), 0700); err != nil {
t.Fatal(err)
}
manager := &Manager{logger: slog.New(slog.DiscardHandler), helper: helper, procRoot: procRoot}
if err := manager.Start(context.Background()); err != nil {
t.Fatal(err)
}
defer manager.Stop(context.Background())
deadline := time.Now().Add(time.Second)
var contents []byte
for time.Now().Before(deadline) {
contents, _ = os.ReadFile(output)
if len(contents) != 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if string(contents) != prefixPath+"\n1\n" {
t.Fatalf("helper environment = %q", contents)
}
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := manager.Stop(stopCtx); err != nil {
t.Fatal(err)
}
}
func TestDiscoverSteamCompatPrefix(t *testing.T) {
root := t.TempDir()
loader := filepath.Join(root, "wine")
if err := os.WriteFile(loader, []byte("#!/bin/sh\n"), 0700); err != nil {
t.Fatal(err)
}
proc := filepath.Join(root, "200")
if err := os.Mkdir(proc, 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(loader, filepath.Join(proc, "exe")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("application.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "environ"), []byte(strings.Join([]string{
"STEAM_COMPAT_DATA_PATH=" + root, "WINELOADER=" + loader, ""}, "\x00")), 0600); err != nil {
t.Fatal(err)
}
got := discover(root, uint32(os.Getuid()))
selected := got[filepath.Join(root, "pfx")]
if selected.loader != loader {
t.Fatalf("Steam loader = %q, want %q", selected.loader, loader)
}
}
func TestApplicationProcessExcludesWineServices(t *testing.T) {
for _, program := range []string{"services.exe", "winedevice.exe", "svchost.exe", "plugplay.exe", "rpcss.exe", "lsass.exe", "wineboot.exe", "winemenubuilder.exe"} {
if applicationProcess("C:\\windows\\system32\\" + strings.ToUpper(program) + "\x00") {
t.Errorf("Wine service %q counted as an application", program)
}
}
for _, test := range []struct {
commandLine string
want bool
}{
{"", false},
{"C:\\windows\\explorer.exe\x00/desktop\x00", false},
{"C:\\windows\\explorer.exe\x00/DESKTOP=Default,1024x768\x00", false},
{"C:\\windows\\explorer.exe\x00C:\\documents\x00", true},
{"C:\\Program Files\\Application\\app.exe\x00/desktop\x00", true},
{"C:\\windows\\system32\\notepad.exe\x00", true},
{"/usr/bin/wine\x00application.exe\x00", true},
} {
if got := applicationProcess(test.commandLine); got != test.want {
t.Errorf("applicationProcess(%q) = %v, want %v", test.commandLine, got, test.want)
}
}
}
func TestManagerStopsHelperWhenOnlyWineServicesRemain(t *testing.T) {
root := t.TempDir()
proc := filepath.Join(root, "101")
if err := os.Mkdir(proc, 0700); err != nil {
t.Fatal(err)
}
loader := filepath.Join(root, "wine")
helper := filepath.Join(root, "helper")
for _, path := range []string{loader, helper} {
if err := os.WriteFile(path, []byte("#!/bin/sh\nexec sleep 30\n"), 0700); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(loader, filepath.Join(proc, "exe")); err != nil {
t.Fatal(err)
}
prefixPath := filepath.Join(root, "prefix")
if err := os.WriteFile(filepath.Join(proc, "environ"), []byte("WINEPREFIX="+prefixPath+"\x00"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("application.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
manager := &Manager{logger: slog.New(slog.DiscardHandler), helper: helper, procRoot: root}
active := make(map[string]*process)
manager.poll(active)
child := active[prefixPath]
if child == nil || child.cmd == nil {
t.Fatal("application did not start a helper")
}
defer stop(child)
if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("C:\\windows\\system32\\winedevice.exe\x00"), 0600); err != nil {
t.Fatal(err)
}
manager.poll(active)
if child.cmd == nil {
t.Fatal("helper stopped before the idle grace period")
}
child.seen = time.Now().Add(-11 * time.Second)
manager.poll(active)
if len(active) != 0 || child.cmd != nil {
t.Fatal("Wine service kept the helper alive after the idle grace period")
}
manager.poll(active)
if len(active) != 0 {
t.Fatal("Wine service restarted the helper")
}
}