Files

408 lines
11 KiB
JavaScript

/**
* 极简 shell 词法解析器
*
* 目的不是完整实现 POSIX shell 语法,而是让安全判定拿到**结构化**的命令,
* 而不是对整行文本套正则 —— 后者对 `X="-rf /"; rm $X` 这类写法毫无办法。
*
* 提供的能力:
* 1. 按 ; && || | & 换行 分段,且不会被引号内的同名字符误导
* 2. 每段拆出 argv、重定向目标
* 3. 标记该段是否含有无法静态求值的动态构造(变量、子 shell、反引号)
*
* 未覆盖(有意为之,交给上层按 high 处理):
* here-doc、进程替换的内部结构、别名展开、函数定义。
*/
const OPERATORS = ['||', '&&', ';;', ';', '|', '&', '\n']
/**
* 把命令行切成 token。
* 返回 { tokens, dynamic } —— token 为 { type: 'word'|'op', value, quoted }
*/
function tokenize(input) {
const tokens = []
let current = null
let dynamic = false
let i = 0
const pushWord = (value, quoted) => {
if (current) {
current.value += value
current.quoted = current.quoted && quoted
} else {
current = { type: 'word', value, quoted }
}
}
const flushWord = () => {
if (current) {
tokens.push(current)
current = null
}
}
while (i < input.length) {
const char = input[i]
// 反斜杠转义
if (char === '\\') {
if (i + 1 < input.length) {
pushWord(input[i + 1], true)
i += 2
} else {
i += 1
}
continue
}
// 单引号:内部完全字面量,$ 不展开
if (char === '\'') {
const end = input.indexOf('\'', i + 1)
if (end === -1) {
pushWord(input.slice(i + 1), true)
i = input.length
} else {
pushWord(input.slice(i + 1, end), true)
i = end + 1
}
continue
}
// 双引号:内部 $ 仍会展开
if (char === '"') {
const { value, end, hasExpansion } = readDoubleQuoted(input, i)
if (hasExpansion) dynamic = true
pushWord(value, !hasExpansion)
i = end
continue
}
// 反引号命令替换
if (char === '`') {
dynamic = true
const end = input.indexOf('`', i + 1)
pushWord(input.slice(i, end === -1 ? input.length : end + 1), false)
i = end === -1 ? input.length : end + 1
continue
}
// $( ) 命令替换 / $(( )) 算术展开 / ${...} / $VAR
if (char === '$') {
dynamic = true
const { value, end } = readDollar(input, i)
pushWord(value, false)
i = end
continue
}
// <( ) >( ) 进程替换
if ((char === '<' || char === '>') && input[i + 1] === '(') {
dynamic = true
const end = matchParen(input, i + 1)
pushWord(input.slice(i, end), false)
i = end
continue
}
// 空白
if (/\s/.test(char) && char !== '\n') {
flushWord()
i += 1
continue
}
// 文件描述符合并,例如 2>&1 / 1>&2。它不读写文件,必须在通用
// 操作符判断之前整体识别,否则会被错误拆成 `>` 和后台执行符 `&`。
const fdRedirect = /^(\d*)(>>?|<)&(\d+|-)/.exec(input.slice(i))
if (fdRedirect) {
flushWord()
tokens.push({
type: 'fd_redirect',
op: fdRedirect[2],
target: `&${ fdRedirect[3] }`,
fd: fdRedirect[1] || undefined
})
i += fdRedirect[0].length
continue
}
// 带文件描述符的普通重定向,例如 2>/dev/null。目标仍由后续 token
// 读取,这里只把紧邻操作符的数字识别为 fd,避免落入 argv。
const numberedRedirect = current
? null
: /^(\d+)(>>?|<)(?![>&])/.exec(input.slice(i))
if (numberedRedirect) {
flushWord()
tokens.push({
type: 'redirect',
value: numberedRedirect[2],
fd: numberedRedirect[1]
})
i += numberedRedirect[0].length
continue
}
// 操作符
const op = OPERATORS.find((item) => input.startsWith(item, i))
if (op) {
flushWord()
tokens.push({ type: 'op', value: op })
i += op.length
continue
}
// 重定向符号需要独立成 token,便于识别目标
if (char === '>' || char === '<') {
const double = input.startsWith('>>', i)
flushWord()
tokens.push({ type: 'redirect', value: double ? '>>' : char })
i += double ? 2 : 1
continue
}
pushWord(char, true)
i += 1
}
flushWord()
return { tokens, dynamic }
}
/** 读取双引号字符串,返回内容与是否含展开 */
function readDoubleQuoted(input, start) {
let i = start + 1
let value = ''
let hasExpansion = false
while (i < input.length) {
const char = input[i]
if (char === '\\' && i + 1 < input.length) {
value += input[i + 1]
i += 2
continue
}
if (char === '"') {
i += 1
break
}
if (char === '$' || char === '`') hasExpansion = true
value += char
i += 1
}
return { value, end: i, hasExpansion }
}
/** 读取 $ 开头的展开结构,原样返回文本 */
function readDollar(input, start) {
if (input[start + 1] === '(') {
const end = matchParen(input, start + 1)
return { value: input.slice(start, end), end }
}
if (input[start + 1] === '{') {
const end = input.indexOf('}', start)
const stop = end === -1 ? input.length : end + 1
return { value: input.slice(start, stop), end: stop }
}
const match = /^\$[A-Za-z_][A-Za-z0-9_]*|^\$[@*#?$!0-9-]/.exec(input.slice(start))
const length = match ? match[0].length : 1
return { value: input.slice(start, start + length), end: start + length }
}
/** 找到与 input[open] 处 '(' 配对的 ')' 的下一个下标 */
function matchParen(input, open) {
let depth = 0
for (let i = open; i < input.length; i += 1) {
if (input[i] === '(') depth += 1
else if (input[i] === ')') {
depth -= 1
if (depth === 0) return i + 1
}
}
return input.length
}
/**
* 命令行 → 分段结构
*
* @param {string} input
* @returns {Array<{ argv: string[], quotedFlags: boolean[], redirects: Array<{op:string,target:string}>,
* dynamic: boolean, raw: string, connector: string|null }>}
*/
export function parseCommandLine(input) {
if (typeof input !== 'string' || !input.trim()) return []
const { tokens, dynamic } = tokenize(input)
const segments = []
let currentArgv = []
let currentQuoted = []
let currentRedirects = []
let connector = null
let pendingRedirect = null
const flushSegment = () => {
if (currentArgv.length || currentRedirects.length) {
segments.push({
argv: currentArgv,
quotedFlags: currentQuoted,
redirects: currentRedirects,
// 动态标记按整行传播:无法静态确定哪一段引用了变量
dynamic,
raw: currentArgv.join(' '),
connector
})
}
currentArgv = []
currentQuoted = []
currentRedirects = []
}
for (const token of tokens) {
if (token.type === 'op') {
flushSegment()
connector = token.value
continue
}
if (token.type === 'redirect') {
pendingRedirect = { op: token.value, fd: token.fd }
continue
}
if (token.type === 'fd_redirect') {
currentRedirects.push({
op: token.op,
target: token.target,
fd: token.fd,
duplicate: true
})
continue
}
// word
if (pendingRedirect) {
const redirect = {
op: pendingRedirect.op,
target: token.value
}
if (pendingRedirect.fd) redirect.fd = pendingRedirect.fd
currentRedirects.push(redirect)
pendingRedirect = null
continue
}
currentArgv.push(token.value)
currentQuoted.push(token.quoted !== false)
}
flushSegment()
return segments
}
/**
* 剥离命令前缀包装(sudo / env / nohup / timeout ...),拿到真实执行的命令。
* 返回 { cmd, args, wrappers } —— cmd 已取 basename。
*/
// 注意:不要把 su 当作 wrapper 剥离。`su -c '<payload>'` 的 payload 必须
// 保留在 args 里,交给 safety.js 递归判定,否则整段会被静默跳过。
const WRAPPERS = new Set([
'sudo', 'doas', 'env', 'nohup', 'setsid', 'nice', 'ionice',
'stdbuf', 'timeout', 'time', 'command', 'builtin', 'exec', 'xargs',
// busybox/toybox 的第一个位置参数才是真正执行的 applet。
'busybox', 'toybox'
])
// 各 wrapper 需要连带跳过的带值选项
const WRAPPER_VALUE_FLAGS = {
sudo: new Set(['-u', '-g', '-h', '-p', '-C', '-r', '-t', '-U']),
doas: new Set(['-u', '-C']),
timeout: new Set(['-s', '--signal', '-k', '--kill-after']),
nice: new Set(['-n', '--adjustment']),
ionice: new Set(['-c', '-n', '-p']),
stdbuf: new Set(['-i', '-o', '-e']),
xargs: new Set(['-n', '-P', '-I', '-d', '-s', '-E'])
}
export function unwrapCommand(argv) {
const wrappers = []
let index = 0
while (index < argv.length) {
const token = argv[index]
const name = basename(token)
if (!WRAPPERS.has(name)) break
wrappers.push(name)
index += 1
// 跳过该 wrapper 自身的选项
const valueFlags = WRAPPER_VALUE_FLAGS[name] || new Set()
while (index < argv.length) {
const arg = argv[index]
if (arg === '--') {
index += 1
break
}
if (!arg.startsWith('-')) {
// env / timeout 的位置参数:VAR=value 或超时时长
if (name === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
index += 1
continue
}
if ((name === 'timeout' || name === 'nice' || name === 'xargs') && /^[\d.]+[smhd]?$/.test(arg)) {
index += 1
continue
}
break
}
if (valueFlags.has(arg)) {
index += 2
continue
}
index += 1
}
}
const rest = argv.slice(index)
return {
cmd: rest.length ? basename(rest[0]) : '',
path: rest.length ? rest[0] : '',
args: rest.slice(1),
wrappers
}
}
export function basename(value) {
if (typeof value !== 'string') return ''
const cleaned = value.split('/').pop()
return cleaned || value
}
/**
* 展开合并写法的短选项,便于判定等价形式。
* `-rf` → ['-r', '-f'];长选项原样保留。
*/
export function expandFlags(args) {
const flags = new Set()
for (const arg of args) {
if (arg === '--') break
if (arg.startsWith('--')) {
flags.add(arg.split('=')[0])
continue
}
if (arg.startsWith('-') && arg.length > 1) {
for (const char of arg.slice(1)) flags.add(`-${ char }`)
}
}
return flags
}
/** 取出非选项的位置参数 */
export function positionalArgs(args) {
const result = []
let afterDoubleDash = false
for (const arg of args) {
if (arg === '--') {
afterDoubleDash = true
continue
}
if (!afterDoubleDash && arg.startsWith('-') && arg.length > 1) continue
result.push(arg)
}
return result
}