| 1 | type CommandWrapper = "env" | "sudo"; |
| 2 | type CommandProgramContext = "exec" | "shell"; |
| 3 | |
| 4 | const MAX_COMMAND_SEGMENTS = 64; |
| 5 | |
| 6 | const SHELL_PROGRAMS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish"]); |
| 7 | const WINDOWS_SHELL_PROGRAMS = new Set([ |
| 8 | "cmd", |
| 9 | "cmd.exe", |
| 10 | "powershell", |
| 11 | "powershell.exe", |
| 12 | "pwsh", |
| 13 | "pwsh.exe", |
| 14 | ]); |
| 15 | const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "-O", "--rcfile", "--init-file"]); |
| 16 | const SHELL_COMMAND_WRAPPERS = new Set(["builtin", "command", "exec"]); |
| 17 | const SHELL_PRECOMMAND_MODIFIERS = new Set(["nocorrect", "noglob", "time"]); |
| 18 | const POWERSHELL_SETUP_PROGRAMS = new Set(["pop-location", "push-location", "set-location"]); |
| 19 | const POWERSHELL_FLAGS = new Set(["-mta", "-nologo", "-noninteractive", "-noprofile", "-sta"]); |
| 20 | const POWERSHELL_OPTIONS_WITH_VALUE = new Set([ |
| 21 | "-configurationname", |
| 22 | "-executionpolicy", |
| 23 | "-inputformat", |
| 24 | "-outputformat", |
| 25 | "-version", |
| 26 | "-windowstyle", |
| 27 | "-workingdirectory", |
| 28 | ]); |
| 29 | const START_PROCESS_FLAGS = new Set([ |
| 30 | "-confirm", |
| 31 | "-debug", |
| 32 | "-loaduserprofile", |
| 33 | "-nonewwindow", |
| 34 | "-passthru", |
| 35 | "-usenewenvironment", |
| 36 | "-verbose", |
| 37 | "-wait", |
| 38 | "-whatif", |
| 39 | ]); |
| 40 | const START_PROCESS_OPTIONS_WITH_VALUE = new Set([ |
| 41 | "-argumentlist", |
| 42 | "-credential", |
| 43 | "-environment", |
| 44 | "-erroraction", |
| 45 | "-errorvariable", |
| 46 | "-informationaction", |
| 47 | "-informationvariable", |
| 48 | "-outbuffer", |
| 49 | "-outvariable", |
| 50 | "-pipelinevariable", |
| 51 | "-progressaction", |
| 52 | "-redirectstandarderror", |
| 53 | "-redirectstandardinput", |
| 54 | "-redirectstandardoutput", |
| 55 | "-verb", |
| 56 | "-warningaction", |
| 57 | "-warningvariable", |
| 58 | "-windowstyle", |
| 59 | "-workingdirectory", |
| 60 | ]); |
| 61 | const SKIPPABLE_SUDO_PROBES = new Set(["[", "[[", "test", "true"]); |
| 62 | const NON_PROGRAM_PREFIX_CHARACTERS = "<>(){}[];|&$`#!%@:"; |
| 63 | const NON_PROGRAM_SUFFIX_CHARACTERS = "){]}`"; |
| 64 | |
| 65 | // These tokens describe shell syntax or shell-local control flow, not a useful |
| 66 | // executable name. Falling back to "command" is less misleading than labels |
| 67 | // such as "Ran if", "Ran [", or "Ran function". |
| 68 | const NON_DESCRIPTIVE_SHELL_PROGRAMS = new Set([ |
| 69 | "!", |
| 70 | "#", |
| 71 | ".", |
| 72 | ":", |
| 73 | "[", |
| 74 | "[[", |
| 75 | "alias", |
| 76 | "and", |
| 77 | "autoload", |
| 78 | "begin", |
| 79 | "bg", |
| 80 | "bind", |
| 81 | "bindkey", |
| 82 | "break", |
| 83 | "builtin", |
| 84 | "caller", |
| 85 | "case", |
| 86 | "catch", |
| 87 | "cd", |
| 88 | "command", |
| 89 | "compgen", |
| 90 | "complete", |
| 91 | "compopt", |
| 92 | "continue", |
| 93 | "coproc", |
| 94 | "declare", |
| 95 | "dirs", |
| 96 | "disown", |
| 97 | "do", |
| 98 | "done", |
| 99 | "elif", |
| 100 | "else", |
| 101 | "enable", |
| 102 | "end", |
| 103 | "esac", |
| 104 | "eval", |
| 105 | "exec", |
| 106 | "exit", |
| 107 | "export", |
| 108 | "false", |
| 109 | "fc", |
| 110 | "fg", |
| 111 | "fi", |
| 112 | "finally", |
| 113 | "for", |
| 114 | "foreach", |
| 115 | "function", |
| 116 | "getopts", |
| 117 | "history", |
| 118 | "if", |
| 119 | "in", |
| 120 | "jobs", |
| 121 | "let", |
| 122 | "local", |
| 123 | "logout", |
| 124 | "mapfile", |
| 125 | "nocorrect", |
| 126 | "noglob", |
| 127 | "not", |
| 128 | "or", |
| 129 | "popd", |
| 130 | "pushd", |
| 131 | "read", |
| 132 | "readarray", |
| 133 | "readonly", |
| 134 | "repeat", |
| 135 | "return", |
| 136 | "select", |
| 137 | "set", |
| 138 | "setopt", |
| 139 | "shift", |
| 140 | "shopt", |
| 141 | "source", |
| 142 | "switch", |
| 143 | "suspend", |
| 144 | "test", |
| 145 | "then", |
| 146 | "time", |
| 147 | "times", |
| 148 | "trap", |
| 149 | "try", |
| 150 | "true", |
| 151 | "type", |
| 152 | "typeset", |
| 153 | "ulimit", |
| 154 | "umask", |
| 155 | "unalias", |
| 156 | "until", |
| 157 | "unset", |
| 158 | "unsetopt", |
| 159 | "wait", |
| 160 | "while", |
| 161 | ]); |
| 162 | |
| 163 | // Unlike setup builtins, these can make later segments part of control flow or |
| 164 | // otherwise unreachable, so do not use a later program as the command label. |
| 165 | const TERMINAL_SHELL_PROGRAMS = new Set([ |
| 166 | "and", |
| 167 | "begin", |
| 168 | "break", |
| 169 | "case", |
| 170 | "catch", |
| 171 | "continue", |
| 172 | "coproc", |
| 173 | "do", |
| 174 | "done", |
| 175 | "elif", |
| 176 | "else", |
| 177 | "end", |
| 178 | "esac", |
| 179 | "eval", |
| 180 | "exec", |
| 181 | "exit", |
| 182 | "false", |
| 183 | "fi", |
| 184 | "finally", |
| 185 | "for", |
| 186 | "foreach", |
| 187 | "function", |
| 188 | "if", |
| 189 | "in", |
| 190 | "not", |
| 191 | "or", |
| 192 | "repeat", |
| 193 | "return", |
| 194 | "select", |
| 195 | "switch", |
| 196 | "then", |
| 197 | "try", |
| 198 | "until", |
| 199 | "while", |
| 200 | ]); |
| 201 | |
| 202 | function shellCommandArgumentIndex(tokens: ReadonlyArray<string>, start: number): number | null { |
| 203 | for (let index = start; index < tokens.length; index += 1) { |
| 204 | const option = tokens[index]!; |
| 205 | if (option === "--" || !option.startsWith("-")) return null; |
| 206 | if (SHELL_OPTIONS_WITH_VALUE.has(option)) { |
| 207 | index += 1; |
| 208 | continue; |
| 209 | } |
| 210 | if (option === "--command" || /^-[a-zA-Z]*c[a-zA-Z]*$/.test(option)) return index + 1; |
| 211 | } |
| 212 | return null; |
| 213 | } |
| 214 | |
| 215 | const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = { |
| 216 | env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), |
| 217 | sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), |
| 218 | }; |
| 219 | |
| 220 | const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = { |
| 221 | env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug", "-v"]), |
| 222 | sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]), |
| 223 | }; |
| 224 | |
| 225 | function tokenizeShellCommand(command: string): string[] | null { |
| 226 | const input = command.trim(); |
| 227 | const tokens: string[] = []; |
| 228 | let current = ""; |
| 229 | let quote: '"' | "'" | null = null; |
| 230 | let escaping = false; |
| 231 | let inBackticks = false; |
| 232 | let substitutionDepth = 0; |
| 233 | let parameterExpansionDepth = 0; |
| 234 | let tokenStarted = false; |
| 235 | |
| 236 | for (let index = 0; index < input.length; index += 1) { |
| 237 | const character = input[index]!; |
| 238 | if (escaping) { |
| 239 | current += character; |
| 240 | escaping = false; |
| 241 | tokenStarted = true; |
| 242 | continue; |
| 243 | } |
| 244 | if (character === "\\" && quote !== "'") { |
| 245 | const nextCharacter = input[index + 1]; |
| 246 | const isWindowsPath = quote === null && /^(?:[A-Za-z]:|\.{1,2})(?:\\[^\s]*)?$/u.test(current); |
| 247 | if ( |
| 248 | (quote === '"' || isWindowsPath) && |
| 249 | nextCharacter !== undefined && |
| 250 | nextCharacter !== '"' && |
| 251 | nextCharacter !== "\\" && |
| 252 | nextCharacter !== "$" && |
| 253 | nextCharacter !== "`" && |
| 254 | nextCharacter !== "\n" |
| 255 | ) { |
| 256 | current += character; |
| 257 | tokenStarted = true; |
| 258 | continue; |
| 259 | } |
| 260 | escaping = true; |
| 261 | tokenStarted = true; |
| 262 | continue; |
| 263 | } |
| 264 | if (inBackticks) { |
| 265 | current += character; |
| 266 | if (character === "`") inBackticks = false; |
| 267 | tokenStarted = true; |
| 268 | continue; |
| 269 | } |
| 270 | if (quote !== null) { |
| 271 | if (character === quote) { |
| 272 | quote = null; |
| 273 | } else { |
| 274 | current += character; |
| 275 | } |
| 276 | tokenStarted = true; |
| 277 | continue; |
| 278 | } |
| 279 | if (character === "`") { |
| 280 | current += character; |
| 281 | inBackticks = true; |
| 282 | tokenStarted = true; |
| 283 | continue; |
| 284 | } |
| 285 | if (character === "$" && input[index + 1] === "{") { |
| 286 | current += "${"; |
| 287 | parameterExpansionDepth += 1; |
| 288 | tokenStarted = true; |
| 289 | index += 1; |
| 290 | continue; |
| 291 | } |
| 292 | if (character === "{" && parameterExpansionDepth > 0) { |
| 293 | current += character; |
| 294 | parameterExpansionDepth += 1; |
| 295 | tokenStarted = true; |
| 296 | continue; |
| 297 | } |
| 298 | if (character === "}" && parameterExpansionDepth > 0) { |
| 299 | current += character; |
| 300 | parameterExpansionDepth -= 1; |
| 301 | tokenStarted = true; |
| 302 | continue; |
| 303 | } |
| 304 | if (character === "$" && input[index + 1] === "(") { |
| 305 | current += "$("; |
| 306 | substitutionDepth += 1; |
| 307 | tokenStarted = true; |
| 308 | index += 1; |
| 309 | continue; |
| 310 | } |
| 311 | if (character === "(") { |
| 312 | current += character; |
| 313 | substitutionDepth += 1; |
| 314 | tokenStarted = true; |
| 315 | continue; |
| 316 | } |
| 317 | if (character === ")" && substitutionDepth > 0) { |
| 318 | current += character; |
| 319 | substitutionDepth -= 1; |
| 320 | tokenStarted = true; |
| 321 | continue; |
| 322 | } |
| 323 | if (character === '"' || character === "'") { |
| 324 | quote = character; |
| 325 | tokenStarted = true; |
| 326 | continue; |
| 327 | } |
| 328 | if (/\s/u.test(character)) { |
| 329 | if (substitutionDepth > 0 || parameterExpansionDepth > 0) { |
| 330 | current += character; |
| 331 | tokenStarted = true; |
| 332 | continue; |
| 333 | } |
| 334 | if (tokenStarted) { |
| 335 | tokens.push(current); |
| 336 | current = ""; |
| 337 | tokenStarted = false; |
| 338 | } |
| 339 | continue; |
| 340 | } |
| 341 | current += character; |
| 342 | tokenStarted = true; |
| 343 | } |
| 344 | |
| 345 | if ( |
| 346 | quote !== null || |
| 347 | escaping || |
| 348 | inBackticks || |
| 349 | substitutionDepth > 0 || |
| 350 | parameterExpansionDepth > 0 |
| 351 | ) { |
| 352 | return null; |
| 353 | } |
| 354 | if (tokenStarted) tokens.push(current); |
| 355 | return tokens; |
| 356 | } |
| 357 | |
| 358 | type ShellCommandSplit = { |
| 359 | readonly firstCommand: string; |
| 360 | readonly remainingCommand: string | null; |
| 361 | readonly separator: string | null; |
| 362 | }; |
| 363 | |
| 364 | type Heredoc = { |
| 365 | readonly delimiter: string; |
| 366 | readonly stripTabs: boolean; |
| 367 | }; |
| 368 | |
| 369 | type ShellSeparator = { |
| 370 | readonly index: number; |
| 371 | readonly length: number; |
| 372 | }; |
| 373 | |
| 374 | type ShellCommentRange = { |
| 375 | readonly start: number; |
| 376 | readonly end: number; |
| 377 | }; |
| 378 | |
| 379 | function commandWithoutShellComments( |
| 380 | command: string, |
| 381 | end: number, |
| 382 | comments: ReadonlyArray<ShellCommentRange>, |
| 383 | ): string { |
| 384 | let result = ""; |
| 385 | let cursor = 0; |
| 386 | for (const comment of comments) { |
| 387 | if (comment.start >= end) break; |
| 388 | result += command.slice(cursor, comment.start); |
| 389 | cursor = Math.min(comment.end, end); |
| 390 | } |
| 391 | return result + command.slice(cursor, end); |
| 392 | } |
| 393 | |
| 394 | function readHeredocDelimiter( |
| 395 | command: string, |
| 396 | start: number, |
| 397 | stripTabs: boolean, |
| 398 | ): { readonly heredoc: Heredoc; readonly end: number } | null { |
| 399 | let index = start; |
| 400 | while (command[index] === " " || command[index] === "\t") index += 1; |
| 401 | |
| 402 | let delimiter = ""; |
| 403 | let quote: '"' | "'" | null = null; |
| 404 | let escaping = false; |
| 405 | for (; index < command.length; index += 1) { |
| 406 | const character = command[index]!; |
| 407 | if (escaping) { |
| 408 | delimiter += character; |
| 409 | escaping = false; |
| 410 | continue; |
| 411 | } |
| 412 | if (character === "\\" && quote !== "'") { |
| 413 | escaping = true; |
| 414 | continue; |
| 415 | } |
| 416 | if (quote !== null) { |
| 417 | if (character === quote) quote = null; |
| 418 | else delimiter += character; |
| 419 | continue; |
| 420 | } |
| 421 | if (character === '"' || character === "'") { |
| 422 | quote = character; |
| 423 | continue; |
| 424 | } |
| 425 | if (/\s/u.test(character) || ";&|<>()".includes(character)) break; |
| 426 | delimiter += character; |
| 427 | } |
| 428 | |
| 429 | if (!delimiter || quote !== null || escaping) return null; |
| 430 | return { heredoc: { delimiter, stripTabs }, end: index }; |
| 431 | } |
| 432 | |
| 433 | function commandAfterHeredocs( |
| 434 | command: string, |
| 435 | start: number, |
| 436 | heredocs: ReadonlyArray<Heredoc>, |
| 437 | ): string | null { |
| 438 | let cursor = start; |
| 439 | for (const heredoc of heredocs) { |
| 440 | let foundDelimiter = false; |
| 441 | while (cursor <= command.length) { |
| 442 | const newlineIndex = command.indexOf("\n", cursor); |
| 443 | const lineEnd = newlineIndex === -1 ? command.length : newlineIndex; |
| 444 | const line = command.slice(cursor, lineEnd).replace(/\r$/u, ""); |
| 445 | const comparableLine = heredoc.stripTabs ? line.replace(/^\t+/u, "") : line; |
| 446 | cursor = newlineIndex === -1 ? command.length : newlineIndex + 1; |
| 447 | if (comparableLine === heredoc.delimiter) { |
| 448 | foundDelimiter = true; |
| 449 | break; |
| 450 | } |
| 451 | if (newlineIndex === -1) break; |
| 452 | } |
| 453 | if (!foundDelimiter) return null; |
| 454 | } |
| 455 | |
| 456 | return command.slice(cursor).trim() || null; |
| 457 | } |
| 458 | |
| 459 | function splitFirstShellCommand(command: string): ShellCommandSplit { |
| 460 | let quote: '"' | "'" | null = null; |
| 461 | let powerShellHereStringQuote: '"' | "'" | null = null; |
| 462 | let escaping = false; |
| 463 | let inBackticks = false; |
| 464 | let inComment = false; |
| 465 | let substitutionDepth = 0; |
| 466 | let parameterExpansionDepth = 0; |
| 467 | const heredocs: Heredoc[] = []; |
| 468 | const comments: ShellCommentRange[] = []; |
| 469 | let commentStart = 0; |
| 470 | let separatorBeforeHeredocs: ShellSeparator | null = null; |
| 471 | |
| 472 | for (let index = 0; index < command.length; index += 1) { |
| 473 | const character = command[index]!; |
| 474 | if (powerShellHereStringQuote !== null) { |
| 475 | if ( |
| 476 | character === powerShellHereStringQuote && |
| 477 | command[index + 1] === "@" && |
| 478 | (index === 0 || command[index - 1] === "\n") |
| 479 | ) { |
| 480 | powerShellHereStringQuote = null; |
| 481 | index += 1; |
| 482 | } |
| 483 | continue; |
| 484 | } |
| 485 | if (inComment) { |
| 486 | if (character !== "\n") continue; |
| 487 | inComment = false; |
| 488 | comments.push({ start: commentStart, end: index }); |
| 489 | } |
| 490 | if (escaping) { |
| 491 | escaping = false; |
| 492 | continue; |
| 493 | } |
| 494 | if (character === "\\" && quote !== "'") { |
| 495 | escaping = true; |
| 496 | continue; |
| 497 | } |
| 498 | if (inBackticks) { |
| 499 | if (character === "`") inBackticks = false; |
| 500 | continue; |
| 501 | } |
| 502 | if (quote !== null) { |
| 503 | if (character === quote) quote = null; |
| 504 | continue; |
| 505 | } |
| 506 | if ( |
| 507 | character === "@" && |
| 508 | (command[index + 1] === '"' || command[index + 1] === "'") && |
| 509 | (command[index + 2] === "\n" || (command[index + 2] === "\r" && command[index + 3] === "\n")) |
| 510 | ) { |
| 511 | powerShellHereStringQuote = command[index + 1] as '"' | "'"; |
| 512 | index += 1; |
| 513 | continue; |
| 514 | } |
| 515 | if (character === '"' || character === "'") { |
| 516 | quote = character; |
| 517 | continue; |
| 518 | } |
| 519 | if (character === "`") { |
| 520 | inBackticks = true; |
| 521 | continue; |
| 522 | } |
| 523 | if ( |
| 524 | character === "#" && |
| 525 | (index === 0 || /\s/u.test(command[index - 1]!) || ";&|(".includes(command[index - 1]!)) |
| 526 | ) { |
| 527 | inComment = true; |
| 528 | commentStart = index; |
| 529 | continue; |
| 530 | } |
| 531 | if (character === "$" && command[index + 1] === "{") { |
| 532 | parameterExpansionDepth += 1; |
| 533 | index += 1; |
| 534 | continue; |
| 535 | } |
| 536 | if (character === "{" && parameterExpansionDepth > 0) { |
| 537 | parameterExpansionDepth += 1; |
| 538 | continue; |
| 539 | } |
| 540 | if (character === "}" && parameterExpansionDepth > 0) { |
| 541 | parameterExpansionDepth -= 1; |
| 542 | continue; |
| 543 | } |
| 544 | if (character === "(") { |
| 545 | substitutionDepth += 1; |
| 546 | continue; |
| 547 | } |
| 548 | if (character === ")" && substitutionDepth > 0) { |
| 549 | substitutionDepth -= 1; |
| 550 | continue; |
| 551 | } |
| 552 | if (substitutionDepth > 0 || parameterExpansionDepth > 0) continue; |
| 553 | |
| 554 | if (character === "<" && command[index + 1] === "<" && command[index + 2] !== "<") { |
| 555 | const stripTabs = command[index + 2] === "-"; |
| 556 | const delimiter = readHeredocDelimiter(command, index + (stripTabs ? 3 : 2), stripTabs); |
| 557 | if (delimiter === null) { |
| 558 | return { firstCommand: command.trim(), remainingCommand: null, separator: null }; |
| 559 | } |
| 560 | heredocs.push(delimiter.heredoc); |
| 561 | index = delimiter.end - 1; |
| 562 | continue; |
| 563 | } |
| 564 | |
| 565 | const isDoubleOperator = |
| 566 | (character === "&" && command[index + 1] === "&") || |
| 567 | (character === "|" && (command[index + 1] === "|" || command[index + 1] === "&")); |
| 568 | const isRedirectionAmpersand = |
| 569 | character === "&" && |
| 570 | (command[index - 1] === ">" || command[index - 1] === "<" || command[index + 1] === ">"); |
| 571 | if ((!isDoubleOperator && !";&|\n".includes(character)) || isRedirectionAmpersand) continue; |
| 572 | |
| 573 | if (character === "\n" && heredocs.length > 0) { |
| 574 | const separator = separatorBeforeHeredocs; |
| 575 | const firstCommand = commandWithoutShellComments( |
| 576 | command, |
| 577 | separator?.index ?? index, |
| 578 | comments, |
| 579 | ).trimStart(); |
| 580 | const commandBeforeHeredocs = separator |
| 581 | ? command.slice(separator.index + separator.length, index).trim() |
| 582 | : ""; |
| 583 | const commandFollowingHeredocs = commandAfterHeredocs(command, index + 1, heredocs); |
| 584 | const remainingCommand = [commandBeforeHeredocs, commandFollowingHeredocs] |
| 585 | .filter((part): part is string => Boolean(part)) |
| 586 | .join("\n"); |
| 587 | return { |
| 588 | firstCommand, |
| 589 | remainingCommand: remainingCommand || null, |
| 590 | separator: separator |
| 591 | ? command.slice(separator.index, separator.index + separator.length) |
| 592 | : "\n", |
| 593 | }; |
| 594 | } |
| 595 | if (heredocs.length > 0) { |
| 596 | separatorBeforeHeredocs ??= { |
| 597 | index, |
| 598 | length: isDoubleOperator ? 2 : 1, |
| 599 | }; |
| 600 | if (isDoubleOperator) index += 1; |
| 601 | continue; |
| 602 | } |
| 603 | |
| 604 | const firstCommand = commandWithoutShellComments(command, index, comments).trimStart(); |
| 605 | let nextCommandIndex = index + (isDoubleOperator ? 2 : 1); |
| 606 | while (/\s/u.test(command[nextCommandIndex] ?? "")) nextCommandIndex += 1; |
| 607 | const nextCommand = command.slice(nextCommandIndex).trim(); |
| 608 | return { |
| 609 | firstCommand, |
| 610 | remainingCommand: nextCommand || null, |
| 611 | separator: isDoubleOperator ? command.slice(index, index + 2) : character, |
| 612 | }; |
| 613 | } |
| 614 | |
| 615 | if (inComment) comments.push({ start: commentStart, end: command.length }); |
| 616 | return { |
| 617 | firstCommand: commandWithoutShellComments(command, command.length, comments).trim(), |
| 618 | remainingCommand: null, |
| 619 | separator: null, |
| 620 | }; |
| 621 | } |
| 622 | |
| 623 | function commandWithoutLeadingShellComments(command: string): string | null { |
| 624 | let remainingCommand = command.trimStart(); |
| 625 | while (remainingCommand.startsWith("#")) { |
| 626 | const newlineIndex = remainingCommand.indexOf("\n"); |
| 627 | if (newlineIndex === -1) return null; |
| 628 | remainingCommand = remainingCommand.slice(newlineIndex + 1).trimStart(); |
| 629 | } |
| 630 | return remainingCommand || null; |
| 631 | } |
| 632 | |
| 633 | function withoutShellLineContinuations(command: string): string { |
| 634 | let normalizedCommand = ""; |
| 635 | let quote: '"' | "'" | null = null; |
| 636 | let escaping = false; |
| 637 | |
| 638 | for (let index = 0; index < command.length; index += 1) { |
| 639 | const character = command[index]!; |
| 640 | if (escaping) { |
| 641 | normalizedCommand += character; |
| 642 | escaping = false; |
| 643 | continue; |
| 644 | } |
| 645 | if (character === "\\" && quote !== "'") { |
| 646 | if (command[index + 1] === "\n") { |
| 647 | index += 1; |
| 648 | continue; |
| 649 | } |
| 650 | if (command[index + 1] === "\r" && command[index + 2] === "\n") { |
| 651 | index += 2; |
| 652 | continue; |
| 653 | } |
| 654 | normalizedCommand += character; |
| 655 | escaping = true; |
| 656 | continue; |
| 657 | } |
| 658 | if (quote !== null) { |
| 659 | if (character === quote) quote = null; |
| 660 | } else if (character === '"' || character === "'") { |
| 661 | quote = character; |
| 662 | } |
| 663 | normalizedCommand += character; |
| 664 | } |
| 665 | |
| 666 | return normalizedCommand; |
| 667 | } |
| 668 | |
| 669 | function indexAfterShellRedirection(tokens: ReadonlyArray<string>, index: number): number | null { |
| 670 | const token = tokens[index]; |
| 671 | if (!token || /^[<>]\(/u.test(token)) return null; |
| 672 | const match = token.match( |
| 673 | /^(?:(?:(?:\d+|\*|\{[A-Za-z_][A-Za-z0-9_]*\})?(?:<<<|<<-|<<|<>|>>|>\||<&|>&|<|>))|&>>|&>)(.*)$/u, |
| 674 | ); |
| 675 | if (!match) return null; |
| 676 | if (match[1]) return index + 1; |
| 677 | return tokens[index + 1] === undefined ? tokens.length + 1 : index + 2; |
| 678 | } |
| 679 | |
| 680 | function serializeShellTokens(tokens: ReadonlyArray<string>): string { |
| 681 | return tokens.map((token) => `'${token.replaceAll("'", "'\\''")}'`).join(" "); |
| 682 | } |
| 683 | |
| 684 | function transparentWrapperCommandIndex( |
| 685 | wrapper: string, |
| 686 | tokens: ReadonlyArray<string>, |
| 687 | index: number, |
| 688 | ): number | null { |
| 689 | if (wrapper === "bundle") { |
| 690 | return tokens[index + 1] === "exec" && tokens[index + 2] !== undefined ? index + 2 : null; |
| 691 | } |
| 692 | |
| 693 | if (wrapper === "nohup") { |
| 694 | let targetIndex = index + 1; |
| 695 | if (tokens[targetIndex] === "--") targetIndex += 1; |
| 696 | const target = tokens[targetIndex]; |
| 697 | return target && !target.startsWith("-") ? targetIndex : null; |
| 698 | } |
| 699 | |
| 700 | if (wrapper === "script") { |
| 701 | // BSD `script` takes an output file before the optional command. Requiring |
| 702 | // an option and both operands avoids guessing about a plain `script file`. |
| 703 | return /^-[adkpqr]+$/u.test(tokens[index + 1] ?? "") && tokens[index + 3] !== undefined |
| 704 | ? index + 3 |
| 705 | : null; |
| 706 | } |
| 707 | |
| 708 | if (wrapper === "arch") { |
| 709 | if (/^-(?:arm64|arm64e|i386|x86_64)$/u.test(tokens[index + 1] ?? "")) { |
| 710 | return tokens[index + 2] !== undefined ? index + 2 : null; |
| 711 | } |
| 712 | return tokens[index + 1] === "-arch" && tokens[index + 3] !== undefined ? index + 3 : null; |
| 713 | } |
| 714 | |
| 715 | if (wrapper === "timeout" || wrapper === "gtimeout") { |
| 716 | return /^(?:\d+(?:\.\d*)?|\.\d+)[smhd]?$/u.test(tokens[index + 1] ?? "") && |
| 717 | tokens[index + 2] !== undefined |
| 718 | ? index + 2 |
| 719 | : null; |
| 720 | } |
| 721 | |
| 722 | return null; |
| 723 | } |
| 724 | |
| 725 | function staticProgramName(value: string): string | null { |
| 726 | const trimmedValue = value.trim(); |
| 727 | if (!trimmedValue || /^[A-Za-z][A-Za-z0-9+.-]*:(?![\\/])/u.test(trimmedValue)) return null; |
| 728 | const program = trimmedValue.split(/[\\/]/u).at(-1); |
| 729 | if ( |
| 730 | !program || |
| 731 | (/\s/u.test(program) && !/[\\/]/u.test(trimmedValue)) || |
| 732 | NON_PROGRAM_PREFIX_CHARACTERS.includes(program[0] ?? "") || |
| 733 | NON_PROGRAM_SUFFIX_CHARACTERS.includes(program.at(-1) ?? "") |
| 734 | ) { |
| 735 | return null; |
| 736 | } |
| 737 | return program; |
| 738 | } |
| 739 | |
| 740 | function leadingPowerShellLiteral(command: string): string | null { |
| 741 | const input = command.trimStart(); |
| 742 | const quote = input[0]; |
| 743 | if (quote !== '"' && quote !== "'") return staticProgramName(input.match(/^\S+/u)?.[0] ?? ""); |
| 744 | |
| 745 | let value = ""; |
| 746 | for (let index = 1; index < input.length; index += 1) { |
| 747 | const character = input[index]!; |
| 748 | if (character === "`" && input[index + 1] !== undefined) { |
| 749 | value += input[index + 1]; |
| 750 | index += 1; |
| 751 | continue; |
| 752 | } |
| 753 | if (character === quote) { |
| 754 | if (quote === "'" && input[index + 1] === "'") { |
| 755 | value += "'"; |
| 756 | index += 1; |
| 757 | continue; |
| 758 | } |
| 759 | return staticProgramName(value); |
| 760 | } |
| 761 | value += character; |
| 762 | } |
| 763 | return null; |
| 764 | } |
| 765 | |
| 766 | function powerShellCallOperatorProgramName(command: string): string | null | undefined { |
| 767 | const match = command.match(/^\s*&\s+([\s\S]*)$/u); |
| 768 | return match ? leadingPowerShellLiteral(match[1]!) : undefined; |
| 769 | } |
| 770 | |
| 771 | type PowerShellAssignment = { |
| 772 | readonly matched: boolean; |
| 773 | readonly program: string | null; |
| 774 | }; |
| 775 | |
| 776 | function powerShellAssignmentProgramName( |
| 777 | command: string, |
| 778 | depth: number, |
| 779 | remainingCommand: string | null, |
| 780 | segmentsRemaining: number, |
| 781 | ): PowerShellAssignment { |
| 782 | const assignment = command.match( |
| 783 | /^\s*\$(?:(env|global|local|script):)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*([\s\S]*)$/iu, |
| 784 | ); |
| 785 | if (!assignment) return { matched: false, program: null }; |
| 786 | |
| 787 | // Environment assignments are setup. Their right-hand side is a value, not |
| 788 | // a command, so prefer the next top-level segment when one exists. |
| 789 | if (assignment[1]?.toLowerCase() === "env") { |
| 790 | return { |
| 791 | matched: true, |
| 792 | program: remainingCommand |
| 793 | ? commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1) |
| 794 | : null, |
| 795 | }; |
| 796 | } |
| 797 | |
| 798 | const value = assignment[2]!.trim(); |
| 799 | // The POSIX-oriented segment splitter does not balance PowerShell arrays or |
| 800 | // hashtables. Do not mistake a key after an internal semicolon for a command. |
| 801 | if (/^(?:\[ordered\]\s*)?@\s*[{(]/iu.test(value)) { |
| 802 | return { matched: true, program: null }; |
| 803 | } |
| 804 | const calledProgram = powerShellCallOperatorProgramName(value); |
| 805 | if (calledProgram !== undefined) return { matched: true, program: calledProgram }; |
| 806 | |
| 807 | const directCommand = value.match(/^(?:@?\(\s*)?([A-Za-z][A-Za-z0-9_.-]*)\b/u)?.[1]; |
| 808 | if (directCommand && !NON_DESCRIPTIVE_SHELL_PROGRAMS.has(directCommand.toLowerCase())) { |
| 809 | const parsedCommand = commandProgramNameInternal(value, depth + 1, "shell", segmentsRemaining); |
| 810 | return { matched: true, program: parsedCommand ?? directCommand }; |
| 811 | } |
| 812 | |
| 813 | return { |
| 814 | matched: true, |
| 815 | program: remainingCommand |
| 816 | ? commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1) |
| 817 | : null, |
| 818 | }; |
| 819 | } |
| 820 | |
| 821 | type WindowsShellPayload = { |
| 822 | readonly matched: boolean; |
| 823 | readonly program: string | null; |
| 824 | }; |
| 825 | |
| 826 | function windowsShellPayloadProgramName( |
| 827 | shell: string, |
| 828 | tokens: ReadonlyArray<string>, |
| 829 | start: number, |
| 830 | depth: number, |
| 831 | remainingCommand: string | null, |
| 832 | separator: string | null, |
| 833 | segmentsRemaining: number, |
| 834 | ): WindowsShellPayload { |
| 835 | const parsePayload = (payload: string | undefined): string | null => { |
| 836 | if (!payload) return null; |
| 837 | const command = |
| 838 | remainingCommand && separator ? `${payload} ${separator} ${remainingCommand}` : payload; |
| 839 | return commandProgramNameInternal(command, depth + 1, "shell", segmentsRemaining); |
| 840 | }; |
| 841 | |
| 842 | if (shell === "cmd" || shell === "cmd.exe") { |
| 843 | for (let index = start; index < tokens.length; index += 1) { |
| 844 | const option = tokens[index]!.toLowerCase(); |
| 845 | if (option !== "/c" && option !== "/k") continue; |
| 846 | const payload = tokens[index + 1]; |
| 847 | return { |
| 848 | matched: true, |
| 849 | program: parsePayload(payload), |
| 850 | }; |
| 851 | } |
| 852 | return { matched: false, program: null }; |
| 853 | } |
| 854 | |
| 855 | for (let index = start; index < tokens.length; index += 1) { |
| 856 | const option = tokens[index]!.toLowerCase(); |
| 857 | if (option === "-command" || option === "-c") { |
| 858 | const payload = tokens[index + 1]; |
| 859 | return { |
| 860 | matched: true, |
| 861 | program: parsePayload(payload), |
| 862 | }; |
| 863 | } |
| 864 | if (option === "-file" || option === "-f") { |
| 865 | return { matched: true, program: staticProgramName(tokens[index + 1] ?? "") }; |
| 866 | } |
| 867 | if (option === "-encodedcommand" || option === "-enc" || option === "-e") { |
| 868 | return { matched: true, program: null }; |
| 869 | } |
| 870 | if (POWERSHELL_OPTIONS_WITH_VALUE.has(option)) { |
| 871 | index += 1; |
| 872 | continue; |
| 873 | } |
| 874 | if (POWERSHELL_FLAGS.has(option)) continue; |
| 875 | if (!option.startsWith("-")) { |
| 876 | return { matched: true, program: staticProgramName(tokens[index]!) }; |
| 877 | } |
| 878 | } |
| 879 | return { matched: false, program: null }; |
| 880 | } |
| 881 | |
| 882 | function startProcessProgramName(tokens: ReadonlyArray<string>, start: number): string | null { |
| 883 | for (let index = start; index < tokens.length; index += 1) { |
| 884 | const token = tokens[index]!; |
| 885 | const option = token.toLowerCase(); |
| 886 | if (option === "-filepath") return staticProgramName(tokens[index + 1] ?? ""); |
| 887 | if (START_PROCESS_FLAGS.has(option)) continue; |
| 888 | if (START_PROCESS_OPTIONS_WITH_VALUE.has(option)) { |
| 889 | if (tokens[index + 1] === undefined) return null; |
| 890 | index += 1; |
| 891 | continue; |
| 892 | } |
| 893 | if (token.startsWith("-")) return null; |
| 894 | return staticProgramName(token); |
| 895 | } |
| 896 | return null; |
| 897 | } |
| 898 | |
| 899 | function literalAssignmentProgram( |
| 900 | token: string, |
| 901 | ): { readonly name: string; readonly program: string | null } | null { |
| 902 | const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/su); |
| 903 | if (!assignment) return null; |
| 904 | const name = assignment[1]!; |
| 905 | let value = assignment[2]!.trim(); |
| 906 | if (!value || /[$`]/u.test(value)) return { name, program: null }; |
| 907 | |
| 908 | if (value.startsWith("(") && value.endsWith(")")) { |
| 909 | const arrayTokens = tokenizeShellCommand(value.slice(1, -1)); |
| 910 | value = arrayTokens?.[0] ?? ""; |
| 911 | } else if (/\s/u.test(value) && !/[\\/]/u.test(value)) { |
| 912 | return { name, program: null }; |
| 913 | } |
| 914 | |
| 915 | return { name, program: staticProgramName(value) }; |
| 916 | } |
| 917 | |
| 918 | function referencedCommandAlias(token: string): string | null { |
| 919 | const reference = token.match( |
| 920 | /^\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)(?:\[@\])?\})$/u, |
| 921 | ); |
| 922 | return reference?.[1] ?? reference?.[2] ?? null; |
| 923 | } |
| 924 | |
| 925 | // Recover only literal aliases declared by an earlier top-level shell segment. |
| 926 | // This covers common `SSH=(ssh ...)` and `TOOL=/path/to/tool` forms without |
| 927 | // evaluating expansions or trying to model general shell state. |
| 928 | function literalCommandAliasProgramName(command: string): string | null { |
| 929 | const aliases = new Map<string, string>(); |
| 930 | let remainingCommand: string | null = command; |
| 931 | let controlFlowDepth = 0; |
| 932 | |
| 933 | for (let segmentCount = 0; remainingCommand && segmentCount < 64; segmentCount += 1) { |
| 934 | const commandWithoutComments = commandWithoutLeadingShellComments(remainingCommand); |
| 935 | if (commandWithoutComments === null) return null; |
| 936 | const commandSplit = splitFirstShellCommand(commandWithoutComments); |
| 937 | const tokens = tokenizeShellCommand(withoutShellLineContinuations(commandSplit.firstCommand)); |
| 938 | if (tokens === null) return null; |
| 939 | |
| 940 | let commandIndex = tokens[0] === "do" || tokens[0] === "then" ? 1 : 0; |
| 941 | while (commandIndex < tokens.length) { |
| 942 | const indexAfterRedirection = indexAfterShellRedirection(tokens, commandIndex); |
| 943 | if (indexAfterRedirection !== null && indexAfterRedirection <= tokens.length) { |
| 944 | commandIndex = indexAfterRedirection; |
| 945 | continue; |
| 946 | } |
| 947 | if (/^[A-Za-z_][A-Za-z0-9_]*\+?=/u.test(tokens[commandIndex] ?? "")) { |
| 948 | commandIndex += 1; |
| 949 | continue; |
| 950 | } |
| 951 | break; |
| 952 | } |
| 953 | |
| 954 | const aliasName = referencedCommandAlias(tokens[commandIndex] ?? ""); |
| 955 | const aliasedProgram = aliasName ? aliases.get(aliasName) : undefined; |
| 956 | if (aliasedProgram) return aliasedProgram; |
| 957 | |
| 958 | const leadingToken = tokens[0]; |
| 959 | if (leadingToken === "fi" || leadingToken === "done" || leadingToken === "esac") { |
| 960 | controlFlowDepth = Math.max(0, controlFlowDepth - 1); |
| 961 | } |
| 962 | if ( |
| 963 | leadingToken === "if" || |
| 964 | leadingToken === "for" || |
| 965 | leadingToken === "while" || |
| 966 | leadingToken === "until" || |
| 967 | leadingToken === "select" || |
| 968 | leadingToken === "case" |
| 969 | ) { |
| 970 | controlFlowDepth += 1; |
| 971 | } |
| 972 | |
| 973 | if (controlFlowDepth === 0 && leadingToken === "unset") { |
| 974 | for (const name of tokens.slice(1)) aliases.delete(name); |
| 975 | } |
| 976 | |
| 977 | const assignmentStart = tokens[0] === "export" ? 1 : 0; |
| 978 | const assignments = tokens.slice(assignmentStart).map(literalAssignmentProgram); |
| 979 | if ( |
| 980 | controlFlowDepth === 0 && |
| 981 | assignments.length > 0 && |
| 982 | assignments.every((assignment) => assignment !== null) |
| 983 | ) { |
| 984 | for (const assignment of assignments) { |
| 985 | if (assignment.program) aliases.set(assignment.name, assignment.program); |
| 986 | else aliases.delete(assignment.name); |
| 987 | } |
| 988 | } |
| 989 | |
| 990 | remainingCommand = commandSplit.remainingCommand; |
| 991 | } |
| 992 | |
| 993 | return null; |
| 994 | } |
| 995 | |
| 996 | function wrappedShellCommandProgramName( |
| 997 | wrapper: string, |
| 998 | tokens: ReadonlyArray<string>, |
| 999 | start: number, |
| 1000 | depth: number, |
| 1001 | remainingCommand: string | null, |
| 1002 | segmentsRemaining: number, |
| 1003 | ): string | null { |
| 1004 | let index = start; |
| 1005 | |
| 1006 | if (wrapper === "command") { |
| 1007 | while (index < tokens.length) { |
| 1008 | const option = tokens[index]!; |
| 1009 | if (option === "--") { |
| 1010 | index += 1; |
| 1011 | break; |
| 1012 | } |
| 1013 | if (!option.startsWith("-") || option === "-") break; |
| 1014 | if (option !== "-p") return null; |
| 1015 | index += 1; |
| 1016 | } |
| 1017 | } else if (wrapper === "builtin") { |
| 1018 | if (tokens[index] === "--") index += 1; |
| 1019 | else if (tokens[index]?.startsWith("-")) return null; |
| 1020 | } else if (wrapper === "exec") { |
| 1021 | while (index < tokens.length) { |
| 1022 | const option = tokens[index]!; |
| 1023 | if (option === "--") { |
| 1024 | index += 1; |
| 1025 | break; |
| 1026 | } |
| 1027 | if (option === "-a") { |
| 1028 | if (tokens[index + 1] === undefined) return null; |
| 1029 | index += 2; |
| 1030 | continue; |
| 1031 | } |
| 1032 | if (/^-a.+/u.test(option) || /^-[cl]+$/u.test(option)) { |
| 1033 | index += 1; |
| 1034 | continue; |
| 1035 | } |
| 1036 | if (option.startsWith("-") && option !== "-") return null; |
| 1037 | break; |
| 1038 | } |
| 1039 | } |
| 1040 | |
| 1041 | const wrappedTokens = tokens.slice(index); |
| 1042 | if (wrappedTokens.length === 0) return null; |
| 1043 | let targetIndex = 0; |
| 1044 | while (targetIndex < wrappedTokens.length) { |
| 1045 | const indexAfterRedirection = indexAfterShellRedirection(wrappedTokens, targetIndex); |
| 1046 | if (indexAfterRedirection === null || indexAfterRedirection > wrappedTokens.length) break; |
| 1047 | targetIndex = indexAfterRedirection; |
| 1048 | } |
| 1049 | const target = wrappedTokens[targetIndex]; |
| 1050 | if (target && /^[A-Za-z_][A-Za-z0-9_]*\+?=/u.test(target)) return null; |
| 1051 | |
| 1052 | const wrappedProgram = commandProgramNameInternal( |
| 1053 | serializeShellTokens(wrappedTokens), |
| 1054 | depth + 1, |
| 1055 | wrapper === "exec" ? "exec" : "shell", |
| 1056 | segmentsRemaining, |
| 1057 | ); |
| 1058 | if (wrappedProgram !== null) return wrappedProgram; |
| 1059 | |
| 1060 | if ( |
| 1061 | wrapper !== "exec" && |
| 1062 | target && |
| 1063 | target === target.toLowerCase() && |
| 1064 | NON_DESCRIPTIVE_SHELL_PROGRAMS.has(target) && |
| 1065 | !TERMINAL_SHELL_PROGRAMS.has(target) && |
| 1066 | remainingCommand |
| 1067 | ) { |
| 1068 | return commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1); |
| 1069 | } |
| 1070 | return null; |
| 1071 | } |
| 1072 | |
| 1073 | function parseCommandProgramName( |
| 1074 | command: string, |
| 1075 | depth: number, |
| 1076 | context: CommandProgramContext, |
| 1077 | segmentsRemaining: number, |
| 1078 | ): string | null { |
| 1079 | if (depth >= 8 || segmentsRemaining <= 0) return null; |
| 1080 | const commandWithoutComments = commandWithoutLeadingShellComments(command); |
| 1081 | if (commandWithoutComments === null) return null; |
| 1082 | if ( |
| 1083 | /^(?:catch|finally|for|foreach|function|if|param|switch|try|while)\s*[{(]/iu.test( |
| 1084 | commandWithoutComments, |
| 1085 | ) |
| 1086 | ) { |
| 1087 | return null; |
| 1088 | } |
| 1089 | if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)\s*\{/u.test(commandWithoutComments)) return null; |
| 1090 | // `&&` and `||` inside a `[[ ... ]]` expression are not top-level command |
| 1091 | // separators. Keep the label conservative instead of scanning the test body. |
| 1092 | if (commandWithoutComments.startsWith("[[")) return null; |
| 1093 | const commandSplit = splitFirstShellCommand(commandWithoutComments); |
| 1094 | if (/^@["'](?:\r?\n)/u.test(commandSplit.firstCommand.trimStart())) { |
| 1095 | return commandSplit.remainingCommand |
| 1096 | ? commandProgramNameInternal( |
| 1097 | commandSplit.remainingCommand, |
| 1098 | depth, |
| 1099 | "shell", |
| 1100 | segmentsRemaining - 1, |
| 1101 | ) |
| 1102 | : null; |
| 1103 | } |
| 1104 | const powerShellAssignment = powerShellAssignmentProgramName( |
| 1105 | commandSplit.firstCommand, |
| 1106 | depth, |
| 1107 | commandSplit.remainingCommand, |
| 1108 | segmentsRemaining, |
| 1109 | ); |
| 1110 | if (powerShellAssignment.matched) return powerShellAssignment.program; |
| 1111 | const windowsPath = commandSplit.firstCommand.match( |
| 1112 | /^\s*((?:\.{1,2}|%[A-Za-z_][A-Za-z0-9_]*%|\$env:[A-Za-z_][A-Za-z0-9_]*)\\\S+)/iu, |
| 1113 | )?.[1]; |
| 1114 | if (windowsPath) return staticProgramName(windowsPath); |
| 1115 | const tokens = tokenizeShellCommand(withoutShellLineContinuations(commandSplit.firstCommand)); |
| 1116 | if (tokens === null) return null; |
| 1117 | const firstCharacter = commandSplit.firstCommand.trimStart()[0]; |
| 1118 | if ( |
| 1119 | tokens.length === 1 && |
| 1120 | (firstCharacter === '"' || firstCharacter === "'") && |
| 1121 | /[\s()=]/u.test(tokens[0] ?? "") && |
| 1122 | !/[\\/]/u.test(tokens[0] ?? "") |
| 1123 | ) { |
| 1124 | return commandSplit.remainingCommand |
| 1125 | ? commandProgramNameInternal( |
| 1126 | commandSplit.remainingCommand, |
| 1127 | depth, |
| 1128 | context, |
| 1129 | segmentsRemaining - 1, |
| 1130 | ) |
| 1131 | : null; |
| 1132 | } |
| 1133 | let index = 0; |
| 1134 | let wrapper: CommandWrapper | null = null; |
| 1135 | let executionContext = context; |
| 1136 | let sawAssignment = false; |
| 1137 | let sawRedirection = false; |
| 1138 | |
| 1139 | while (index < tokens.length) { |
| 1140 | const token = tokens[index]; |
| 1141 | if (!token) return null; |
| 1142 | const indexAfterRedirection = indexAfterShellRedirection(tokens, index); |
| 1143 | if (indexAfterRedirection !== null) { |
| 1144 | if (indexAfterRedirection > tokens.length) return null; |
| 1145 | sawRedirection = true; |
| 1146 | index = indexAfterRedirection; |
| 1147 | continue; |
| 1148 | } |
| 1149 | if (/^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(token)) { |
| 1150 | sawAssignment = true; |
| 1151 | index += 1; |
| 1152 | continue; |
| 1153 | } |
| 1154 | if (executionContext === "shell" && token === ":") { |
| 1155 | return commandSplit.remainingCommand |
| 1156 | ? commandProgramNameInternal( |
| 1157 | commandSplit.remainingCommand, |
| 1158 | depth, |
| 1159 | executionContext, |
| 1160 | segmentsRemaining - 1, |
| 1161 | ) |
| 1162 | : null; |
| 1163 | } |
| 1164 | if ( |
| 1165 | NON_PROGRAM_PREFIX_CHARACTERS.includes(token[0] ?? "") && |
| 1166 | !(token.startsWith("$") && token.includes("/")) |
| 1167 | ) { |
| 1168 | return executionContext === "shell" && token.startsWith("[") && commandSplit.remainingCommand |
| 1169 | ? commandProgramNameInternal( |
| 1170 | commandSplit.remainingCommand, |
| 1171 | depth, |
| 1172 | executionContext, |
| 1173 | segmentsRemaining - 1, |
| 1174 | ) |
| 1175 | : null; |
| 1176 | } |
| 1177 | const tokenProgram = token.split(/[\\/]/).at(-1); |
| 1178 | const isUnqualifiedToken = token === tokenProgram; |
| 1179 | if (tokenProgram === "env" || tokenProgram === "sudo") { |
| 1180 | wrapper = tokenProgram; |
| 1181 | executionContext = "exec"; |
| 1182 | index += 1; |
| 1183 | continue; |
| 1184 | } |
| 1185 | if (wrapper !== null && token === "--") { |
| 1186 | wrapper = null; |
| 1187 | index += 1; |
| 1188 | continue; |
| 1189 | } |
| 1190 | if (wrapper !== null && token.startsWith("-")) { |
| 1191 | if (wrapper === "env" && (token === "-S" || token === "--split-string")) { |
| 1192 | const splitCommand = tokens[index + 1]; |
| 1193 | return splitCommand |
| 1194 | ? commandProgramNameInternal(splitCommand, depth + 1, executionContext, segmentsRemaining) |
| 1195 | : null; |
| 1196 | } |
| 1197 | if (wrapper === "env" && token.startsWith("--split-string=")) { |
| 1198 | return commandProgramNameInternal( |
| 1199 | token.slice("--split-string=".length), |
| 1200 | depth + 1, |
| 1201 | executionContext, |
| 1202 | segmentsRemaining, |
| 1203 | ); |
| 1204 | } |
| 1205 | if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { |
| 1206 | if (tokens[index + 1] === undefined) return null; |
| 1207 | index += 2; |
| 1208 | continue; |
| 1209 | } |
| 1210 | if (COMMAND_WRAPPER_FLAGS[wrapper].has(token)) { |
| 1211 | index += 1; |
| 1212 | continue; |
| 1213 | } |
| 1214 | const equalsIndex = token.indexOf("="); |
| 1215 | if (token.startsWith("--") && equalsIndex > 2) { |
| 1216 | if (!COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token.slice(0, equalsIndex))) { |
| 1217 | return null; |
| 1218 | } |
| 1219 | index += 1; |
| 1220 | continue; |
| 1221 | } |
| 1222 | if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) { |
| 1223 | let consumesNextToken = false; |
| 1224 | for (const [optionIndex, option] of token.slice(1).split("").entries()) { |
| 1225 | const shortOption = `-${option}`; |
| 1226 | if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) { |
| 1227 | consumesNextToken = optionIndex === token.length - 2; |
| 1228 | break; |
| 1229 | } |
| 1230 | if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null; |
| 1231 | } |
| 1232 | if (consumesNextToken && tokens[index + 1] === undefined) return null; |
| 1233 | index += consumesNextToken ? 2 : 1; |
| 1234 | continue; |
| 1235 | } |
| 1236 | return null; |
| 1237 | } |
| 1238 | if (tokenProgram && SHELL_PROGRAMS.has(tokenProgram.replace(/\.exe$/i, "").toLowerCase())) { |
| 1239 | const scriptIndex = shellCommandArgumentIndex(tokens, index + 1); |
| 1240 | if (scriptIndex !== null) { |
| 1241 | const script = tokens[scriptIndex]; |
| 1242 | return script |
| 1243 | ? commandProgramNameInternal(script, depth + 1, "shell", segmentsRemaining) |
| 1244 | : null; |
| 1245 | } |
| 1246 | } |
| 1247 | const lowerTokenProgram = tokenProgram?.toLowerCase(); |
| 1248 | if (lowerTokenProgram && WINDOWS_SHELL_PROGRAMS.has(lowerTokenProgram)) { |
| 1249 | const payload = windowsShellPayloadProgramName( |
| 1250 | lowerTokenProgram, |
| 1251 | tokens, |
| 1252 | index + 1, |
| 1253 | depth, |
| 1254 | commandSplit.remainingCommand, |
| 1255 | commandSplit.separator, |
| 1256 | segmentsRemaining, |
| 1257 | ); |
| 1258 | if (payload.matched) return payload.program; |
| 1259 | } |
| 1260 | if (lowerTokenProgram === "start-process") { |
| 1261 | const startedProgram = startProcessProgramName(tokens, index + 1); |
| 1262 | if (startedProgram !== null) return startedProgram; |
| 1263 | } |
| 1264 | if ( |
| 1265 | executionContext === "shell" && |
| 1266 | isUnqualifiedToken && |
| 1267 | tokenProgram && |
| 1268 | SHELL_PRECOMMAND_MODIFIERS.has(tokenProgram) |
| 1269 | ) { |
| 1270 | index += 1; |
| 1271 | if (tokenProgram === "time" && tokens[index] === "-p") index += 1; |
| 1272 | if (tokens[index]?.startsWith("-")) return null; |
| 1273 | continue; |
| 1274 | } |
| 1275 | if (isUnqualifiedToken && tokenProgram) { |
| 1276 | const targetIndex = transparentWrapperCommandIndex(tokenProgram, tokens, index); |
| 1277 | if (targetIndex !== null) { |
| 1278 | const wrappedProgram = commandProgramNameInternal( |
| 1279 | serializeShellTokens(tokens.slice(targetIndex)), |
| 1280 | depth + 1, |
| 1281 | "exec", |
| 1282 | segmentsRemaining, |
| 1283 | ); |
| 1284 | if (wrappedProgram !== null) return wrappedProgram; |
| 1285 | } |
| 1286 | } |
| 1287 | if (isUnqualifiedToken && tokenProgram && SHELL_COMMAND_WRAPPERS.has(tokenProgram)) { |
| 1288 | return wrappedShellCommandProgramName( |
| 1289 | tokenProgram, |
| 1290 | tokens, |
| 1291 | index + 1, |
| 1292 | depth, |
| 1293 | commandSplit.remainingCommand, |
| 1294 | segmentsRemaining, |
| 1295 | ); |
| 1296 | } |
| 1297 | if ( |
| 1298 | (executionContext === "shell" || |
| 1299 | (wrapper === "sudo" && tokenProgram && SKIPPABLE_SUDO_PROBES.has(tokenProgram))) && |
| 1300 | isUnqualifiedToken && |
| 1301 | tokenProgram && |
| 1302 | NON_DESCRIPTIVE_SHELL_PROGRAMS.has(tokenProgram) && |
| 1303 | (!TERMINAL_SHELL_PROGRAMS.has(tokenProgram) || |
| 1304 | (tokenProgram === "false" && commandSplit.separator !== "&&")) && |
| 1305 | commandSplit.remainingCommand |
| 1306 | ) { |
| 1307 | return commandProgramNameInternal( |
| 1308 | commandSplit.remainingCommand, |
| 1309 | depth, |
| 1310 | "shell", |
| 1311 | segmentsRemaining - 1, |
| 1312 | ); |
| 1313 | } |
| 1314 | if ( |
| 1315 | executionContext === "shell" && |
| 1316 | isUnqualifiedToken && |
| 1317 | lowerTokenProgram && |
| 1318 | POWERSHELL_SETUP_PROGRAMS.has(lowerTokenProgram) && |
| 1319 | commandSplit.remainingCommand |
| 1320 | ) { |
| 1321 | return commandProgramNameInternal( |
| 1322 | commandSplit.remainingCommand, |
| 1323 | depth, |
| 1324 | "shell", |
| 1325 | segmentsRemaining - 1, |
| 1326 | ); |
| 1327 | } |
| 1328 | if ( |
| 1329 | !tokenProgram || |
| 1330 | (isUnqualifiedToken && NON_DESCRIPTIVE_SHELL_PROGRAMS.has(tokenProgram)) || |
| 1331 | NON_PROGRAM_PREFIX_CHARACTERS.includes(tokenProgram[0] ?? "") || |
| 1332 | NON_PROGRAM_SUFFIX_CHARACTERS.includes(tokenProgram.at(-1) ?? "") || |
| 1333 | tokenProgram.endsWith("()") || |
| 1334 | /^[A-Za-z_][A-Za-z0-9_]*\(\)\{$/u.test(tokenProgram) |
| 1335 | ) { |
| 1336 | return null; |
| 1337 | } |
| 1338 | return tokenProgram || null; |
| 1339 | } |
| 1340 | |
| 1341 | if ((sawAssignment || sawRedirection) && wrapper === null && commandSplit.remainingCommand) { |
| 1342 | return commandProgramNameInternal( |
| 1343 | commandSplit.remainingCommand, |
| 1344 | depth, |
| 1345 | executionContext, |
| 1346 | segmentsRemaining - 1, |
| 1347 | ); |
| 1348 | } |
| 1349 | return null; |
| 1350 | } |
| 1351 | |
| 1352 | function commandProgramNameInternal( |
| 1353 | command: string, |
| 1354 | depth: number, |
| 1355 | context: CommandProgramContext, |
| 1356 | segmentsRemaining = MAX_COMMAND_SEGMENTS, |
| 1357 | ): string | null { |
| 1358 | if (segmentsRemaining <= 0) return null; |
| 1359 | const calledProgram = powerShellCallOperatorProgramName(command); |
| 1360 | if (calledProgram !== undefined) return calledProgram; |
| 1361 | return ( |
| 1362 | parseCommandProgramName(command, depth, context, segmentsRemaining) ?? |
| 1363 | (context === "shell" ? literalCommandAliasProgramName(command) : null) |
| 1364 | ); |
| 1365 | } |
| 1366 | |
| 1367 | export function commandProgramName(command: string, depth = 0): string | null { |
| 1368 | return commandProgramNameInternal(command, depth, "shell", MAX_COMMAND_SEGMENTS); |
| 1369 | } |
| 1370 | |
| 1371 | // Escape bytes and other control characters render as invisible gaps; display |
| 1372 | // shows them as Unicode control pictures (ESC becomes ␛) instead. Tab, newline |
| 1373 | // and carriage return lay out as whitespace, so CRLF scripts stay unmarked. |
| 1374 | // oxlint-disable-next-line no-control-regex |
| 1375 | const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu; |
| 1376 | |
| 1377 | /** |
| 1378 | * The command as rows show it: a plain shell -c wrapper removed and control |
| 1379 | * characters made visible. Callers retain the original for details. |
| 1380 | */ |
| 1381 | export function commandDisplayText(command: string): string { |
| 1382 | return withVisibleControlCharacters(withoutShellWrapper(command)); |
| 1383 | } |
| 1384 | |
| 1385 | /** Replaces each control character with its control picture; the length does not change. */ |
| 1386 | export function withVisibleControlCharacters(text: string): string { |
| 1387 | return text.replace(CONTROL_CHARACTERS, (character) => |
| 1388 | character === "\u007f" ? "␡" : String.fromCharCode(0x2400 + character.charCodeAt(0)), |
| 1389 | ); |
| 1390 | } |
| 1391 | |
| 1392 | // A wrapper whose quoting does not balance is kept: the script's end cannot be |
| 1393 | // known, which happens when Codex's secret redaction removes the backslash of |
| 1394 | // an escaped quote inside `-lc "…"`. |
| 1395 | function withoutShellWrapper(command: string): string { |
| 1396 | const trimmed = command.trim(); |
| 1397 | const split = splitFirstShellCommand(trimmed); |
| 1398 | if (split.remainingCommand !== null) return trimmed; |
| 1399 | const tokens = tokenizeShellCommand(trimmed); |
| 1400 | const program = tokens?.[0] |
| 1401 | ?.split(/[\\/]/u) |
| 1402 | .at(-1) |
| 1403 | ?.replace(/\.exe$/iu, ""); |
| 1404 | if (!tokens || !program || !SHELL_PROGRAMS.has(program)) return trimmed; |
| 1405 | const scriptIndex = shellCommandArgumentIndex(tokens, 1); |
| 1406 | // Positional arguments can affect the script; keep those invocations intact. |
| 1407 | if (scriptIndex === null || scriptIndex !== tokens.length - 1) return trimmed; |
| 1408 | return tokens[scriptIndex]?.trim() || trimmed; |
| 1409 | } |
| 1410 | |
| 1411 | const POWERSHELL_PROGRAMS = new Set(["powershell", "pwsh"]); |
| 1412 | |
| 1413 | // PowerShell's approved verbs (`Get-Verb`), plus the ForEach, Where, Sort and |
| 1414 | // Tee of its built-in *-Object cmdlets. Cmdlets match only capitalized, as |
| 1415 | // agents write them: lowercase `update-grub` or `install-info` are Linux tools. |
| 1416 | const POWERSHELL_VERBS = new Set( |
| 1417 | `Add Approve Assert Backup Block Build Checkpoint Clear Close Compare Complete Compress Confirm Connect Convert ConvertFrom ConvertTo Copy Debug Deny Deploy Disable Disconnect Dismount Edit Enable Enter Exit Expand Export Find ForEach Format Get Grant Group Hide Import Initialize Install Invoke Join Limit Lock Measure Merge Mount Move New Open Optimize Out Ping Pop Protect Publish Push Read Receive Redo Register Remove Rename Repair Request Reset Resize Resolve Restart Restore Resume Revoke Save Search Select Send Set Show Skip Sort Split Start Step Stop Submit Suspend Switch Sync Tee Test Trace Unblock Undo Uninstall Unlock Unprotect Unpublish Unregister Update Use Wait Watch Where Write`.split( |
| 1418 | " ", |
| 1419 | ), |
| 1420 | ); |
| 1421 | |
| 1422 | function isPowerShellCmdlet(word: string): boolean { |
| 1423 | const cmdlet = /^([A-Z][a-z]+(?:[A-Z][a-z]+)?)-[A-Z][A-Za-z]*$/u.exec(word); |
| 1424 | return cmdlet !== null && POWERSHELL_VERBS.has(cmdlet[1]!); |
| 1425 | } |
| 1426 | |
| 1427 | /** |
| 1428 | * The grammar to highlight a command with: PowerShell when pwsh or powershell |
| 1429 | * runs it, or when it is written in PowerShell, as Windows agents run it. |
| 1430 | */ |
| 1431 | export function commandHighlightLanguage(command: string): "powershell" | "shellscript" { |
| 1432 | const program = /^(?:&\s*)?(?:"([^"]*)"|'([^']*)'|(\S+))/u.exec(command.trim()); |
| 1433 | const name = (program?.[1] ?? program?.[2] ?? program?.[3] ?? "") |
| 1434 | .split(/[\\/]/u) |
| 1435 | .at(-1) |
| 1436 | ?.toLowerCase() |
| 1437 | .replace(/\.exe$/u, ""); |
| 1438 | if (name && POWERSHELL_PROGRAMS.has(name)) return "powershell"; |
| 1439 | return isPowerShellScript(command) ? "powershell" : "shellscript"; |
| 1440 | } |
| 1441 | |
| 1442 | /** Whether any statement starts like PowerShell and never like POSIX shell. */ |
| 1443 | function isPowerShellScript(command: string): boolean { |
| 1444 | let rest: string | null = command.trim(); |
| 1445 | // A leading call operator; POSIX shell cannot start a command with `&`. |
| 1446 | if (/^&\s*\S/u.test(rest)) return true; |
| 1447 | for (let segment = 0; rest && segment < MAX_COMMAND_SEGMENTS; segment += 1) { |
| 1448 | const { firstCommand, remainingCommand } = splitFirstShellCommand(rest); |
| 1449 | const statement = firstCommand.trim(); |
| 1450 | if ( |
| 1451 | // `$env:NAME` or `$name = value`; neither parses as POSIX shell. |
| 1452 | /^\$(?:env|global|local|script):/iu.test(statement) || |
| 1453 | /^\$[A-Za-z_]\w*\s+=/u.test(statement) || |
| 1454 | isPowerShellCmdlet(statement.match(/^\S+/u)?.[0] ?? "") |
| 1455 | ) { |
| 1456 | return true; |
| 1457 | } |
| 1458 | rest = remainingCommand; |
| 1459 | } |
| 1460 | return false; |
| 1461 | } |