This commit is contained in:
2026-08-18 18:11:28 -04:00
parent 0e9a650fa2
commit bbaf47028f
259 changed files with 0 additions and 0 deletions
@@ -0,0 +1,579 @@
/**
* Shell parameter / arithmetic word expansion (`$VAR`, `${…}`, `$((…))`)
* and command-substitution / pathname expansion of tokenized words.
*/
import { findArithmeticClose } from './shell-lex.js'
import { shellCheckUnboundParam } from './shell-nounset.js'
import { bareOsEvalArithmeticExpr } from './shell-arithmetic.js'
import { BARE_OS_EXIT_STATUS_ENV } from './shell-runtime.js'
import { pathnameExpandShellWord } from './shell-glob.js'
/** @typedef {{ q: 'u' | 's' | 'd', t: string }} ShellWordPart */
/**
* @param {string} inner
* @param {Record<string, string>} env
*/
function expandParamBracedInner(inner, env, depth = 0) {
const expandNested = (s) => {
const txt = String(s ?? '')
if (!txt.includes('$')) return txt
return expandWord(txt, env, depth + 1)
}
const tr = inner.trim()
const lenParam = /^#([A-Za-z_][A-Za-z0-9_]*)$/.exec(tr)
if (lenParam) {
shellCheckUnboundParam(lenParam[1], env)
return String(String(env[lenParam[1]] ?? '').length)
}
const indirectOn =
env &&
(env.BARE_OS_SHELL_INDIRECT_EXPANSION === '1' ||
env.BARE_OS_SHELL_INDIRECT_EXPANSION === 'true')
const indirectName = /^!([A-Za-z_][A-Za-z0-9_]*)$/.exec(tr)
if (indirectOn && indirectName) {
const ref = String(env[indirectName[1]] ?? '')
shellCheckUnboundParam(ref, env)
return String(env[ref] ?? '')
}
const paramV2 =
env &&
(env.BARE_OS_SHELL_PARAM_EXPANSION_V2 === '1' ||
env.BARE_OS_SHELL_PARAM_EXPANSION_V2 === 'true')
const paramV3 =
env &&
(env.BARE_OS_SHELL_PARAM_EXPANSION_V3 === '1' ||
env.BARE_OS_SHELL_PARAM_EXPANSION_V3 === 'true')
const errIdx = inner.indexOf(':?')
if (paramV3 && errIdx > 0) {
const name = inner.slice(0, errIdx).trim()
const msg = inner.slice(errIdx + 2)
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
const v = env[name]
if (v == null || String(v) === '') {
throw new Error(expandNested(msg) || 'parameter null or unset')
}
return String(v)
}
}
const assignIdx = inner.indexOf(':=')
if (paramV2 && assignIdx > 0) {
const name = inner.slice(0, assignIdx).trim()
const alt = inner.slice(assignIdx + 2)
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
let v = env[name]
if (v == null || String(v) === '') {
const ex = expandNested(alt)
env[name] = ex
v = ex
}
return String(v ?? '')
}
}
const posixUnsetOnly =
env &&
(env.BARE_OS_SHELL_POSIX_UNSET_ONLY_DEFAULT === '1' ||
env.BARE_OS_SHELL_POSIX_UNSET_ONLY_DEFAULT === 'true')
if (posixUnsetOnly) {
const hy = inner.indexOf('-')
if (
hy > 0 &&
inner.slice(hy - 1, hy + 1) !== ':-' &&
!inner.includes(':')
) {
const m = /^([A-Za-z_][A-Za-z0-9_]*)-(.+)$/.exec(inner)
if (m && m[1] && m[2] != null) {
const name = m[1]
const alt = m[2]
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
if (!Object.prototype.hasOwnProperty.call(env, name))
return expandNested(alt)
return String(env[name] ?? '')
}
}
}
}
const idx = inner.indexOf(':-')
if (idx > 0) {
const name = inner.slice(0, idx).trim()
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
const alt = inner.slice(idx + 2)
const v = env[name]
if (v != null && String(v) !== '') return String(v)
return expandNested(alt)
}
}
const plusIdx = inner.indexOf(':+')
if (paramV3 && plusIdx > 0) {
const name = inner.slice(0, plusIdx).trim()
const alt = inner.slice(plusIdx + 2)
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
const v = env[name]
if (v != null && String(v) !== '') return expandNested(alt)
return ''
}
}
if (paramV3) {
const sliceRe = /^([A-Za-z_][A-Za-z0-9_]*):(\d+)(?::(\d+))?$/.exec(tr)
if (sliceRe) {
const name = sliceRe[1]
const off = Number.parseInt(sliceRe[2], 10)
const ln =
sliceRe[3] != null ? Number.parseInt(sliceRe[3], 10) : undefined
const v = String(env[name] ?? '')
let out = Number.isFinite(off) ? v.slice(off) : v
if (ln != null && Number.isFinite(ln)) out = out.slice(0, ln)
return out
}
const globalRepl = /^([A-Za-z_][A-Za-z0-9_]*)\/\/(.*)\/(.*)$/.exec(tr)
if (globalRepl && globalRepl[2].length <= 256 && globalRepl[3].length <= 512) {
const name = globalRepl[1]
let v = String(env[name] ?? '')
const pat = globalRepl[2]
const rep = expandNested(globalRepl[3])
try {
const re = new RegExp(pat, 'g')
v = v.replace(re, rep)
} catch {
/* invalid regex — leave value */
}
return v
}
}
if (paramV2) {
const longPref = /^([A-Za-z_][A-Za-z0-9_]*)##(.+)$/.exec(inner)
if (longPref && longPref[2].length > 0 && longPref[2].length <= 128) {
const v = String(env[longPref[1]] ?? '')
const pat = longPref[2]
if (pat === '*/') {
const i = v.lastIndexOf('/')
return i >= 0 ? v.slice(i + 1) : v
}
let end = -1
for (let i = 0; i <= v.length - pat.length; i++) {
if (v.slice(i, i + pat.length) === pat) end = i + pat.length
}
return end >= 0 ? v.slice(end) : v
}
const shortPref = /^([A-Za-z_][A-Za-z0-9_]*)#(.+)$/.exec(inner)
if (shortPref && shortPref[2].length > 0 && shortPref[2].length <= 128) {
const v = String(env[shortPref[1]] ?? '')
const pat = shortPref[2]
if (pat === '*/') {
const i = v.indexOf('/')
return i >= 0 ? v.slice(i + 1) : v
}
const i = v.indexOf(pat)
return i >= 0 ? v.slice(i + pat.length) : v
}
const longSuf = /^([A-Za-z_][A-Za-z0-9_]*)%%(.+)$/.exec(inner)
if (longSuf && longSuf[2].length > 0 && longSuf[2].length <= 128) {
const v = String(env[longSuf[1]] ?? '')
const pat = longSuf[2]
if (!/[?*[]/.test(pat) && v.endsWith(pat))
return v.slice(0, v.length - pat.length)
if (pat.includes('*') && !pat.includes('[') && !pat.includes('?')) {
const parts = pat.split('*')
if (parts.length === 2) {
const a = parts[0]
const b = parts[1]
let best = -1
for (let len = 1; len <= v.length; len++) {
const suf = v.slice(v.length - len)
if (
suf.startsWith(a) &&
suf.endsWith(b) &&
suf.length >= a.length + b.length
) {
if (best < 0 || len > best) best = len
}
}
if (best > 0) return v.slice(0, v.length - best)
}
}
return v
}
const shortSuf = /^([A-Za-z_][A-Za-z0-9_]*)%(.+)$/.exec(inner)
if (shortSuf && shortSuf[2].length > 0 && shortSuf[2].length <= 128) {
const v = String(env[shortSuf[1]] ?? '')
const pat = shortSuf[2]
if (!/[?*[]/.test(pat) && v.endsWith(pat))
return v.slice(0, v.length - pat.length)
if (pat.includes('*') && !pat.includes('[') && !pat.includes('?')) {
const parts = pat.split('*')
if (parts.length === 2) {
const a = parts[0]
const b = parts[1]
let best = -1
for (let len = 1; len <= v.length; len++) {
const suf = v.slice(v.length - len)
if (
suf.startsWith(a) &&
suf.endsWith(b) &&
suf.length >= a.length + b.length
) {
if (best < 0 || len < best) best = len
}
}
if (best > 0) return v.slice(0, v.length - best)
}
}
return v
}
}
const hash = inner.indexOf('#')
if (hash > 0) {
const name = inner.slice(0, hash).trim()
const pref = inner.slice(hash + 1)
if (
/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
pref.length > 0 &&
pref.length <= 128
) {
const v = String(env[name] ?? '')
return v.startsWith(pref) ? v.slice(pref.length) : v
}
}
const keySimple = inner.trim()
shellCheckUnboundParam(keySimple, env)
return env[keySimple] ?? ''
}
/**
* @param {string} s
* @param {Record<string, string>} env
*/
export function expandWord(s, env, depth = 0) {
const maxDepthRaw = Number.parseInt(
String(env?.BARE_OS_SHELL_EXPANSION_MAX_DEPTH || '32'),
10
)
const maxDepth =
Number.isFinite(maxDepthRaw) && maxDepthRaw > 0 ? Math.min(maxDepthRaw, 256) : 32
if (depth > maxDepth) {
throw new Error(`shell: expansion recursion too deep (max ${maxDepth})`)
}
const paramExpOn =
env &&
(env.BARE_OS_SHELL_PARAM_EXPANSION === '1' ||
env.BARE_OS_SHELL_PARAM_EXPANSION === 'true')
const paramV2 =
env &&
(env.BARE_OS_SHELL_PARAM_EXPANSION_V2 === '1' ||
env.BARE_OS_SHELL_PARAM_EXPANSION_V2 === 'true')
const paramV3 =
env &&
(env.BARE_OS_SHELL_PARAM_EXPANSION_V3 === '1' ||
env.BARE_OS_SHELL_PARAM_EXPANSION_V3 === 'true')
let out = ''
let j = 0
while (j < s.length) {
if (s[j] === '$') {
if (s[j + 1] === '(' && s[j + 2] === '(') {
const close = findArithmeticClose(s, j + 3)
if (close < 0) {
out += s.slice(j)
break
}
const inner = s.slice(j + 3, close)
try {
out += bareOsEvalArithmeticExpr(inner, env)
} catch (e) {
if (
env?.BARE_OS_SHELL_POSIX_MODE === '1' ||
env?.BARE_OS_SHELL_POSIX_MODE === 'true'
) {
throw new Error(
'shell: arithmetic: invalid token (POSIX mode strict arithmetic)'
)
}
throw e
}
j = close + 2
continue
}
if (s[j + 1] === '{') {
const end = s.indexOf('}', j + 2)
if (end === -1) {
out += s.slice(j)
break
}
const inner = s.slice(j + 2, end)
const tr0 = inner.trim()
const indirectOnBr =
env?.BARE_OS_SHELL_INDIRECT_EXPANSION === '1' ||
env?.BARE_OS_SHELL_INDIRECT_EXPANSION === 'true'
if (inner === '?') {
out += env[BARE_OS_EXIT_STATUS_ENV] ?? '0'
} else if (
/^#[A-Za-z_][A-Za-z0-9_]*$/.test(tr0) ||
(indirectOnBr && /^![A-Za-z_][A-Za-z0-9_]*$/.test(tr0)) ||
(paramExpOn &&
(inner.includes(':-') ||
(paramV3 && (inner.includes(':+') || inner.includes(':?'))) ||
(paramV3 &&
(/^[A-Za-z_][A-Za-z0-9_]*:\d/.test(tr0) ||
/^[A-Za-z_][A-Za-z0-9_]*\/\//.test(inner))) ||
(paramV2 &&
(inner.includes(':=') ||
/^[A-Za-z_][A-Za-z0-9_]*##/.test(inner) ||
/^[A-Za-z_][A-Za-z0-9_]*%%/.test(inner) ||
/^[A-Za-z_][A-Za-z0-9_]*%[^%]/.test(inner) ||
/^[A-Za-z_][A-Za-z0-9_]*#[^#]/.test(inner))) ||
(/^[A-Za-z_][A-Za-z0-9_]*#/.test(inner) && inner.includes('#'))))
) {
out += expandParamBracedInner(inner, env, depth + 1)
} else {
const ik = inner.trim()
shellCheckUnboundParam(ik, env)
out += env[ik] ?? ''
}
j = end + 1
continue
}
if (s[j + 1] === '?') {
out += env[BARE_OS_EXIT_STATUS_ENV] ?? '0'
j += 2
continue
}
if (/[0-9]/.test(s[j + 1] ?? '')) {
const pn = s[j + 1]
shellCheckUnboundParam(pn, env)
out += env[pn] ?? ''
j += 2
continue
}
let k = j + 1
while (k < s.length && /[A-Za-z0-9_]/.test(s[k])) k++
const name = s.slice(j + 1, k)
if (name) {
shellCheckUnboundParam(name, env)
out += env[name] ?? ''
j = k
} else {
out += '$'
j++
}
continue
}
out += s[j++]
}
return out
}
/**
* @param {string} s
* @param {number} dollarIdx index of `$` in a `$(…)` command substitution
*/
function findCmdSubstCloseParen(s, dollarIdx) {
if (s[dollarIdx] !== '$' || s[dollarIdx + 1] !== '(') return -1
let depth = 1
for (let j = dollarIdx + 2; j < s.length; j++) {
if (s[j] === '(') depth++
else if (s[j] === ')') {
depth--
if (depth === 0) return j
}
}
return -1
}
/**
* Next `$(` that is command substitution (skip `$((` arithmetic regions).
* @param {string} s
* @param {number} from
*/
function findNextCmdSubstParen(s, from) {
let i = from
while (i < s.length) {
const j = s.indexOf('$(', i)
if (j < 0) return -1
if (j + 2 < s.length && s[j + 2] === '(') {
const ac = findArithmeticClose(s, j + 3)
if (ac < 0) return -1
i = ac + 2
continue
}
return j
}
return -1
}
/**
* @param {string} s
* @param {number} from scan from here (first char inside / after opener)
*/
function findBacktickClose(s, from) {
return s.indexOf('`', from)
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} inner
* @param {Record<string, string>} env
*/
async function expandCmdsubstEmbedded(ctx, inner, env) {
const ex = typeof ctx.execLine === 'function' ? ctx.execLine : null
if (!ex) throw new Error('shell: cmdsubst: execLine unavailable')
const maxLen =
Number.parseInt(env.BARE_OS_SHELL_CMDSUBST_MAX_BYTES || '8192', 10) || 8192
const lines = []
const prev = ctx.console.log
ctx.console.log = (...a) => {
lines.push(a.map(String).join(' '))
}
try {
await ex(inner.trim())
} finally {
ctx.console.log = prev
}
let out = lines.join('\n').replace(/\n+$/, '')
if (out.length > maxLen) out = out.slice(0, maxLen)
const totalBudgetRaw = Number.parseInt(
String(env.BARE_OS_SHELL_EXPANSION_MAX_BYTES || '262144'),
10
)
const totalBudget =
Number.isFinite(totalBudgetRaw) && totalBudgetRaw > 0
? Math.min(totalBudgetRaw, 8 * 1024 * 1024)
: 262144
if (out.length > totalBudget) {
throw new Error(
`shell: expansion exceeds BARE_OS_SHELL_EXPANSION_MAX_BYTES (${totalBudget})`
)
}
return out
}
/**
* Optional trace hook for expansion ordering.
* Order is: parameter/command/arithmetic, then split/glob.
* @param {Record<string, unknown>} ctx
* @param {Record<string, string>} env
* @param {Record<string, unknown>} row
*/
function maybeTraceShellExpansion(ctx, env, row) {
const on =
env.BARE_OS_SHELL_EXPANSION_TRACE === '1' ||
env.BARE_OS_SHELL_EXPANSION_TRACE === 'true'
if (!on) return
if (!Array.isArray(ctx.shellExpansionTrace)) ctx.shellExpansionTrace = []
ctx.shellExpansionTrace.push({
ts: Date.now(),
...row
})
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} s
* @param {Record<string, string>} env
* @param {number} [depth]
*/
async function expandWordWithCmdSubst(ctx, s, env, depth = 0) {
const maxDepth = 2
if (depth > maxDepth) throw new Error('shell: cmdsubst: nesting too deep')
const rawCmdOn = String(env.BARE_OS_SHELL_CMDSUBST || '').trim().toLowerCase()
const cmdOn = !(rawCmdOn === '0' || rawCmdOn === 'false' || rawCmdOn === 'off')
if (!cmdOn) return expandWord(s, env)
const tick = findBacktickClose(s, 0)
const dol = findNextCmdSubstParen(s, 0)
/** @type {'tick'|'dol'|null} */
let kind = null
let pos = -1
if (tick >= 0 && (dol < 0 || tick < dol)) {
kind = 'tick'
pos = tick
} else if (dol >= 0) {
kind = 'dol'
pos = dol
} else {
return expandWord(s, env)
}
if (kind === 'dol') {
const closeParen = findCmdSubstCloseParen(s, pos)
if (closeParen < 0) return expandWord(s, env)
const inner = s.slice(pos + 2, closeParen)
const pre = s.slice(0, pos)
const post = s.slice(closeParen + 1)
const mid = await expandCmdsubstEmbedded(ctx, inner, env)
const merged = pre + mid + post
return expandWordWithCmdSubst(ctx, merged, env, depth + 1)
}
const closeTick = findBacktickClose(s, pos + 1)
if (closeTick < 0) return expandWord(s, env)
const inner = s.slice(pos + 1, closeTick)
const pre = s.slice(0, pos)
const post = s.slice(closeTick + 1)
const mid = await expandCmdsubstEmbedded(ctx, inner, env)
const merged = pre + mid + post
return expandWordWithCmdSubst(ctx, merged, env, depth + 1)
}
/**
* Param / command-substitution expansion per quote segment, then pathname expansion.
* Expansion ordering (declared profile): parameter/command/arithmetic -> word split -> glob.
* @param {Record<string, unknown>} ctx
* @param {{ type: 'word', value: string, parts?: ShellWordPart[] }} wordTok
* @param {Record<string, string>} env
* @param {{ redirect?: boolean, disablePathnameExpansion?: boolean }} [globOpts]
* @returns {Promise<string[]>}
*/
export async function expandShellWordTokens(ctx, wordTok, env, globOpts) {
const parts =
wordTok.parts && wordTok.parts.length
? wordTok.parts
: [{ q: /** @type {'u'} */ ('u'), t: wordTok.value }]
/** @type {ShellWordPart[]} */
const ep = []
for (const p of parts) {
if (p.q === 's') ep.push(p)
else {
maybeTraceShellExpansion(ctx, env, {
stage: 'expand-pre',
quote: p.q,
input: p.t
})
const s = await expandWordWithCmdSubst(ctx, p.t, env, 0)
maybeTraceShellExpansion(ctx, env, {
stage: 'expand-post',
quote: p.q,
output: s
})
ep.push({ q: p.q, t: s })
}
}
const out = await pathnameExpandShellWord(ctx, ep, env, globOpts || {})
maybeTraceShellExpansion(ctx, env, {
stage: 'split-glob',
outputCount: out.length,
output: out.slice(0, 8)
})
const budgetRaw = Number.parseInt(
String(env.BARE_OS_SHELL_EXPANSION_MAX_BYTES || '262144'),
10
)
const budget =
Number.isFinite(budgetRaw) && budgetRaw > 0
? Math.min(budgetRaw, 8 * 1024 * 1024)
: 262144
const bytes = out.reduce((n, s) => n + String(s).length, 0)
if (bytes > budget) {
throw new Error(
`shell: expansion exceeds BARE_OS_SHELL_EXPANSION_MAX_BYTES (${budget})`
)
}
return out
}