| 1 | //go:build windows |
| 2 | |
| 3 | package sandbox |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "reasonix/internal/proc" |
| 14 | ) |
| 15 | |
| 16 | // installedPowerShells returns every real PowerShell on this host, 5.1 first. |
| 17 | func installedPowerShells(t *testing.T) []Shell { |
| 18 | t.Helper() |
| 19 | var out []Shell |
| 20 | seen := map[string]bool{} |
| 21 | for _, p := range windowsPowerShellCandidates() { |
| 22 | if fileExists(p) && !seen[strings.ToLower(p)] { |
| 23 | seen[strings.ToLower(p)] = true |
| 24 | out = append(out, Shell{Kind: ShellPowerShell, Path: p}) |
| 25 | } |
| 26 | } |
| 27 | if p, err := exec.LookPath("pwsh"); err == nil && !seen[strings.ToLower(p)] { |
| 28 | out = append(out, Shell{Kind: ShellPowerShell, Path: p}) |
| 29 | } |
| 30 | if len(out) == 0 { |
| 31 | t.Skip("no PowerShell on this host") |
| 32 | } |
| 33 | return out |
| 34 | } |
| 35 | |
| 36 | func isWindowsPowerShell(sh Shell) bool { |
| 37 | return strings.EqualFold(pathBase(sh.Path), "powershell.exe") |
| 38 | } |
| 39 | |
| 40 | // runPowerShell runs script under sh in dir. setup, when set, runs first and then |
| 41 | // hands script to Invoke-Expression, so it can put the session into a state the |
| 42 | // script has to survive: no console, or ConstrainedLanguage. |
| 43 | func runPowerShell(t *testing.T, sh Shell, dir, setup, script string) (stdout, stderr string) { |
| 44 | t.Helper() |
| 45 | argv := []string{sh.Path, "-NoProfile", "-NonInteractive", "-Command", script} |
| 46 | env := os.Environ() |
| 47 | if setup != "" { |
| 48 | argv[len(argv)-1] = setup + "; Invoke-Expression $env:RX_TEST_SCRIPT" |
| 49 | env = append(env, "RX_TEST_SCRIPT="+script) |
| 50 | } |
| 51 | cmd := exec.Command(argv[0], argv[1:]...) |
| 52 | cmd.Dir = dir |
| 53 | cmd.Env = env |
| 54 | proc.HideWindow(cmd) |
| 55 | var so, se bytes.Buffer |
| 56 | cmd.Stdout, cmd.Stderr = &so, &se |
| 57 | if err := cmd.Run(); err != nil { |
| 58 | t.Fatalf("%s: %v\nstderr: %s", sh.Path, err, se.String()) |
| 59 | } |
| 60 | return so.String(), se.String() |
| 61 | } |
| 62 | |
| 63 | const ( |
| 64 | freeConsole = `Add-Type -Name K -Namespace RxTest -MemberDefinition '[DllImport("kernel32.dll")] public static extern bool FreeConsole();'; $null = [RxTest.K]::FreeConsole()` |
| 65 | constrained = `$ExecutionContext.SessionState.LanguageMode = 'ConstrainedLanguage'` |
| 66 | ) |
| 67 | |
| 68 | func assertUTF8File(t *testing.T, path, want string, bom bool) { |
| 69 | t.Helper() |
| 70 | got, err := os.ReadFile(path) |
| 71 | if err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | wantBytes := []byte(want) |
| 75 | if bom { |
| 76 | wantBytes = append([]byte{0xEF, 0xBB, 0xBF}, wantBytes...) |
| 77 | } |
| 78 | if !bytes.Equal(got, wantBytes) { |
| 79 | t.Fatalf("%s = % x, want % x", filepath.Base(path), got, wantBytes) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestPowerShellToolRedirectWritesUTF8(t *testing.T) { |
| 84 | for _, sh := range installedPowerShells(t) { |
| 85 | t.Run(filepath.Base(sh.Path), func(t *testing.T) { |
| 86 | dir := t.TempDir() |
| 87 | argv := sh.argv(`'中文' > f.txt; Get-Content f.txt`) |
| 88 | stdout, stderr := runPowerShell(t, sh, dir, "", argv[len(argv)-1]) |
| 89 | if stderr != "" || stdout != "中文\r\n" { |
| 90 | t.Fatalf("stdout=%q stderr=%q", stdout, stderr) |
| 91 | } |
| 92 | // Windows PowerShell's "utf8" always carries a BOM; 7+ writes none. |
| 93 | assertUTF8File(t, filepath.Join(dir, "f.txt"), "中文\r\n", isWindowsPowerShell(sh)) |
| 94 | }) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestPowerShellPrologueSurvivesRestrictedSessions(t *testing.T) { |
| 99 | for _, sh := range installedPowerShells(t) { |
| 100 | for name, setup := range map[string]string{"no-console": freeConsole, "constrained-language": constrained} { |
| 101 | t.Run(filepath.Base(sh.Path)+"/"+name, func(t *testing.T) { |
| 102 | dir := t.TempDir() |
| 103 | argv := sh.argv(`'中文' > f.txt; 'done'`) |
| 104 | stdout, stderr := runPowerShell(t, sh, dir, setup, argv[len(argv)-1]) |
| 105 | if stderr != "" { |
| 106 | t.Fatalf("prologue wrote errors: %q", stderr) |
| 107 | } |
| 108 | if !strings.HasSuffix(stdout, "done\r\n") { |
| 109 | t.Fatalf("command did not run after the prologue: %q", stdout) |
| 110 | } |
| 111 | assertUTF8File(t, filepath.Join(dir, "f.txt"), "中文\r\n", isWindowsPowerShell(sh)) |
| 112 | }) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestPowerShellPipesUTF8WithoutBOMToNativeCommands(t *testing.T) { |
| 118 | for _, sh := range installedPowerShells(t) { |
| 119 | t.Run(filepath.Base(sh.Path), func(t *testing.T) { |
| 120 | dir := t.TempDir() |
| 121 | // cmd's own redirect stores findstr's stdout untouched, so the file |
| 122 | // holds exactly the bytes PowerShell sent down the pipe. |
| 123 | argv := sh.argv(`'中文' | cmd.exe /d /c 'findstr . > p.txt'`) |
| 124 | _, stderr := runPowerShell(t, sh, dir, "", argv[len(argv)-1]) |
| 125 | if stderr != "" { |
| 126 | t.Fatalf("stderr=%q", stderr) |
| 127 | } |
| 128 | got, err := os.ReadFile(filepath.Join(dir, "p.txt")) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | if want := []byte("中文\r\n"); !bytes.Equal(got, want) { |
| 133 | t.Fatalf("piped bytes = % x, want % x", got, want) |
| 134 | } |
| 135 | }) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // Hooks run user scripts: they get UTF-8 output capture but keep PowerShell's own |
| 140 | // file-writing defaults. |
| 141 | func TestHookPrologueLeavesFileDefaultsAlone(t *testing.T) { |
| 142 | for _, sh := range installedPowerShells(t) { |
| 143 | t.Run(filepath.Base(sh.Path), func(t *testing.T) { |
| 144 | stdout, stderr := runPowerShell(t, sh, t.TempDir(), "", PowerShellUTF8Script(`'[' + $PSDefaultParameterValues['Out-File:Encoding'] + ']'; '中文'`)) |
| 145 | if stderr != "" || stdout != "[]\r\n中文\r\n" { |
| 146 | t.Fatalf("stdout=%q stderr=%q", stdout, stderr) |
| 147 | } |
| 148 | }) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // Execution aliases are what the Store installs pwsh as; they must be the paths |
| 153 | // that pay a launch probe. |
| 154 | func TestPowerShellLaunchesProbesRealExecutionAlias(t *testing.T) { |
| 155 | dir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "WindowsApps") |
| 156 | matches, _ := filepath.Glob(filepath.Join(dir, "*.exe")) |
| 157 | var alias string |
| 158 | for _, m := range matches { |
| 159 | if fi, err := os.Lstat(m); err == nil && fi.Mode()&os.ModeIrregular != 0 { |
| 160 | alias = m |
| 161 | break |
| 162 | } |
| 163 | } |
| 164 | if alias == "" { |
| 165 | t.Skip("no execution alias on this host") |
| 166 | } |
| 167 | probed := 0 |
| 168 | saved := aliasLaunches |
| 169 | aliasLaunches = &launchProbes{seen: map[string]launchProbe{}, run: func(string) bool { probed++; return false }} |
| 170 | t.Cleanup(func() { aliasLaunches = saved }) |
| 171 | if powerShellLaunches(alias) || probed != 1 { |
| 172 | t.Fatalf("%s: launches=%v probes=%d, want one failed probe", alias, powerShellLaunches(alias), probed) |
| 173 | } |
| 174 | } |
| 175 |