返回 DeepSeek-Reasonix
windows_compat.go
根目录 / internal / hook / windows_compat.go
1 package hook
2
3 import (
4 "errors"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "sync"
12
13 fileencoding "reasonix/internal/fileutil/encoding"
14 "reasonix/internal/sandbox"
15 )
16
17 var windowsHookBash struct {
18 sync.Once
19 path string
20 err error
21 }
22
23 var windowsDefaultHookShell struct {
24 sync.Once
25 shell sandbox.Shell
26 err error
27 }
28
29 // These helpers preserve explicit `sh -c` / `bash -c` hook contracts on
30 // Windows while allowing the caller to supply the effective configured Bash.
31 func windowsPOSIXShellArgvInvocationWith(command string, args []string, resolve func() (string, error)) (string, []string, bool, error) {
32 if !isBarePOSIXShellWord(command) || !hasCommandStringFlag(args) {
33 return "", nil, false, nil
34 }
35 path, err := resolve()
36 if err != nil {
37 return "", nil, true, err
38 }
39 return path, append([]string(nil), args...), true, nil
40 }
41
42 func windowsPOSIXShellInvocationWith(command string, resolve func() (string, error)) (string, []string, bool, error) {
43 fields, _, _, ok := parseSimpleHookCommandFields(command)
44 if !ok || len(fields) < 3 || !isBarePOSIXShellWord(fields[0]) || !hasCommandStringFlag(fields[1:]) {
45 return "", nil, false, nil
46 }
47 path, err := resolve()
48 if err != nil {
49 return "", nil, true, err
50 }
51 return path, append([]string(nil), fields[1:]...), true, nil
52 }
53
54 // windowsBatchCommandLine builds the cmd.exe command line for a shell-form .cmd
55 // or .bat hook whose executable is already quoted. Go's default Windows
56 // argument encoder follows CommandLineToArgvW, but cmd.exe has different quote
57 // rules: passing a command string that starts with a quoted executable can leave
58 // the quotes escaped into the command name. Preserve the original argument tail
59 // byte-for-byte so valid batch syntax is not reinterpreted.
60 func windowsBatchCommandLine(command string) (string, bool) {
61 command = strings.TrimSpace(command)
62 if len(command) < 2 || command[0] != '"' {
63 return "", false
64 }
65 closingQuote := strings.IndexByte(command[1:], '"')
66 if closingQuote < 0 {
67 return "", false
68 }
69 closingQuote++
70 executable := normalizeWindowsBatchExecutable(command[1:closingQuote])
71 if !isWindowsBatchExecutable(executable) {
72 return "", false
73 }
74 tail := command[closingQuote+1:]
75 if tail != "" && !isShellWhitespace(tail[0]) {
76 return "", false
77 }
78 if !isSimpleWindowsBatchTail(tail) {
79 return "", false
80 }
81 // /s strips the first and last quotes around the /c string, leaving the
82 // quoted executable and its untouched argument tail for cmd.exe to parse.
83 return `cmd.exe /d /s /c ""` + executable + `"` + tail + `"`, true
84 }
85
86 func windowsBatchArgvCommandLine(command string, args []string) (string, bool) {
87 executable := normalizeWindowsBatchExecutable(command)
88 if !isWindowsBatchExecutable(executable) || strings.ContainsAny(executable, "\"%!\r\n") {
89 return "", false
90 }
91
92 var b strings.Builder
93 b.WriteString(`cmd.exe /d /s /c ""`)
94 b.WriteString(executable)
95 b.WriteByte('"')
96 for _, arg := range args {
97 rendered, ok := renderWindowsBatchArg(arg)
98 if !ok {
99 return "", false
100 }
101 b.WriteByte(' ')
102 b.WriteString(rendered)
103 }
104 b.WriteByte('"')
105 return b.String(), true
106 }
107
108 // windowsCmdCommandLine wraps a raw shell-form script without tokenizing or
109 // re-rendering it. cmd.exe owns all quote, variable, pipeline, and chaining
110 // semantics inside the /c string.
111 func windowsCmdCommandLine(command string) string {
112 return `cmd.exe /d /s /c "` + command + `"`
113 }
114
115 func normalizeWindowsBatchExecutable(executable string) string {
116 return strings.ReplaceAll(strings.TrimSpace(executable), "/", `\`)
117 }
118
119 func isWindowsBatchExecutable(executable string) bool {
120 lower := strings.ToLower(executable)
121 return strings.HasSuffix(lower, ".cmd") || strings.HasSuffix(lower, ".bat")
122 }
123
124 func isPOSIXShellScriptFile(path string) bool {
125 path = strings.TrimSpace(path)
126 if path == "" || isWindowsBatchExecutable(path) {
127 return false
128 }
129 info, err := os.Stat(path)
130 if err != nil || !info.Mode().IsRegular() {
131 return false
132 }
133 file, err := os.Open(path)
134 if err != nil {
135 return false
136 }
137 body, readErr := io.ReadAll(io.LimitReader(file, 512))
138 closeErr := file.Close()
139 if readErr != nil || closeErr != nil || len(body) < 3 || body[0] != '#' || body[1] != '!' {
140 return false
141 }
142 line := strings.TrimSpace(strings.SplitN(string(body[2:]), "\n", 2)[0])
143 if line == "" {
144 return false
145 }
146 for field := range strings.FieldsSeq(line) {
147 field = strings.Trim(strings.ToLower(field), `"'`)
148 field = strings.TrimSuffix(filepath.Base(filepath.ToSlash(field)), ".exe")
149 switch field {
150 case "sh", "bash", "dash", "zsh", "ksh":
151 return true
152 }
153 }
154 return false
155 }
156
157 func isSimpleWindowsBatchTail(tail string) bool {
158 quoted := false
159 for i := range len(tail) {
160 switch tail[i] {
161 case '\r', '\n':
162 return false
163 case '"':
164 quoted = !quoted
165 case '&', '|', ';', '<', '>', '(', ')':
166 if !quoted {
167 return false
168 }
169 }
170 }
171 return !quoted
172 }
173
174 func renderWindowsBatchArg(arg string) (string, bool) {
175 // cmd.exe expands percent variables even inside quotes, and delayed
176 // expansion can do the same for exclamation marks. Keep argv-form support
177 // deliberately narrow instead of silently changing a literal argument.
178 if strings.ContainsAny(arg, "\"%!\r\n") {
179 return "", false
180 }
181 if arg == "" || strings.ContainsAny(arg, " \t&|;<>()^[]{}=' +,`~") {
182 return `"` + arg + `"`, true
183 }
184 return arg, true
185 }
186
187 func isBarePOSIXShellWord(word string) bool {
188 word = strings.TrimSpace(word)
189 if strings.ContainsAny(word, `/\:`) {
190 return false
191 }
192 word = strings.ToLower(word)
193 return word == "sh" || word == "sh.exe" || word == "bash" || word == "bash.exe"
194 }
195
196 func hasCommandStringFlag(args []string) bool {
197 for i := 0; i < len(args); i++ {
198 arg := args[i]
199 if arg == "-" || arg == "--" || !strings.HasPrefix(arg, "-") {
200 return false
201 }
202 if after, ok := strings.CutPrefix(arg, "--"); ok {
203 name, _, hasInlineValue := strings.Cut(after, "=")
204 if !hasInlineValue && bashLongOptionNeedsOperand(name) {
205 if i+1 >= len(args) {
206 return false
207 }
208 i++
209 }
210 continue
211 }
212 options := strings.TrimPrefix(arg, "-")
213 for optionIndex := 0; optionIndex < len(options); optionIndex++ {
214 switch options[optionIndex] {
215 case 'c':
216 return i+1 < len(args)
217 case 'o', 'O':
218 // -o/-O consume an option name. Any remaining bytes in this
219 // argument are that operand, not more single-letter flags.
220 if optionIndex+1 == len(options) {
221 if i+1 >= len(args) {
222 return false
223 }
224 i++
225 }
226 optionIndex = len(options)
227 }
228 }
229 }
230 return false
231 }
232
233 func bashLongOptionNeedsOperand(name string) bool {
234 return name == "init-file" || name == "rcfile"
235 }
236
237 func cachedWindowsHookBash() (string, error) {
238 windowsHookBash.Do(func() {
239 windowsHookBash.path, windowsHookBash.err = discoverWindowsHookBash("")
240 })
241 return windowsHookBash.path, windowsHookBash.err
242 }
243
244 func resolveWindowsHookBash(preferredPath string) (string, error) {
245 if strings.TrimSpace(preferredPath) == "" {
246 return cachedWindowsHookBash()
247 }
248 return discoverWindowsHookBash(preferredPath)
249 }
250
251 func discoverWindowsHookBash(preferredPath string) (string, error) {
252 shell, ok := sandbox.ResolveExplicitBash(preferredPath)
253 if !ok {
254 return "", missingWindowsHookBashError()
255 }
256 path, err := resolvedHookShellPath(shell)
257 if err != nil {
258 return "", missingWindowsHookBashError()
259 }
260 return path, nil
261 }
262
263 func cachedWindowsDefaultHookShell() (sandbox.Shell, error) {
264 windowsDefaultHookShell.Do(func() {
265 sh := sandbox.ResolveShell("", "", nil)
266 path, err := resolvedHookShellPath(sh)
267 if err != nil {
268 windowsDefaultHookShell.err = errors.New("hook requires a shell on Windows, but neither Git Bash nor PowerShell is usable")
269 return
270 }
271 sh.Path = path
272 windowsDefaultHookShell.shell = sh
273 })
274 return windowsDefaultHookShell.shell, windowsDefaultHookShell.err
275 }
276
277 func resolvedHookShellPath(shell sandbox.Shell) (string, error) {
278 path := strings.TrimSpace(shell.Path)
279 if path == "" {
280 path = shell.Kind.String()
281 }
282 if resolved, err := exec.LookPath(path); err == nil {
283 return resolved, nil
284 }
285 if filepath.IsAbs(path) {
286 if info, err := os.Stat(path); err == nil && !info.IsDir() {
287 return path, nil
288 }
289 }
290 return "", fmt.Errorf("hook shell %q is not executable", path)
291 }
292
293 func missingWindowsHookBashError() error {
294 return errors.New("hook requires a POSIX shell on Windows, but no usable Git Bash was found; install Git for Windows or replace the POSIX shell hook with a native portable command")
295 }
296
297 // decodeHookOutput keeps UTF-8-native runtimes such as Node byte-for-byte,
298 // while recovering legacy Windows cmd.exe output (notably CP936/GB18030) before
299 // it reaches the desktop renderer. Hook stdout/stderr are text contracts, so a
300 // final valid-UTF-8 guard is safer than surfacing raw invalid bytes.
301 func decodeHookOutput(raw []byte, truncated bool) string {
302 decoded := fileencoding.DecodeOutput(raw, fileencoding.Cut{Tail: truncated})
303 return strings.TrimSpace(strings.ToValidUTF8(string(decoded), "\uFFFD"))
304 }
305
305 lines GO