返回 DeepSeek-Reasonix
windows_compat.go
根目录 / internal / hook / windows_compat.go
1 package hook
2
3 import (
4 "errors"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "sync"
11 "unicode/utf8"
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 isSimpleWindowsBatchTail(tail string) bool {
125 quoted := false
126 for i := 0; i < len(tail); i++ {
127 switch tail[i] {
128 case '\r', '\n':
129 return false
130 case '"':
131 quoted = !quoted
132 case '&', '|', ';', '<', '>', '(', ')':
133 if !quoted {
134 return false
135 }
136 }
137 }
138 return !quoted
139 }
140
141 func renderWindowsBatchArg(arg string) (string, bool) {
142 // cmd.exe expands percent variables even inside quotes, and delayed
143 // expansion can do the same for exclamation marks. Keep argv-form support
144 // deliberately narrow instead of silently changing a literal argument.
145 if strings.ContainsAny(arg, "\"%!\r\n") {
146 return "", false
147 }
148 if arg == "" || strings.ContainsAny(arg, " \t&|;<>()^[]{}=' +,`~") {
149 return `"` + arg + `"`, true
150 }
151 return arg, true
152 }
153
154 func isBarePOSIXShellWord(word string) bool {
155 word = strings.TrimSpace(word)
156 if strings.ContainsAny(word, `/\:`) {
157 return false
158 }
159 word = strings.ToLower(word)
160 return word == "sh" || word == "sh.exe" || word == "bash" || word == "bash.exe"
161 }
162
163 func hasCommandStringFlag(args []string) bool {
164 for i := 0; i < len(args); i++ {
165 arg := args[i]
166 if arg == "-" || arg == "--" || !strings.HasPrefix(arg, "-") {
167 return false
168 }
169 if strings.HasPrefix(arg, "--") {
170 name, _, hasInlineValue := strings.Cut(strings.TrimPrefix(arg, "--"), "=")
171 if !hasInlineValue && bashLongOptionNeedsOperand(name) {
172 if i+1 >= len(args) {
173 return false
174 }
175 i++
176 }
177 continue
178 }
179 options := strings.TrimPrefix(arg, "-")
180 for optionIndex := 0; optionIndex < len(options); optionIndex++ {
181 switch options[optionIndex] {
182 case 'c':
183 return i+1 < len(args)
184 case 'o', 'O':
185 // -o/-O consume an option name. Any remaining bytes in this
186 // argument are that operand, not more single-letter flags.
187 if optionIndex+1 == len(options) {
188 if i+1 >= len(args) {
189 return false
190 }
191 i++
192 }
193 optionIndex = len(options)
194 }
195 }
196 }
197 return false
198 }
199
200 func bashLongOptionNeedsOperand(name string) bool {
201 return name == "init-file" || name == "rcfile"
202 }
203
204 func cachedWindowsHookBash() (string, error) {
205 windowsHookBash.Do(func() {
206 windowsHookBash.path, windowsHookBash.err = discoverWindowsHookBash("")
207 })
208 return windowsHookBash.path, windowsHookBash.err
209 }
210
211 func resolveWindowsHookBash(preferredPath string) (string, error) {
212 if strings.TrimSpace(preferredPath) == "" {
213 return cachedWindowsHookBash()
214 }
215 return discoverWindowsHookBash(preferredPath)
216 }
217
218 func discoverWindowsHookBash(preferredPath string) (string, error) {
219 shell := sandbox.ResolveShell("bash", preferredPath, nil)
220 if shell.Kind != sandbox.ShellBash {
221 return "", missingWindowsHookBashError()
222 }
223 path, err := resolvedHookShellPath(shell)
224 if err != nil {
225 return "", missingWindowsHookBashError()
226 }
227 return path, nil
228 }
229
230 func cachedWindowsDefaultHookShell() (sandbox.Shell, error) {
231 windowsDefaultHookShell.Do(func() {
232 sh := sandbox.ResolveShell("", "", nil)
233 path, err := resolvedHookShellPath(sh)
234 if err != nil {
235 windowsDefaultHookShell.err = errors.New("hook requires a shell on Windows, but neither Git Bash nor PowerShell is usable")
236 return
237 }
238 sh.Path = path
239 windowsDefaultHookShell.shell = sh
240 })
241 return windowsDefaultHookShell.shell, windowsDefaultHookShell.err
242 }
243
244 func resolvedHookShellPath(shell sandbox.Shell) (string, error) {
245 path := strings.TrimSpace(shell.Path)
246 if path == "" {
247 path = shell.Kind.String()
248 }
249 if resolved, err := exec.LookPath(path); err == nil {
250 return resolved, nil
251 }
252 if filepath.IsAbs(path) {
253 if info, err := os.Stat(path); err == nil && !info.IsDir() {
254 return path, nil
255 }
256 }
257 return "", fmt.Errorf("hook shell %q is not executable", path)
258 }
259
260 func missingWindowsHookBashError() error {
261 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")
262 }
263
264 // decodeHookOutput keeps UTF-8-native runtimes such as Node byte-for-byte,
265 // while recovering legacy Windows cmd.exe output (notably CP936/GB18030) before
266 // it reaches the desktop renderer. Hook stdout/stderr are text contracts, so a
267 // final valid-UTF-8 guard is safer than surfacing raw invalid bytes.
268 func decodeHookOutput(raw []byte, truncated bool) string {
269 if len(raw) == 0 {
270 return ""
271 }
272 decoded := raw
273 if !utf8.Valid(raw) {
274 if prefix, ok := truncatedUTF8Prefix(raw, truncated); ok {
275 decoded = prefix
276 } else {
277 decoded = fileencoding.DecodeToUTF8(raw)
278 }
279 }
280 return strings.TrimSpace(strings.ToValidUTF8(string(decoded), "\uFFFD"))
281 }
282
283 func truncatedUTF8Prefix(raw []byte, truncated bool) ([]byte, bool) {
284 if !truncated {
285 return nil, false
286 }
287 for suffixLen := 1; suffixLen < utf8.UTFMax && suffixLen <= len(raw); suffixLen++ {
288 prefix := raw[:len(raw)-suffixLen]
289 suffix := raw[len(raw)-suffixLen:]
290 if utf8.Valid(prefix) && !utf8.FullRune(suffix) {
291 return prefix, true
292 }
293 }
294 return nil, false
295 }
296
296 lines GO