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