/** * 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} 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} 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} ctx * @param {string} inner * @param {Record} 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} ctx * @param {Record} env * @param {Record} 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} ctx * @param {string} s * @param {Record} 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} ctx * @param {{ type: 'word', value: string, parts?: ShellWordPart[] }} wordTok * @param {Record} env * @param {{ redirect?: boolean, disablePathnameExpansion?: boolean }} [globOpts] * @returns {Promise} */ 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 }