Files
anomalyco_opencode/packages/shell-scan/src/index.ts
T

676 lines
23 KiB
TypeScript

export * as ShellScan from "./index.js"
export type OpaqueReason =
| "command-substitution"
| "compound-command"
| "dynamic-command-name"
| "dynamic-directory"
| "dynamic-execution"
| "heredoc"
| "invalid-redirect"
| "invalid-structure"
| "unterminated-escape"
| "unterminated-quote"
export const Nested = Symbol("ShellScan.Nested")
type Command = { resource: string; words: string[]; [Nested]?: true }
export type Result = { kind: "scanned"; commands: Command[] } | { kind: "opaque"; reason: OpaqueReason }
const BASH_COMPOUND_KEYWORDS = new Set([
"if",
"then",
"elif",
"else",
"fi",
"for",
"while",
"until",
"case",
"select",
"function",
"do",
"done",
"coproc",
])
const POWERSHELL_LOCATIONS = new Set(["set-location", "cd", "chdir", "sl", "push-location", "pushd"])
const BASH_REDIRECTS = ["&>>", "&>", "<<<", "<<-", "<<", "<>", "<&", ">&", ">|", ">>", ">", "<"]
const MAX_BASH_INPUT_LENGTH = 64 * 1024
const MAX_SUBSTITUTION_DEPTH = 32
export function scan(input: string): Result {
return scanBash(input, 0)
}
function scanBash(input: string, depth: number): Result {
if (input.length > MAX_BASH_INPUT_LENGTH) return { kind: "opaque", reason: "invalid-structure" }
const group = bashLeadingGroup(input)
if (group) {
if (depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "invalid-structure" }
const nested = scanBash(group.source, depth + 1)
if (nested.kind === "opaque") return nested
const suffix = input.slice(group.end + 1).trim()
if (!suffix) return nested
const separator = /^(?:&&|\|\||\|&|[;&|])/.exec(suffix)?.[0]
const remaining = separator ? suffix.slice(separator.length).trim() : suffix
if (separator && !remaining) return nested
const prefixed = separator ? remaining : /^[<>]/.test(remaining) ? `: ${remaining}` : undefined
if (!prefixed) return { kind: "opaque", reason: "compound-command" }
const rest = scanBash(prefixed, depth + 1)
if (rest.kind === "opaque") return rest
return {
kind: "scanned",
commands: nested.commands.concat(rest.commands.filter((command) => command.words[0] !== ":")),
}
}
const commands: Array<{ resource: string; words: string[] }> = []
const nestedCommands: Array<{ resource: string; words: string[] }> = []
const words: string[] = []
const assignmentWords: boolean[] = []
let word = ""
let wordStarted = false
let assignmentWord = false
let assignmentHeadUnsafe = false
let segment = 0
let quote: "single" | "double" | undefined
let compound = false
let invalidRedirect = false
let invalidStructure = false
let separated = false
let comment: number | undefined
let heredoc = false
let redirectTarget = false
let hasRedirect = false
let terminalBackground = false
const finishWord = () => {
if (!wordStarted) return
if (!redirectTarget) {
words.push(word)
assignmentWords.push(assignmentWord)
}
redirectTarget = false
word = ""
wordStarted = false
assignmentWord = false
assignmentHeadUnsafe = false
}
const finishCommand = (end: number, boundary = false) => {
finishWord()
const resource = input.slice(segment, end).trim()
const name = assignmentWords.findIndex((assignment) => !assignment)
if (name >= 0 && /[*?[]/.test(words[name])) compound = true
if (resource && name >= 0)
commands.push({
resource,
words: words.slice(name),
})
else if (!(assignmentWords.length > 0 && assignmentWords.every(Boolean)) && (hasRedirect || boundary || separated))
invalidStructure = true
commands.push(...nestedCommands.splice(0))
words.length = 0
assignmentWords.length = 0
separated = true
hasRedirect = false
}
for (let index = 0; index < input.length; index++) {
const char = input[index]
if (quote === "single") {
wordStarted = true
if (char === "'") quote = undefined
else word += char
continue
}
if (quote === "double") {
wordStarted = true
if (char === '"') quote = undefined
else if (char === "\\" && index + 1 < input.length) {
const next = input[index + 1]
if ('$`"\\\n'.includes(next)) {
index++
if (next !== "\n") word += next
} else word += char
} else if ((char === "$" && input[index + 1] === "(") || char === "`") {
const substitution = bashSubstitution(input, index)
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
const result = scanBash(substitution.source, depth + 1)
if (result.kind === "opaque") return result
nestedCommands.push(...result.commands.map(markNested))
word += input.slice(index, substitution.end + 1)
index = substitution.end
} else {
if (char === "$" && /^\$\{[^}:@]+@P\}/.test(input.slice(index)))
return { kind: "opaque", reason: "dynamic-execution" }
if (char === "$" && /^\$\{\([^)]*e[^)]*\)/.test(input.slice(index)))
return { kind: "opaque", reason: "dynamic-execution" }
word += char
}
continue
}
if (char === "'") {
quote = "single"
wordStarted = true
if (!assignmentWord) assignmentHeadUnsafe = true
continue
}
if (char === '"') {
quote = "double"
wordStarted = true
if (!assignmentWord) assignmentHeadUnsafe = true
continue
}
if (char === "\\") {
if (index + 1 >= input.length) return { kind: "opaque", reason: "unterminated-escape" }
wordStarted = true
if (input[index + 1] === "\n") index++
else {
if (!assignmentWord) assignmentHeadUnsafe = true
word += input[++index]
}
continue
}
if ((char === "$" && input[index + 1] === "(") || char === "`") {
const substitution = bashSubstitution(input, index)
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
const result = scanBash(substitution.source, depth + 1)
if (result.kind === "opaque") return result
nestedCommands.push(...result.commands.map(markNested))
wordStarted = true
word += input.slice(index, substitution.end + 1)
index = substitution.end
continue
}
if ((char === "<" || char === ">") && input[index + 1] === "(") {
const substitution = bashParenthesized(input, index + 1)
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
const result = scanBash(substitution.source, depth + 1)
if (result.kind === "opaque") return result
nestedCommands.push(...result.commands.map(markNested))
wordStarted = true
word += input.slice(index, substitution.end + 1)
index = substitution.end
continue
}
if (char === "$" && input[index + 1] === "{" && /^\$\{[^}:@]+@P\}/.test(input.slice(index)))
return { kind: "opaque", reason: "dynamic-execution" }
if (char === "$" && /^\$\{\([^)]*e[^)]*\)/.test(input.slice(index)))
return { kind: "opaque", reason: "dynamic-execution" }
if (char === "$" && input[index + 1] === "[") return { kind: "opaque", reason: "dynamic-execution" }
if (char === "<" && input[index + 1] === "<") heredoc = true
if (char === "#" && !wordStarted) {
finishCommand(index)
comment = index
const newline = input.indexOf("\n", index)
if (newline === -1) break
index = newline
segment = newline + 1
continue
}
const redirect = "<>&".includes(char)
? BASH_REDIRECTS.find((candidate) => input.startsWith(candidate, index))
: undefined
if (redirect) {
hasRedirect = true
if (redirectTarget) invalidRedirect = true
if (wordStarted && /^\d+$/.test(word)) {
word = ""
wordStarted = false
} else finishWord()
redirectTarget = true
index += redirect.length - 1
continue
}
if ("()".includes(char) || (char === "!" && !wordStarted)) compound = true
if (/\s/.test(char) && char !== "\n") {
finishWord()
continue
}
const next = input[index + 1]
const separator =
(char === "&" && next === "&") || (char === "|" && (next === "|" || next === "&"))
? char + next
: char === ";" || char === "|" || char === "&" || char === "\n"
? char
: undefined
if (separator) {
finishCommand(index, true)
if (redirectTarget) invalidRedirect = true
terminalBackground = separator === "&" || separator === ";" || separator === "\n"
index += separator.length - 1
segment = index + 1
continue
}
terminalBackground = false
wordStarted = true
if (char === "=" && !assignmentHeadUnsafe && /^[A-Za-z_][A-Za-z0-9_]*\+?$/.test(word)) assignmentWord = true
word += char
}
if (quote) return { kind: "opaque", reason: "unterminated-quote" }
if (heredoc) return { kind: "opaque", reason: "heredoc" }
if (!terminalBackground && (comment === undefined || input.includes("\n", comment))) finishCommand(input.length)
if (redirectTarget) invalidRedirect = true
if (separated && !terminalBackground && comment === undefined && !input.slice(segment).trim()) invalidStructure = true
if (invalidStructure) return { kind: "opaque", reason: "invalid-structure" }
if (invalidRedirect) return { kind: "opaque", reason: "invalid-redirect" }
const conditional = bashConditionalCommands(commands)
if (conditional) commands.splice(0, commands.length, ...conditional)
if (compound || commands.some((command) => BASH_COMPOUND_KEYWORDS.has(command.words[0] ?? "")))
return { kind: "opaque", reason: "compound-command" }
if (commands.some((command) => /[$`]/.test(command.words[0] ?? "")))
return { kind: "opaque", reason: "dynamic-command-name" }
if (commands.some((command) => command.words[0]?.startsWith("=")))
return { kind: "opaque", reason: "dynamic-command-name" }
return { kind: "scanned", commands }
}
function bashConditionalCommands(commands: Array<{ resource: string; words: string[] }>) {
if (commands[0]?.words[0] !== "if" || commands.at(-1)?.words[0] !== "fi") return
const keywords = new Set(["if", "then", "elif", "else", "fi"])
if (
commands.some((command) => BASH_COMPOUND_KEYWORDS.has(command.words[0] ?? "") && !keywords.has(command.words[0]!))
)
return
const normalized: Array<{ resource: string; words: string[] }> = []
let phase: "condition" | "body" | "else" = "condition"
let hasCommand = false
let sawElse = false
for (const [index, command] of commands.entries()) {
const keyword = command.words[0]
if (!keywords.has(keyword ?? "")) {
normalized.push(command)
hasCommand = true
continue
}
const offset = command.resource.indexOf(keyword!) + keyword!.length
const inline =
command.words.length > 1
? { resource: command.resource.slice(offset).trim(), words: command.words.slice(1) }
: undefined
if (index === 0) {
if (inline) normalized.push(inline)
hasCommand = Boolean(inline)
continue
}
if (keyword === "then") {
if (phase !== "condition" || !hasCommand) return
phase = "body"
hasCommand = Boolean(inline)
}
if (keyword === "elif") {
if (phase !== "body" || !hasCommand || sawElse) return
phase = "condition"
hasCommand = Boolean(inline)
}
if (keyword === "else") {
if (phase !== "body" || !hasCommand || sawElse) return
phase = "else"
sawElse = true
hasCommand = Boolean(inline)
}
if (keyword === "fi") {
if (index !== commands.length - 1 || phase === "condition" || !hasCommand || inline) return
continue
}
if (inline) normalized.push(inline)
}
return normalized
}
function bashLeadingGroup(input: string) {
const start = input.search(/\S/)
if (start < 0) return
if (input[start] === "{") {
const group = bashBraced(input, start)
if (!group) return
const source = group.source.trim()
if (!source.endsWith(";")) return
return { source: source.slice(0, -1), end: group.end }
}
if (input[start] !== "(") return
return bashParenthesized(input, start)
}
function bashBraced(input: string, start: number) {
let quote: "single" | "double" | undefined
let level = 1
for (let index = start + 1; index < input.length; index++) {
const char = input[index]
if (quote === "single") {
if (char === "'") quote = undefined
continue
}
if (char === "\\") {
index++
continue
}
if (char === "'") {
quote = "single"
continue
}
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (char === "{" && quote !== "double") level++
if (char !== "}" || quote === "double" || --level) continue
return { source: input.slice(start + 1, index), end: index }
}
}
function bashParenthesized(input: string, start: number) {
let quote: "single" | "double" | undefined
let level = 1
for (let index = start + 1; index < input.length; index++) {
const char = input[index]
if (quote === "single") {
if (char === "'") quote = undefined
continue
}
if (char === "\\") {
index++
continue
}
if (char === "'") {
quote = "single"
continue
}
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (char === "(" && quote !== "double") level++
if (char !== ")" || quote === "double" || --level) continue
return { source: input.slice(start + 1, index), end: index }
}
}
function bashSubstitution(input: string, start: number) {
if (input[start] === "`") {
for (let index = start + 1; index < input.length; index++) {
if (input[index] === "\\") index++
else if (input[index] === "`") return { source: input.slice(start + 1, index).replaceAll("\\`", "`"), end: index }
}
return
}
if (input.slice(start, start + 3) === "$((") return
let quote: "single" | "double" | undefined
let level = 1
for (let index = start + 2; index < input.length; index++) {
const char = input[index]
if (quote === "single") {
if (char === "'") quote = undefined
continue
}
if (char === "\\") {
index++
continue
}
if (char === "'") {
quote = "single"
continue
}
if (quote !== "double" && char === "#" && (index === start + 2 || /[\s;&|()]/.test(input[index - 1] ?? ""))) return
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (char === "`" && quote !== "double") {
const nested = bashSubstitution(input, index)
if (!nested) return
index = nested.end
continue
}
if (quote === "double") {
if (char === "$" && input[index + 1] === "(") {
level++
index++
} else if (char === ")" && level > 1) level--
continue
}
if (char === "(") level++
if (char !== ")" || --level) continue
return { source: input.slice(start + 2, index), end: index }
}
}
export function scanPowerShell(input: string): Result {
return scanPowerShellNested(input, 0)
}
function scanPowerShellNested(input: string, depth: number): Result {
if (input.length > MAX_BASH_INPUT_LENGTH || depth >= MAX_SUBSTITUTION_DEPTH)
return { kind: "opaque", reason: "invalid-structure" }
const commands: Array<{ resource: string; words: string[] }> = []
const nestedCommands: Array<{ resource: string; words: string[] }> = []
const words: string[] = []
let segment = 0
let word = ""
let started = false
let quote: "single" | "double" | undefined
let dynamic = false
let invalid = false
let redirectTarget = false
let comment = false
let separated = false
let dangling = false
const finishWord = () => {
if (!started) return
if (!redirectTarget) words.push(word)
redirectTarget = false
word = ""
started = false
}
const finishCommand = (end: number, boundary = false) => {
finishWord()
const resource = input.slice(segment, end).trim()
if (resource) commands.push({ resource, words: [...words] })
else if (boundary && separated) invalid = true
commands.push(...nestedCommands.splice(0))
words.length = 0
separated ||= Boolean(resource)
}
for (let index = 0; index < input.length; index++) {
const char = input[index]
if (quote) {
started = true
if (quote === "single" && char === "'" && input[index + 1] === "'") {
word += "'"
index++
} else if ((quote === "single" && char === "'") || (quote === "double" && char === '"')) quote = undefined
else if (char === "`" && index + 1 < input.length) word += input[++index]
else {
if (quote === "double" && char === "$" && input[index + 1] === "(") dynamic = true
word += char
}
continue
}
if (char === "'" || char === '"') {
quote = char === "'" ? "single" : "double"
started = true
continue
}
if (char === "`" && index + 1 < input.length) {
if (input[index + 1] === "\r" || input[index + 1] === "\n") return { kind: "opaque", reason: "invalid-structure" }
if (";&|".includes(input[index + 1])) return { kind: "opaque", reason: "invalid-structure" }
if (words.length === 0) dynamic = true
started = true
word += input[++index]
continue
}
if (char === "`") return { kind: "opaque", reason: "unterminated-escape" }
if (char === "<" && input[index + 1] === "#") return { kind: "opaque", reason: "dynamic-execution" }
if (char === "#" && !started) {
if (/^#requires\b/i.test(input.slice(index))) return { kind: "opaque", reason: "dynamic-execution" }
finishCommand(index)
comment = true
const endings = [input.indexOf("\n", index), input.indexOf("\r", index)].filter((ending) => ending >= 0)
const newline = endings.length > 0 ? Math.min(...endings) : -1
if (newline === -1) break
comment = false
index = input[newline] === "\r" && input[newline + 1] === "\n" ? newline + 1 : newline
segment = newline + 1
continue
}
const redirect =
char === ">" || (!started && (char === "*" || /\d/.test(char))) ? powerShellRedirect(input, index) : undefined
if (redirect) {
finishWord()
redirectTarget = !redirect.includes("&")
index += redirect.length - 1
continue
}
if (char === "{" && !started) {
const block = powerShellBlock(input, index)
if (!block) return { kind: "opaque", reason: "invalid-structure" }
const result = scanPowerShellNested(block.source, depth + 1)
if (result.kind === "opaque") return result
nestedCommands.push(...result.commands.map(markNested))
started = true
word += input.slice(index, block.end + 1)
index = block.end
continue
}
if (char === "}") return { kind: "opaque", reason: "invalid-structure" }
if (char === "&" && !started && words.length === 0) continue
if (char === "." && !started && words.length === 0 && (/\s/.test(input[index + 1] ?? "") || !input[index + 1]))
continue
if ("@()".includes(char)) dynamic = true
if (/\s/.test(char) && char !== "\n" && char !== "\r") {
finishWord()
continue
}
const next = input[index + 1]
const separator =
char === "\r" && next === "\n"
? char + next
: (char === "&" && next === "&") || (char === "|" && next === "|")
? char + next
: char === ";" || char === "|" || char === "&" || char === "\n" || char === "\r"
? char
: undefined
if (separator) {
finishCommand(index, true)
if (redirectTarget) invalid = true
dangling = ![";", "&", "\n", "\r", "\r\n"].includes(separator)
index += separator.length - 1
segment = separator === "&" ? index : index + 1
continue
}
started = true
dangling = false
word += char
}
if (quote) return { kind: "opaque", reason: "unterminated-quote" }
if (!comment) finishCommand(input.length)
if (redirectTarget || invalid || dangling) return { kind: "opaque", reason: "invalid-structure" }
const reason = dynamic
? "dynamic-execution"
: commands.reduce<OpaqueReason | undefined>(
(result, command) => result ?? powerShellOpaqueReason(command),
undefined,
)
if (reason) return { kind: "opaque", reason }
return { kind: "scanned", commands }
}
function powerShellBlock(input: string, start: number) {
let quote: "single" | "double" | undefined
let level = 1
for (let index = start + 1; index < input.length; index++) {
const char = input[index]
if (quote === "single") {
if (char === "'" && input[index + 1] === "'") index++
else if (char === "'") quote = undefined
continue
}
if (char === "`") {
index++
continue
}
if (char === "#" && quote !== "double") {
const newline = input.indexOf("\n", index)
if (newline < 0) return
index = newline
continue
}
if (char === "<" && input[index + 1] === "#" && quote !== "double") {
const end = input.indexOf("#>", index + 2)
if (end < 0) return
index = end + 1
continue
}
if (char === "'") {
quote = "single"
continue
}
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (char === "{" && quote !== "double") level++
if (char !== "}" || quote === "double" || --level) continue
return { source: input.slice(start + 1, index), end: index }
}
}
function shellCommandName(word: string | undefined) {
const value = (word ?? "").toLowerCase()
return value.slice(Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1)
}
function powerShellOpaqueReason(command: Command): OpaqueReason | undefined {
const head = command.words[0] ?? ""
if (
(head.includes("\\") &&
!/^[A-Za-z]:\\/.test(head) &&
!/^[A-Za-z_][A-Za-z0-9_.-]*\\[A-Za-z_][A-Za-z0-9_.-]*$/.test(head)) ||
head.includes("$") ||
head.includes("@")
)
return "dynamic-execution"
const name = shellCommandName(head)
if (["return", "throw", "exit", "break", "continue"].includes(name) && command.words.length > 1)
return "dynamic-execution"
if (!POWERSHELL_LOCATIONS.has(name)) return
if (
command.words.some(
(word, index) =>
index > 0 &&
(word.includes("(") ||
(word.includes("$") && !knownPowerShellDirectory(word)) ||
(/^[A-Za-z]+:/.test(word) && !/^[A-Za-z]:[\\/]/.test(word))),
)
)
return "dynamic-directory"
}
function knownPowerShellDirectory(word: string) {
const variable = /^(?:\$(?:PWD|HOME|PSHOME)|\$env:[A-Za-z_][A-Za-z0-9_]*|\$\{env:[^}]+\})(?:[\\/]|$)/i.exec(word)
return Boolean(variable) && !word.slice(variable?.[0].length).includes("$")
}
function powerShellRedirect(input: string, index: number) {
let cursor = index
if (input[cursor] === "*") cursor++
else while (/\d/.test(input[cursor] ?? "")) cursor++
if (input[cursor] !== ">") return
cursor++
if (input[cursor] === ">") cursor++
if (input[cursor] === "&") {
cursor++
while (/\d/.test(input[cursor] ?? "")) cursor++
}
return input.slice(index, cursor)
}
function markNested(command: Command) {
return Object.defineProperty({ ...command }, Nested, { value: true })
}