返回 DeepSeek-Reasonix
shell_powershell_windows_test.go
根目录 / internal / sandbox / shell_powershell_windows_test.go
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
175 lines GO