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