mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 13:07:53 +08:00
408 lines
11 KiB
JavaScript
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
|
|
}
|