| 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 |