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") } }