/** * Shell implementation (tokenizer slice in {@link ./shell-tokenizer.js}; planner/executor here). */ import { runBinCommand, resolveBinInPath } from './kernel-runner.js' import { tokenizeBareShellLineForDiagnostics } from './shell-tokenizer.js' import { tokenizeBareShellLineDetailed } from './shell-tokenizer.js' import { findArithmeticClose, lexShellLine } from './shell-lex.js' export { tokenizeBareShellLineForDiagnostics } export { tokenizeBareShellLineDetailed } import { applyBareOsThemeFromEnv, bareOsGetThemePreset, bareOsListThemeNames } from './bare-os-theme-presets.js' import { pathnameExpandShellWord, bareOsFnmatch } from './shell-glob.js' import { bareOsKernelMetricSet } from './bare-os-kernel-metrics.js' /** @see execShellLine — nounset violations surface as exit status 1 */ export const BARE_OS_SHELL_NOUNSET_ERROR = 'BARE_OS_SHELL_NOUNSET' /** * @param {Record | null | undefined} env */ function shellNounsetEnvOn(env) { return ( env && (env.BARE_OS_SHELL_NOUNSET === '1' || env.BARE_OS_SHELL_NOUNSET === 'true') ) } /** * @param {string} name * @param {Record} env */ function shellCheckUnboundParam(name, env) { if (!shellNounsetEnvOn(env) || !name) return const n = name.trim() if (n === '?' || /^\d+$/.test(n)) return if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(n)) return if (!Object.prototype.hasOwnProperty.call(env, n)) { const e = new Error(`unbound variable: ${n}`) e.code = BARE_OS_SHELL_NOUNSET_ERROR throw e } } /** * Expand `$VAR`, `${VAR}`, and `$N` inside arithmetic before identifier substitution. * @param {string} inner * @param {Record} env */ function bareOsArithmeticExpandDollarForms(inner, env) { const s = String(inner || '') if (s.length > 4096) throw new Error('shell: arithmetic: expression too long') /** @type {string[]} */ const parts = [] let i = 0 let replacements = 0 const maxRep = 256 while (i < s.length) { if (s[i] !== '$') { parts.push(s[i++]) continue } if (++replacements > maxRep) throw new Error('shell: arithmetic: expansion limit exceeded') if (i + 1 >= s.length) { parts.push('$') i++ continue } if (s[i + 1] === '{') { const end = s.indexOf('}', i + 2) if (end === -1) { parts.push('$') i++ continue } const name = s.slice(i + 2, end).trim() if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error('shell: arithmetic: invalid ${} in expression') shellCheckUnboundParam(name, env) const raw = env[name] const n = Number.parseInt(String(raw ?? '0').trim(), 10) parts.push(String(Number.isFinite(n) ? n : 0)) i = end + 1 continue } const c1 = s[i + 1] if (/[0-9]/.test(c1)) { shellCheckUnboundParam(c1, env) const raw = env[c1] const n = Number.parseInt(String(raw ?? '0').trim(), 10) parts.push(String(Number.isFinite(n) ? n : 0)) i += 2 continue } if (!/[A-Za-z_]/.test(c1)) { parts.push('$') i++ continue } let k = i + 2 while (k < s.length && /[A-Za-z0-9_]/.test(s[k])) k++ const name = s.slice(i + 1, k) shellCheckUnboundParam(name, env) const raw = env[name] const n = Number.parseInt(String(raw ?? '0').trim(), 10) parts.push(String(Number.isFinite(n) ? n : 0)) i = k } return parts.join('') } /** * @param {string} expr * @param {Record} env */ function bareOsEvalArithmeticExpr(expr, env) { const pre = bareOsArithmeticExpandDollarForms(String(expr || ''), env) const src = pre.trim() if (!src) return '0' if (!/^[A-Za-z0-9_+\-*/%()\s]+$/.test(src)) { throw new Error('shell: arithmetic: invalid token') } const replaced = src.replace(/\b[A-Za-z_][A-Za-z0-9_]*\b/g, (name) => { shellCheckUnboundParam(name, env) const raw = env[name] const n = Number.parseInt(String(raw ?? '0').trim(), 10) return String(Number.isFinite(n) ? n : 0) }) let out = 0 try { out = Function('"use strict"; return (' + replaced + ');')() } catch { throw new Error('shell: arithmetic: invalid expression') } const num = Number(out) if (!Number.isFinite(num)) throw new Error('shell: arithmetic: non-finite result') return String(Math.trunc(num)) } const SHELL_BUILTINS = new Set([ 'alias', 'unalias', 'barerc', 'cd', 'export', 'unset', 'readonly', 'umask', 'set', ':', 'command', 'type', 'logout', 'exit', 'jobs', 'fg', 'bg', 'wait', 'suspend-job', 'disown', 'trap', 'test', '[' ]) /** * Optional POSIX-style **`read`** builtin (bounded line, IFS split). Off by default. * @param {Record | null | undefined} env */ export function bareOsShellReadBuiltinEnabled(env) { const o = env && typeof env === 'object' ? env : {} return o.BARE_OS_SHELL_READ_BUILTIN === '1' || o.BARE_OS_SHELL_READ_BUILTIN === 'true' } /** * Sorted list of shell builtin command names for completion / UX. * Includes **`read`** only when {@link bareOsShellReadBuiltinEnabled} is true. * @param {Record | null | undefined} env * @returns {string[]} */ export function listBareOsShellBuiltins(env) { const out = [...SHELL_BUILTINS] if (bareOsShellReadBuiltinEnabled(env)) out.push('read') out.sort() return out } /** * @param {string} cmd * @param {Record | null | undefined} env */ function isShellBuiltin(cmd, env) { if (SHELL_BUILTINS.has(cmd)) return true if (cmd === 'read' && bareOsShellReadBuiltinEnabled(env)) return true return false } /** * @param {string} line * @param {string} ifs * @param {number} nNames */ function bareOsShellReadSplitFields(line, ifs, nNames) { const sep = ifs.length ? ifs[0] : ' ' if (nNames <= 1) return [line] const out = [] let rest = line for (let i = 0; i < nNames - 1; i++) { const idx = rest.indexOf(sep) if (idx === -1) { out.push(rest) rest = '' break } out.push(rest.slice(0, idx)) rest = rest.slice(idx + sep.length) } while (out.length < nNames - 1) out.push('') out.push(rest) return out } /** * @param {Record} ctx * @param {number} maxBytes * @param {(m: string) => void} errFn * @returns {Promise} null = EOF / error */ async function bareOsShellReadOneLine(ctx, maxBytes, errFn, opts = {}) { const delimiter = typeof opts.delimiter === 'string' ? opts.delimiter : '\n' const timeoutMs = Number.isFinite(opts.timeoutMs) ? Number(opts.timeoutMs) : 0 if (typeof ctx.shellStdin === 'string') { const raw = ctx.shellStdin const idx = delimiter ? raw.indexOf(delimiter) : -1 const line = idx === -1 ? raw : raw.slice(0, idx) ctx.shellStdin = idx === -1 ? '' : raw.slice(idx + delimiter.length) if (line.length > maxBytes) { errFn(`read: line exceeds BARE_OS_SHELL_READ_MAX_BYTES (${maxBytes})`) return null } return line } const rl = ctx.readLine if (typeof rl === 'function') { const readP = rl('') const ln = timeoutMs > 0 ? await Promise.race([ readP, new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)) ]) : await readP if (ln == null) return null if (ln.length > maxBytes) { errFn(`read: line exceeds BARE_OS_SHELL_READ_MAX_BYTES (${maxBytes})`) return null } return ln } errFn( 'read: no input (redirect stdin, use a pipeline, or interactive readLine)' ) return null } /** * @param {Record} ctx * @param {string[]} argv * @param {Record} env * @param {(m: string) => void} origErr */ async function runShellReadBuiltin(ctx, argv, env, origErr) { let i = 1 let rawMode = false let delimiter = '\n' let timeoutMs = 0 while (i < argv.length && argv[i].startsWith('-')) { const a = argv[i] if (a === '-r') { rawMode = true } else if (a === '-d') { const d = argv[i + 1] if (d == null) { origErr.call(ctx.console, 'read: option requires an argument -- d') ctx.exitCode = 2 return } delimiter = String(d).slice(0, 1) i++ } else if (a === '-t') { const v = argv[i + 1] if (v == null) { origErr.call(ctx.console, 'read: option requires an argument -- t') ctx.exitCode = 2 return } const n = Number.parseFloat(String(v)) if (!Number.isFinite(n) || n < 0) { origErr.call(ctx.console, 'read: invalid timeout: ' + String(v)) ctx.exitCode = 2 return } timeoutMs = Math.min(Math.floor(n * 1000), 120000) i++ } else if (a === '--') { i++ break } else { origErr.call(ctx.console, 'read: unsupported option: ' + a) ctx.exitCode = 2 return } i++ } const names = argv.slice(i).filter(Boolean) if (!names.length) names.push('REPLY') const maxRaw = env.BARE_OS_SHELL_READ_MAX_BYTES const maxParsed = maxRaw != null && String(maxRaw).trim() !== '' ? Number.parseInt(String(maxRaw), 10) : 65536 const maxBytes = Number.isFinite(maxParsed) && maxParsed > 0 ? Math.min(maxParsed, 2_000_000) : 65536 let line = await bareOsShellReadOneLine( ctx, maxBytes, (m) => origErr.call(ctx.console, m), { delimiter, timeoutMs } ) if (line === null) { for (const n of names) env[n] = '' ctx.exitCode = 1 return } if (!rawMode) { line = line.replace(/\\(.)/g, '$1') } const ifs = env.IFS !== undefined && env.IFS !== null ? String(env.IFS) : ' \t\n' const fields = bareOsShellReadSplitFields(line, ifs, names.length) for (let j = 0; j < names.length; j++) { const k = names[j] if ( ctx.shellReadonlyVars instanceof Set && ctx.shellReadonlyVars.has(k) ) { origErr.call(ctx.console, k + ': readonly variable') ctx.exitCode = 1 return } env[k] = fields[j] ?? '' } ctx.exitCode = 0 } /** * When `suspend-job` sets `stopped` on a background entry, yield between statements * until `fg` / `bg` clears it (cooperative logical job control; no host SIGTSTP). * @param {{ stopped?: boolean }} entry */ function waitWhileShellJobStopped(entry) { if (!entry || !entry.stopped) return Promise.resolve() return new Promise((resolve) => { const id = setInterval(() => { if (!entry.stopped) { clearInterval(id) resolve(undefined) } }, 10) }) } /** * Boot policy v4: comma list in `BARE_OS_BOOT_POLICY_DENY_EXEC_LINE_BUILTINS`. * @param {string} name * @param {Record | null | undefined} env */ function isExecLineBuiltinDenied(name, env) { const raw = env && env.BARE_OS_BOOT_POLICY_DENY_EXEC_LINE_BUILTINS if (raw == null || String(raw).trim() === '') return false const set = new Set( String(raw) .split(',') .map((s) => s.trim()) .filter(Boolean) ) return set.has(name) } /** * @param {string} key * @param {Record | null | undefined} env */ function shellCsvSetFromEnv(key, env) { const raw = env && env[key] if (raw == null || String(raw).trim() === '') return new Set() return new Set( String(raw) .split(',') .map((s) => s.trim()) .filter(Boolean) ) } /** * @param {Record} ctx * @param {string} event * @param {Record} payload */ function appendShellAuditEvent(ctx, event, payload = {}) { if (!Array.isArray(ctx.shellAuditEvents)) ctx.shellAuditEvents = [] ctx.shellAuditEvents.push({ schema: 1, ts: Date.now(), event, ...payload }) } /** * @param {string} name * @param {Record | null | undefined} env */ function shellCommandDeniedByPolicy(name, env) { const cmd = String(name || '').trim() if (!cmd) return false const deny = shellCsvSetFromEnv('BARE_OS_SHELL_DENY_COMMANDS', env) if (deny.has(cmd)) return true const allow = shellCsvSetFromEnv('BARE_OS_SHELL_ALLOW_COMMANDS', env) if (allow.size > 0 && !allow.has(cmd)) return true return false } /** * @param {string} path * @param {Record | null | undefined} env */ function shellUnsafeRedirectPath(path, env) { const guardOn = env && (env.BARE_OS_SHELL_REDIRECT_GUARD === '1' || env.BARE_OS_SHELL_REDIRECT_GUARD === 'true') if (!guardOn) return false const p = String(path || '').trim() if (!p) return false if (p.includes('/../') || p.startsWith('../') || p.endsWith('/..')) return true if (p.startsWith('/proc/') || p.startsWith('/sys/') || p.startsWith('/dev/')) return true return false } /** * @param {Record} ctx * @param {Record} childCtx */ function mergeChildExitCode(ctx, childCtx) { if (childCtx.exitCode !== undefined && childCtx.exitCode !== null) { ctx.exitCode = childCtx.exitCode } } /** * @param {unknown} identity * @returns {identity is { state: unknown, publicKey: unknown, secretKey: unknown }} */ function isMergeableIdentitySession(identity) { if (!identity || typeof identity !== 'object') return false return ( Object.prototype.hasOwnProperty.call(identity, 'state') && Object.prototype.hasOwnProperty.call(identity, 'publicKey') && Object.prototype.hasOwnProperty.call(identity, 'secretKey') ) } /** * Merge shell child-command side effects we intentionally allow to flow back. * Today this includes exit status and identity session state. * @param {Record} ctx * @param {Record} childCtx */ function mergePipelineChildCtx(ctx, childCtx) { mergeChildExitCode(ctx, childCtx) if (isMergeableIdentitySession(childCtx.identity)) { ctx.identity = /** @type {Record} */ (childCtx.identity) } } /** Env key mirroring last command exit status (POSIX `$?` parity). */ export const BARE_OS_EXIT_STATUS_ENV = 'BARE_OS_EXIT_STATUS' /** * Mirror `ctx.exitCode` into `ctx.vfs.env` so kernels and `echo $?` see last status. * @param {Record} ctx */ export function syncBareOsExitStatusEnv(ctx) { const env = ctx.vfs?.env if (!env || typeof env !== 'object') return const n = Number(ctx.exitCode) env[BARE_OS_EXIT_STATUS_ENV] = String(Number.isFinite(n) ? n : 0) } /** Max alias indirections (prevents cycles). */ const MAX_ALIAS_DEPTH = 16 /** Default caps for simulated pipeline capture (`console.log` between stages). */ export const DEFAULT_PIPELINE_MAX_STAGES = 32 export const DEFAULT_PIPELINE_MAX_CAPTURE_BYTES = 2 * 1024 * 1024 export const DEFAULT_PIPELINE_MAX_CAPTURE_LINES = 50000 /** * Resolved simulated pipeline limits for the current `vfs.env` / session env. * @param {Record | null | undefined} env */ export function getBareOsPipelineLimits(env) { const o = env && typeof env === 'object' ? env : {} const parse = (key, def) => { const v = o[key] if (v == null || v === '') return def const n = Number.parseInt(String(v), 10) return Number.isFinite(n) && n > 0 ? n : def } /** Upper bounds on simulated capture (after streaming multiplier); tunable for high-RAM hosts. */ const absMaxBytes = parse( 'BARE_OS_PIPELINE_ABS_MAX_BYTES', 512 * 1024 * 1024 ) const absMaxLines = parse('BARE_OS_PIPELINE_ABS_MAX_LINES', 2_000_000) const streamOn = o.BARE_OS_SHELL_STREAMING === '1' || o.BARE_OS_SHELL_STREAMING === 'true' const multRaw = Number.parseFloat( String(o.BARE_OS_SHELL_STREAMING_MULT || '4') ) const mult = streamOn && Number.isFinite(multRaw) && multRaw > 1 ? Math.min(multRaw, 16) : 1 const baseBytes = parse( 'BARE_OS_PIPELINE_MAX_BYTES', DEFAULT_PIPELINE_MAX_CAPTURE_BYTES ) const baseLines = parse( 'BARE_OS_PIPELINE_MAX_LINES', DEFAULT_PIPELINE_MAX_CAPTURE_LINES ) const effectiveBytes = Math.floor(baseBytes * mult) const effectiveLines = Math.floor(baseLines * mult) return { maxStages: parse( 'BARE_OS_PIPELINE_MAX_STAGES', DEFAULT_PIPELINE_MAX_STAGES ), maxBytes: Math.min(effectiveBytes, absMaxBytes), maxLines: Math.min(effectiveLines, absMaxLines), streamingMultiplier: mult, /** True when `BARE_OS_SHELL_STREAMING` relaxes caps via multiplier. */ streamingEnabled: streamOn, /** Parsed `BARE_OS_PIPELINE_MAX_*` before multiplier (for operator snapshots). */ baseMaxBytes: baseBytes, baseMaxLines: baseLines, /** Hard ceilings after multiplier (`BARE_OS_PIPELINE_ABS_MAX_*`; defaults 512 MiB / 2 M lines). */ absCapBytes: absMaxBytes, absCapLines: absMaxLines } } /** * Baseline aliases; `~/.barerc` and `unalias -a` merge/reset from this table. * @returns {Record} */ export function defaultShellAliases() { return { nano: 'edit', top: 'baretop', btop: 'baretop', ll: 'ls -la', la: 'ls -A', l: 'ls', '..': 'cd ..', '...': 'cd ../..' } } /** * Expand first argv[0] through alias chain; append original argv.slice(1). * @param {string[]} argv * @param {Record | null | undefined} aliases * @returns {string[]} */ export function expandArgvAliases(argv, aliases) { if (!argv.length) return argv const map = aliases && typeof aliases === 'object' ? aliases : {} const out = [...argv] let depth = 0 while (depth < MAX_ALIAS_DEPTH) { const first = out[0] const repl = map[first] if (repl == null || repl === '') break const words = tokenize(repl) .filter((t) => t.type === 'word') .map((t) => t.value) if (!words.length) break out.splice(0, 1, ...words) depth++ } if (depth >= MAX_ALIAS_DEPTH && map[out[0]]) { throw new Error('alias: expansion nested too deeply') } return out } /** * Strip one layer of matching single/double quotes from alias value. * @param {string} val */ function stripAliasQuotes(val) { const v = val.trim() if (v.length >= 2) { const q = v[0] if ((q === "'" || q === '"') && v[v.length - 1] === q) { return v.slice(1, -1) } } return v } /** * @param {Record} ctx * @param {string} rest content after `alias ` (name=value...) */ export function applyAliasDefinition(ctx, rest) { const eq = rest.indexOf('=') if (eq <= 0) return false const aname = rest.slice(0, eq).trim() if (!aname) return false let val = rest.slice(eq + 1).trim() val = stripAliasQuotes(val) if (!ctx.shellAliases) ctx.shellAliases = {} ctx.shellAliases[aname] = val return true } /** * @param {Record} ctx * @param {string[]} argv argv for unalias builtin (includes 'unalias') */ export function runUnaliasBuiltin(ctx, argv, logError) { if (!ctx.shellAliases) ctx.shellAliases = { ...defaultShellAliases() } const args = argv.slice(1) if (args.length === 0) { logError('unalias: missing name') return } if (args.includes('-a')) { ctx.shellAliases = { ...defaultShellAliases() } return } for (const name of args) { if (name === '-a') continue delete ctx.shellAliases[name] } } /** * Parse one line from ~/.barerc (without leading `unalias`). */ function applyBarercUnalias(ctx, rest) { if (!ctx.shellAliases) ctx.shellAliases = { ...defaultShellAliases() } const parts = rest.split(/\s+/).filter(Boolean) if (parts.length === 1 && parts[0] === '-a') { ctx.shellAliases = { ...defaultShellAliases() } return } for (const name of parts) { if (name === '-a') ctx.shellAliases = { ...defaultShellAliases() } else delete ctx.shellAliases[name] } } /** * Comment-only template written on first login when `~/.barerc` is absent * (`loadBarerc(ctx, { createSkeletonIfMissing: true })`). */ export const BARERC_SKELETON = `# Bare OS — ~/.barerc (not full sh; only export, alias, unalias, theme, # comments). # # export MY_VAR=value # theme default # export BARE_OS_COLOR_DEPTH=truecolor # export BARE_OS_DIRCOLORS=~/.dir_colors # export BARE_OS_LS_COLORS_LOCKED=1 # alias gst='git status' # unalias ll ` /** * Load `~/.barerc`: only `export`, `alias`, `unalias`, `theme`, comments, blank lines. * Resets aliases to defaults first, then applies file. * @param {Record} ctx * @param {{ createSkeletonIfMissing?: boolean }} [opts] If true and the file is missing, write {@link BARERC_SKELETON} (login / unlock only). */ export async function loadBarerc(ctx, opts = {}) { const { createSkeletonIfMissing = false } = opts const strict = globalThis.process?.env?.BARE_OS_STRICT_BARC === '1' ctx.shellAliases = { ...defaultShellAliases() } const vfs = ctx.vfs const env = vfs.env let buf = null try { buf = await vfs.readFile('~/.barerc') } catch { buf = null } let text = null if (!buf && createSkeletonIfMissing) { try { await vfs.writeFile('~/.barerc', ctx.b4a.from(BARERC_SKELETON)) text = BARERC_SKELETON } catch (e) { ctx.console?.error?.( '[bare-os] could not create ~/.barerc: ' + ((e && e.message) || e) ) } } if (!text) { if (!buf) { await applyBareOsThemeFromEnv(ctx) return } text = ctx.b4a.toString(buf) } for (const line of text.split(/\r?\n/)) { const t = line.trim() if (!t || t.startsWith('#')) continue if (t.startsWith('export ')) { const rest = t.slice(7).trim() const eq = rest.indexOf('=') if (eq > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(rest.slice(0, eq))) { env[rest.slice(0, eq)] = expandWord(rest.slice(eq + 1), env) } else if (strict) { ctx.console?.error?.('barerc: ignored: ' + t) } continue } if (t.startsWith('theme ') || t === 'theme') { const name = t === 'theme' ? '' : t.slice(6).trim() if (!name) { if (strict) ctx.console?.error?.('barerc: theme requires a name') continue } if (!/^[a-zA-Z0-9_.-]+$/.test(name)) { if (strict) ctx.console?.error?.('barerc: invalid theme name: ' + name) continue } const norm = name.toLowerCase().replace(/\s+/g, '_') if (!bareOsGetThemePreset(norm)) { ctx.console?.error?.('barerc: unknown theme: ' + name) continue } env.BARE_OS_THEME = norm continue } if (t.startsWith('alias ')) { const ok = applyAliasDefinition(ctx, t.slice(6).trim()) if (!ok && strict) ctx.console?.error?.('barerc: ignored: ' + t) continue } if (t.startsWith('unalias ')) { applyBarercUnalias(ctx, t.slice(8).trim()) continue } if (strict) ctx.console?.error?.('barerc: ignored: ' + t) } await applyBareOsThemeFromEnv(ctx) } /** * @typedef {{ q: 'u' | 's' | 'd', t: string }} ShellWordPart */ /** * @typedef {{ type: 'word', value: string, parts: ShellWordPart[] } | { type: 'op', value: string }} Token */ /** @param {Token} t */ export function shellWordText(t) { return t && t.type === 'word' ? t.value : '' } /** @param {string} line */ export function tokenize(line) { return /** @type {Token[]} */ (lexShellLine(line)) } function collapseShellLineContinuations(s) { return String(s || '').replace(/\\\r?\n/g, '') } /** * @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 } /** * @typedef {{ * argv: Extract[], * assign: Record, * redirIn: Extract | null, * redirOut: Extract | null, * redirAppend: boolean, * redirErr: Extract | null, * redirErrAppend: boolean, * mergeStderrToStdout: boolean, * redirHereDoc: string | null * }} SimpleCmd */ /** * @param {Token[]} tokens * @returns {SimpleCmd[][]} */ export function parsePipeline(tokens) { /** @type {Token[][]} */ const pipes = [[]] for (const t of tokens) { if (t.type === 'op' && t.value === '|') { pipes.push([]) } else { pipes[pipes.length - 1].push(t) } } return pipes.map((seg) => parseSimpleCommand(seg)) } /** * Structured syntax error that callers can route differently from expansion/runtime failures. * @param {string} message * @param {{ index?: number, phase?: 'tokenize' | 'parse' | 'expand' | 'runtime' }} [meta] */ export function bareOsShellError(message, meta = {}) { const e = new Error(String(message || 'shell error')) e.code = 'BARE_OS_SHELL_ERROR' e.shellPhase = meta.phase || 'runtime' if (Number.isFinite(meta.index)) e.shellIndex = Number(meta.index) return e } /** * Snapshot-friendly parse artifact for deterministic grammar tests. * @param {string} line * @returns {{ * schema: 1, * line: string, * diagnosticTokens: ReturnType, * tokens: ReturnType, * pipeline: ReturnType * }} */ export function bareOsShellAstSnapshot(line) { const src = String(line || '') return { schema: 1, line: src, diagnosticTokens: tokenizeBareShellLineDetailed(src), tokens: tokenize(src), pipeline: parsePipeline(tokenize(src)) } } /** @param {Token[]} seg */ function parseSimpleCommand(seg) { /** @type {Record} */ const assign = {} /** @type {Extract | null} */ let redirIn = null /** @type {Extract | null} */ let redirOut = null let redirAppend = false /** @type {Extract | null} */ let redirErr = null let redirErrAppend = false let mergeStderrToStdout = false /** @type {string | null} */ let redirHereDoc = null /** @type {Extract[]} */ const argvWords = [] let seenCommand = false let w = 0 while (w < seg.length) { const t = seg[w] if (t.type === 'op') { if (t.value === '2>' || t.value === '2>>') { const n = seg[w + 1] if (n && n.type === 'word') { redirErrAppend = t.value === '2>>' redirErr = n w += 2 continue } } if (t.value === '2>&1') { mergeStderrToStdout = true w++ continue } if (t.value === '>' || t.value === '>>') { const n = seg[w + 1] if (n && n.type === 'op' && n.value === '(') { throw bareOsShellError( 'shell: process substitution >(…) is unsupported', { phase: 'parse' } ) } if (n && n.type === 'word') { redirAppend = t.value === '>>' redirOut = n w += 2 continue } } if (t.value === '<') { const n = seg[w + 1] if (n && n.type === 'op' && n.value === '(') { throw bareOsShellError( 'shell: process substitution <(…) is unsupported', { phase: 'parse' } ) } if (n && n.type === 'word') { redirIn = n w += 2 continue } } if (t.value === '<<' || t.value === '<<-') { const n = seg[w + 1] if (n && n.type === 'word') { w += 2 continue } } if (t.value === '<<<') { const n = seg[w + 1] if (n && n.type === 'word') { redirHereDoc = n.value w += 2 continue } } w++ continue } const v = t.value if (!seenCommand) { const eq = v.indexOf('=') if (eq > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(v.slice(0, eq))) { assign[v.slice(0, eq)] = v.slice(eq + 1) w++ continue } } seenCommand = true argvWords.push(/** @type {Extract} */ (t)) w++ } let i = 0 while (i < argvWords.length) { const wt = (k) => shellWordText(argvWords[i + k]) if ( wt(0) === '2' && wt(1) === '>' && wt(2) === '&' && wt(3) === '1' ) { mergeStderrToStdout = true argvWords.splice(i, 4) continue } if (wt(0) === '2' && wt(1) === '>') { redirErr = argvWords[i + 2] ?? null redirErrAppend = false argvWords.splice(i, 3) continue } if (wt(0) === '2' && wt(1) === '>>') { redirErr = argvWords[i + 2] ?? null redirErrAppend = true argvWords.splice(i, 3) continue } if (wt(0) === '>') { redirOut = argvWords[i + 1] ?? null redirAppend = false argvWords.splice(i, 2) continue } if (wt(0) === '>>') { redirOut = argvWords[i + 1] ?? null redirAppend = true argvWords.splice(i, 2) continue } if (wt(0) === '<') { redirIn = argvWords[i + 1] ?? null argvWords.splice(i, 2) continue } if (wt(0) === '<<<') { redirHereDoc = wt(1) || '' argvWords.splice(i, 2) continue } if (wt(0) === '<<' || wt(0) === '<<-') { argvWords.splice(i, 2) continue } i++ } return { argv: argvWords, assign, redirIn, redirOut, redirAppend, redirErr, redirErrAppend, mergeStderrToStdout, redirHereDoc } } /** * Split token list on `;` into separate commands (AND-OR lists). * @param {Token[]} tokens * @returns {Token[][]} */ export function splitTokensBySemicolon(tokens) { /** @type {Token[][]} */ const lists = [] /** @type {Token[]} */ let cur = [] let kwDepth = 0 let parenDepth = 0 let braceDepth = 0 for (const t of tokens) { if (t.type === 'word') { if ( t.value === 'if' || t.value === 'while' || t.value === 'until' || t.value === 'for' || t.value === 'select' || t.value === 'case' ) kwDepth++ else if ( t.value === 'fi' || t.value === 'done' || t.value === 'esac' ) kwDepth = Math.max(0, kwDepth - 1) } else if (t.type === 'op') { if (t.value === '(') parenDepth++ else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1) else if (t.value === '{') braceDepth++ else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1) } if ( t.type === 'op' && t.value === ';' && kwDepth === 0 && parenDepth === 0 && braceDepth === 0 ) { lists.push(cur) cur = [] } else { cur.push(t) } } lists.push(cur) return lists } /** * Split one semicolon-separated list on `&&` / `||` (left-associative chain). * @param {Token[]} tokens * @returns {{ segments: Token[][], ops: string[] }} */ export function splitTokensByAndOr(tokens) { /** @type {Token[][]} */ const segments = [] /** @type {string[]} */ const ops = [] /** @type {Token[]} */ let cur = [] let kwDepth = 0 let parenDepth = 0 let braceDepth = 0 for (const t of tokens) { if (t.type === 'word') { if ( t.value === 'if' || t.value === 'while' || t.value === 'until' || t.value === 'for' || t.value === 'select' || t.value === 'case' ) kwDepth++ else if ( t.value === 'fi' || t.value === 'done' || t.value === 'esac' ) kwDepth = Math.max(0, kwDepth - 1) } else if (t.type === 'op') { if (t.value === '(') parenDepth++ else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1) else if (t.value === '{') braceDepth++ else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1) } if ( t.type === 'op' && (t.value === '&&' || t.value === '||') && kwDepth === 0 && parenDepth === 0 && braceDepth === 0 ) { segments.push(cur) ops.push(t.value) cur = [] } else { cur.push(t) } } segments.push(cur) return { segments, ops } } /** @param {Token[]} seg */ function segmentHasCommand(seg) { if (!seg.length) return false try { const cmd = parseSimpleCommand(seg) return cmd.argv.length > 0 || Object.keys(cmd.assign).length > 0 } catch (e) { if (e && /** @type {{ code?: string }} */ (e).code === 'BARE_OS_SHELL_ERROR') return true throw e } } /** * @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 {Extract} wordTok * @param {Record} env * @param {{ redirect?: boolean, disablePathnameExpansion?: boolean }} [globOpts] * @returns {Promise} */ 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 } /** * Normalized redirection plan independent from execution side-effects. * @param {SimpleCmd} cmd */ export function planShellRedirections(cmd) { return { stdin: cmd.redirHereDoc != null ? 'heredoc' : cmd.redirIn ? 'file' : 'inherit', stdout: cmd.redirOut ? (cmd.redirAppend ? 'append' : 'truncate') : 'inherit', stderr: cmd.mergeStderrToStdout ? 'stdout' : cmd.redirErr ? cmd.redirErrAppend ? 'append' : 'truncate' : 'inherit' } } /** * Execution graph (lists -> and/or -> pipelines) for debugging and tests. * @param {string} line */ export function buildShellExecutionGraph(line) { const tokens = tokenize(String(line || '')) const lists = splitTokensBySemicolon(tokens) const graph = { schema: 1, line: String(line || ''), listCount: lists.length, lists: [] } for (const list of lists) { const { segments, ops } = splitTokensByAndOr(list) const entry = { andOrOps: ops.slice(), segments: [] } for (const seg of segments) { let pipe try { pipe = parsePipeline(seg) } catch (e) { const code = e && /** @type {{ code?: string }} */ (e).code entry.segments.push({ parseError: code === 'BARE_OS_SHELL_ERROR' ? (e && /** @type {Error} */ (e).message) || String(e) : String((e && /** @type {Error} */ (e).message) || e) }) continue } entry.segments.push({ pipelineLength: pipe.length, commands: pipe.map((cmd) => ({ argv: cmd.argv.map((w) => shellWordText(w)), redirections: planShellRedirections(cmd) })) }) } graph.lists.push(entry) } return graph } /** * @param {string} signal */ function normalizeShellSignalName(signal) { return String(signal || '') .trim() .replace(/^SIG/i, '') .toUpperCase() } /** * Execute a registered trap handler for a signal, when present. * @param {Record} ctx * @param {string} signal * @returns {Promise} true when a trap ran */ export async function dispatchShellTrapSignal(ctx, signal) { const sig = normalizeShellSignalName(signal) if (!sig) return false const handlers = ctx.shellTrapHandlers && typeof ctx.shellTrapHandlers === 'object' ? /** @type {Record} */ (ctx.shellTrapHandlers) : null if (!handlers || !handlers[sig]) return false const cmd = String(handlers[sig] || '').trim() if (!cmd) return false await execShellLine(ctx, cmd) return true } /** * Shallow clone for pipeline **`/bin`** execution: session **`env`**, optional **`shellStdin`**, and * **`bareOsStdoutCaptured`** when stdout is captured (pipe to next stage or **`>`** redirect) so * utilities (e.g. **`ls`**) can use one-record-per-line output. * @param {Record} ctx * @param {Record} env * @param {string | null} stdinText * @param {boolean} bareOsStdoutCaptured */ function bareOsPipelineChildCtx(ctx, env, stdinText, bareOsStdoutCaptured) { const o = Object.assign({}, ctx, { env: env && typeof env === 'object' ? { ...env } : env, bareOsStdoutCaptured }) if (stdinText != null) o.shellStdin = stdinText return o } /** * Convert raw binary chunks into shell pipeline capture text. * Pipeline transport is text-based, so bytes are mapped 1:1 via char codes. * @param {string | Uint8Array} chunk */ function bareOsPipelineRawChunkToText(chunk) { if (typeof chunk === 'string') return chunk if (!(chunk instanceof Uint8Array)) return String(chunk) let s = '' for (let i = 0; i < chunk.length; i++) s += String.fromCharCode(chunk[i]) return s } /** * Optional timeout guard for potentially stalled pipeline stages. * @param {Record} env * @param {() => Promise} run * @param {string} label */ async function runWithShellPipelineStageTimeout(env, run, label) { const raw = Number.parseInt(String(env.BARE_OS_SHELL_PIPELINE_STAGE_TIMEOUT_MS || ''), 10) const timeoutMs = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 120000) : 0 if (!timeoutMs) return await run() /** @type {ReturnType | null} */ let timer = null try { return await Promise.race([ run(), new Promise((_, reject) => { timer = setTimeout(() => { reject( new Error( `shell: pipeline stage timeout (${timeoutMs}ms): ${label}` ) ) }, timeoutMs) }) ]) } finally { if (timer) clearTimeout(timer) } } /** * @param {Record} ctx * @param {SimpleCmd[]} pipeline * @returns {Promise<'exit' | 'ok'>} */ async function execParsedPipeline(ctx, pipeline) { const vfs = ctx.vfs const env = vfs.env const lim = getBareOsPipelineLimits(env) if (pipeline.length > lim.maxStages) { ctx.console.error( `shell: pipeline exceeds BARE_OS_PIPELINE_MAX_STAGES (${lim.maxStages})` ) ctx.exitCode = 1 return 'ok' } try { bareOsKernelMetricSet('shell.pipeline_last_stages', pipeline.length) } catch { /* ignore */ } let stdinText = typeof ctx.shellStdin === 'string' ? ctx.shellStdin : null /** Last completed pipeline stage exit (POSIX default: status of last stage; optional pipefail). */ let pipelineLastExit = 0 /** True when **`BARE_OS_SHELL_PIPEFAIL`** is already set or set by a stage prefix in this pipeline. */ let pipelineWantsPipefail = env.BARE_OS_SHELL_PIPEFAIL === '1' || env.BARE_OS_SHELL_PIPEFAIL === 'true' /** First non-zero stage exit when pipefail is active (handbook: first failing stage wins). */ let pipefailFirstNonZero = 0 /** Stage exit codes for optional `BARE_OS_PIPESTATUS` (space-separated). */ const pipelineStageExits = [] const recordStageExit = (code) => { const c = Number(code) || 0 pipelineStageExits.push(c) pipelineLastExit = c if (pipelineWantsPipefail && pipefailFirstNonZero === 0 && c !== 0) { pipefailFirstNonZero = c } } try { for (let pi = 0; pi < pipeline.length; pi++) { const cmd = pipeline[pi] const isLast = pi === pipeline.length - 1 const origLog = ctx.console.log const origErr = ctx.console.error if (!cmd.argv.length && !Object.keys(cmd.assign).length) continue if (!ctx.shellAliases) ctx.shellAliases = { ...defaultShellAliases() } /** @type {string[]} */ let argv = [] for (let wi = 0; wi < cmd.argv.length; wi++) { const wTok = cmd.argv[wi] const exprMulCompat = wi > 0 && argv[0] === 'expr' && wTok.value === '*' && Array.isArray(wTok.parts) && wTok.parts.length === 1 && wTok.parts[0].q === 'u' && wTok.parts[0].t === '*' const xs = await expandShellWordTokens(ctx, wTok, env, { disablePathnameExpansion: exprMulCompat }) if ( xs.length === 0 && (env.BARE_OS_STRICT_POSIX === '1' || env.BARE_OS_STRICT_POSIX === 'true') ) { origErr.call( ctx.console, 'shell: pathname expansion produced no matches (strict POSIX)' ) ctx.exitCode = 1 argv = [] break } for (const x of xs) argv.push(x) } if (argv.length === 0 && cmd.argv.length) { continue } try { argv = expandArgvAliases(argv, ctx.shellAliases) } catch (e) { ctx.console.error((e && e.message) || String(e)) ctx.exitCode = 1 continue } const name = argv[0] appendShellAuditEvent(ctx, 'shell.command.start', { command: name, argv: argv.slice(0, 16), stage: pi }) if (shellCommandDeniedByPolicy(name, env)) { origErr.call(ctx.console, 'shell: command denied by policy: ' + name) ctx.exitCode = 126 recordStageExit(ctx.exitCode) appendShellAuditEvent(ctx, 'shell.command.error', { command: name, stage: pi, reason: 'policy_deny' }) continue } const sandboxOn = env.BARE_OS_SHELL_SANDBOX === '1' || env.BARE_OS_SHELL_SANDBOX === 'true' if (sandboxOn && !isShellBuiltin(name, env) && name !== 'command' && name !== 'type') { origErr.call(ctx.console, 'shell: sandbox blocks external command: ' + name) ctx.exitCode = 126 recordStageExit(ctx.exitCode) appendShellAuditEvent(ctx, 'shell.command.error', { command: name, stage: pi, reason: 'sandbox_block' }) continue } /** @type {string | null} */ let resolvedRedirOut = null /** @type {string | null} */ let resolvedRedirErr = null if (cmd.redirOut) { const ps = await expandShellWordTokens(ctx, cmd.redirOut, env, { redirect: true }) if (ps.length === 0) { origErr.call(ctx.console, 'shell: stdout redirect: no match') ctx.exitCode = 1 continue } if (ps.length > 1) { origErr.call(ctx.console, 'shell: stdout redirect: ambiguous') ctx.exitCode = 1 continue } resolvedRedirOut = ps[0] if (shellUnsafeRedirectPath(resolvedRedirOut, env)) { origErr.call(ctx.console, 'shell: unsafe stdout redirect path denied') ctx.exitCode = 1 recordStageExit(ctx.exitCode) appendShellAuditEvent(ctx, 'shell.command.error', { command: name, stage: pi, reason: 'unsafe_redirect_stdout', path: resolvedRedirOut }) continue } } if (cmd.redirErr && !cmd.mergeStderrToStdout) { const ps = await expandShellWordTokens(ctx, cmd.redirErr, env, { redirect: true }) if (ps.length === 0) { origErr.call(ctx.console, 'shell: stderr redirect: no match') ctx.exitCode = 1 continue } if (ps.length > 1) { origErr.call(ctx.console, 'shell: stderr redirect: ambiguous') ctx.exitCode = 1 continue } resolvedRedirErr = ps[0] if (shellUnsafeRedirectPath(resolvedRedirErr, env)) { origErr.call(ctx.console, 'shell: unsafe stderr redirect path denied') ctx.exitCode = 1 recordStageExit(ctx.exitCode) appendShellAuditEvent(ctx, 'shell.command.error', { command: name, stage: pi, reason: 'unsafe_redirect_stderr', path: resolvedRedirErr }) continue } } if (cmd.redirHereDoc) { stdinText = expandWord(cmd.redirHereDoc, env) } else if (cmd.redirIn) { const paths = await expandShellWordTokens(ctx, cmd.redirIn, env, { redirect: true }) if (paths.length === 0) { origErr.call(ctx.console, 'shell: stdin redirect: no match') ctx.exitCode = 1 continue } if (paths.length > 1) { origErr.call(ctx.console, 'shell: stdin redirect: ambiguous') ctx.exitCode = 1 continue } if (shellUnsafeRedirectPath(paths[0], env)) { origErr.call(ctx.console, 'shell: unsafe stdin redirect path denied') ctx.exitCode = 1 recordStageExit(ctx.exitCode) appendShellAuditEvent(ctx, 'shell.command.error', { command: name, stage: pi, reason: 'unsafe_redirect_stdin', path: paths[0] }) continue } const buf = await vfs.readFile(paths[0]) stdinText = buf ? ctx.b4a.toString(buf) : '' } else if (pi === 0 && typeof ctx.shellHeredocOnce === 'string') { stdinText = ctx.shellHeredocOnce delete ctx.shellHeredocOnce } const outChunks = [] const errChunks = [] const capOut = !isLast || cmd.redirOut != null const mergeErr = cmd.mergeStderrToStdout const capErrSeparate = cmd.redirErr != null && !mergeErr const checkCaptureSize = (chunks) => { const joined = chunks.join('') if (joined.length > lim.maxBytes) { throw new Error( `shell: pipeline output exceeds BARE_OS_PIPELINE_MAX_BYTES (${lim.maxBytes})` ) } const lineCount = joined.split('\n').length - 1 if (lineCount > lim.maxLines) { throw new Error( `shell: pipeline output exceeds BARE_OS_PIPELINE_MAX_LINES (${lim.maxLines})` ) } } const pushOut = (...args) => { const line = args.map(String).join(' ') + '\n' outChunks.push(line) const st = ctx.bareOsSessionStats if (st && typeof st.pipelineBytesTotal === 'number') { st.pipelineBytesTotal += line.length } checkCaptureSize(outChunks) } /** @param {string | Uint8Array} chunk */ const pushOutRaw = (chunk) => { const text = bareOsPipelineRawChunkToText(chunk) outChunks.push(text) const st = ctx.bareOsSessionStats if (st && typeof st.pipelineBytesTotal === 'number') { st.pipelineBytesTotal += text.length } checkCaptureSize(outChunks) } const pushErr = (...args) => { const line = args.map(String).join(' ') + '\n' errChunks.push(line) const st = ctx.bareOsSessionStats if (st && typeof st.pipelineBytesTotal === 'number') { st.pipelineBytesTotal += line.length } checkCaptureSize(errChunks) } for (const [k, val] of Object.entries(cmd.assign)) { if ( ctx.shellReadonlyVars instanceof Set && ctx.shellReadonlyVars.has(k) ) { origErr.call(ctx.console, k + ': readonly variable') ctx.exitCode = 1 continue } env[k] = expandWord(val, env) } if ( env.BARE_OS_SHELL_PIPEFAIL === '1' || env.BARE_OS_SHELL_PIPEFAIL === 'true' ) { pipelineWantsPipefail = true } if (!cmd.argv.length) { ctx.exitCode = 0 continue } ctx.exitCode = 0 if (capOut) { ctx.console.log = pushOut if (mergeErr) ctx.console.error = pushOut else if (capErrSeparate) ctx.console.error = pushErr else ctx.console.error = origErr } else { ctx.console.log = origLog if (capErrSeparate) ctx.console.error = pushErr else if (mergeErr) ctx.console.error = origLog else ctx.console.error = origErr } let code = 'ok' try { if ( ctx.shellFunctions && typeof ctx.shellFunctions === 'object' && Object.prototype.hasOwnProperty.call(ctx.shellFunctions, name) ) { const fn = ctx.shellFunctions[name] const maxDepth = Number.parseInt( String(env.BARE_OS_SHELL_FUNCTION_MAX_DEPTH || '32'), 10 ) const cap = Number.isFinite(maxDepth) && maxDepth > 0 ? Math.min(maxDepth, 128) : 32 const curDepth = typeof ctx.shellFunctionDepth === 'number' ? ctx.shellFunctionDepth : 0 if (curDepth >= cap) { origErr.call( ctx.console, `shell: function recursion too deep (max ${cap})` ) ctx.exitCode = 1 continue } const prevEnv = vfs.env const fnEnv = env const prevPositional = {} for (const k of ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '#']) { prevPositional[k] = fnEnv[k] } assignShellFunctionPositionalEnv(fnEnv, argv) vfs.env = fnEnv ctx.env = fnEnv ctx.shellFunctionDepth = curDepth + 1 try { const fnResult = await runWithShellPipelineStageTimeout( env, () => execSemicolonLists(ctx, fn.body), `function ${name}` ) if (fnResult === 'exit') code = 'exit' } finally { for (const [k, v] of Object.entries(prevPositional)) { if (v == null) delete fnEnv[k] else fnEnv[k] = v } ctx.shellFunctionDepth = curDepth vfs.env = prevEnv ctx.env = prevEnv } continue } if (isShellBuiltin(name, env) && isExecLineBuiltinDenied(name, env)) { origErr.call( ctx.console, 'shell: builtin denied by boot policy: ' + name ) ctx.exitCode = 126 continue } if (name === 'alias') { if (argv.length === 1) { const al = ctx.shellAliases || {} for (const k of Object.keys(al).sort()) { origLog.call(ctx.console, `${k}='${al[k]}'`) } } else { let okCount = 0 for (const part of argv.slice(1)) { if (applyAliasDefinition(ctx, part)) okCount++ } if (okCount === 0) { origErr.call( ctx.console, 'alias: usage: alias name=value [name=value ...]' ) ctx.exitCode = 1 } } } else if (name === 'unalias') { runUnaliasBuiltin(ctx, argv, (m) => { origErr.call(ctx.console, m) ctx.exitCode = 1 }) } else if (name === 'barerc') { const sub = argv[1] if (sub === 'reload') { await loadBarerc(ctx, { createSkeletonIfMissing: false }) } else { origErr.call(ctx.console, 'barerc: usage: barerc reload') ctx.exitCode = 1 } } else if (name === 'cd') { try { await vfs.chdir(argv[1] || vfs.home) } catch (e) { origErr.call(ctx.console, (e && e.message) || String(e)) ctx.exitCode = 1 } } else if (name === 'export') { const args = argv.slice(1) if (args.length === 1 && args[0] === '-p') { const keys = Object.keys(env).sort((a, b) => a.localeCompare(b)) for (const k of keys) { const v = String(env[k] ?? '') const q = "'" + v.replace(/'/g, "'\\''") + "'" origLog.call(ctx.console, 'export ' + k + '=' + q) } } else { let sawErr = false for (const a of args) { if (a === '-p') { origErr.call( ctx.console, 'export: -p must be the only argument' ) ctx.exitCode = 2 sawErr = true break } if (a.startsWith('-')) { origErr.call(ctx.console, 'export: unsupported option: ' + a) ctx.exitCode = 2 sawErr = true break } const eq = a.indexOf('=') if (eq > 0) { const k = a.slice(0, eq) if (!isValidShellIdentifier(k)) { origErr.call(ctx.console, `export: not an identifier: ${k}`) ctx.exitCode = 1 sawErr = true continue } if ( ctx.shellReadonlyVars instanceof Set && ctx.shellReadonlyVars.has(k) ) { origErr.call(ctx.console, k + ': readonly variable') ctx.exitCode = 1 sawErr = true continue } env[k] = expandWord(a.slice(eq + 1), env) } else { if (!isValidShellIdentifier(a)) { origErr.call(ctx.console, `export: not an identifier: ${a}`) ctx.exitCode = 1 sawErr = true continue } if (!Object.prototype.hasOwnProperty.call(env, a)) env[a] = '' } } if (!sawErr) ctx.exitCode = 0 } } else if (name === 'unset') { if (!ctx.shellReadonlyVars) ctx.shellReadonlyVars = new Set() for (const a of argv.slice(1)) { if (a.startsWith('-')) continue if (ctx.shellReadonlyVars.has(a)) { origErr.call( ctx.console, 'unset: ' + a + ': cannot unset: readonly variable' ) ctx.exitCode = 1 continue } delete env[a] } } else if (name === 'readonly') { if (!ctx.shellReadonlyVars) ctx.shellReadonlyVars = new Set() const args = argv.slice(1) if (args.length === 1 && args[0] === '-p') { const keys = [...ctx.shellReadonlyVars].sort((a, b) => a.localeCompare(b) ) for (const k of keys) { const v = String(env[k] ?? '') const q = "'" + v.replace(/'/g, "'\\''") + "'" origLog.call(ctx.console, 'readonly ' + k + '=' + q) } } else { for (const a of args) { if (a === '-p') { origErr.call( ctx.console, 'readonly: -p must be the only argument' ) ctx.exitCode = 2 break } if (a.startsWith('-')) { origErr.call(ctx.console, 'readonly: unsupported option: ' + a) ctx.exitCode = 2 break } const eq = a.indexOf('=') if (eq > 0) { const k = a.slice(0, eq) env[k] = expandWord(a.slice(eq + 1), env) ctx.shellReadonlyVars.add(k) } else ctx.shellReadonlyVars.add(a) } } } else if (name === 'umask') { if (argv[1] != null) { const oct = argv[1] if (!/^[0-7]{1,4}$/.test(oct)) { origErr.call(ctx.console, 'umask: invalid octal mask') ctx.exitCode = 1 } else { env.UMASK = oct } } else { const u = env.UMASK || '022' origLog.call(ctx.console, String(u).padStart(4, '0')) } } else if (name === 'set') { const args = argv.slice(1) if ( args.length === 1 && (args[0] === '-o' || args[0] === '+o') ) { const on = (v) => v === '1' || v === 'true' ? 'on' : 'off' origLog.call( ctx.console, `errexit ${on(env.BARE_OS_SHELL_ERREXIT)}` ) origLog.call( ctx.console, `nounset ${on(env.BARE_OS_SHELL_NOUNSET)}` ) origLog.call( ctx.console, `pipefail ${on(env.BARE_OS_SHELL_PIPEFAIL)}` ) origLog.call( ctx.console, `noglob ${on(env.BARE_OS_SHELL_NOGLOB)}` ) } else if (args.length === 1 && args[0] === '-f') { env.BARE_OS_SHELL_NOGLOB = '1' } else if (args.length === 1 && args[0] === '+f') { delete env.BARE_OS_SHELL_NOGLOB } else if ( args.length === 2 && args[0] === '-o' && args[1] === 'errexit' ) { env.BARE_OS_SHELL_ERREXIT = '1' } else if ( args.length === 2 && args[0] === '+o' && args[1] === 'errexit' ) { delete env.BARE_OS_SHELL_ERREXIT } else if (args.length === 1 && args[0] === '-e') { env.BARE_OS_SHELL_ERREXIT = '1' } else if (args.length === 1 && args[0] === '+e') { delete env.BARE_OS_SHELL_ERREXIT } else if ( args.length === 2 && args[0] === '-o' && args[1] === 'nounset' ) { env.BARE_OS_SHELL_NOUNSET = '1' } else if ( args.length === 2 && args[0] === '+o' && args[1] === 'nounset' ) { delete env.BARE_OS_SHELL_NOUNSET } else if (args.length === 1 && args[0] === '-u') { env.BARE_OS_SHELL_NOUNSET = '1' } else if (args.length === 1 && args[0] === '+u') { delete env.BARE_OS_SHELL_NOUNSET } else if ( args.length === 2 && args[0] === '-o' && args[1] === 'pipefail' ) { env.BARE_OS_SHELL_PIPEFAIL = '1' } else if ( args.length === 2 && args[0] === '+o' && args[1] === 'pipefail' ) { delete env.BARE_OS_SHELL_PIPEFAIL } else { origErr.call( ctx.console, 'set: unsupported arguments (only -f / +f / -e / +e / -u / +u / -o errexit|nounset|pipefail / +o errexit|nounset|pipefail)' ) ctx.exitCode = 1 } } else if (name === ':') { /* no-op */ } else if (name === 'command') { const cargs = argv.slice(1) if (cargs.length === 0) { origErr.call(ctx.console, 'command: missing operand') ctx.exitCode = 1 } else if (cargs[0] === '-v' || cargs[0] === '-V') { const cmdn = cargs[1] if (!cmdn) { origErr.call(ctx.console, 'command: missing operand') ctx.exitCode = 1 } else if (isShellBuiltin(cmdn, env)) { origLog.call(ctx.console, cmdn) } else { const p = await resolveBinInPath(ctx, cmdn) if (p) origLog.call(ctx.console, p) else { origErr.call(ctx.console, 'command: ' + cmdn + ': not found') ctx.exitCode = 1 } } } else { const childCtx = bareOsPipelineChildCtx(ctx, env, stdinText, capOut) if (capOut) childCtx.bareOsBinWrite = pushOutRaw await runWithShellPipelineStageTimeout( env, () => runBinCommand(childCtx, cargs), cargs[0] || 'command' ) mergePipelineChildCtx(ctx, childCtx) } } else if (name === 'type') { const cmdn = argv[1] if (!cmdn) { origErr.call(ctx.console, 'type: missing operand') ctx.exitCode = 1 } else if (isShellBuiltin(cmdn, env)) { origLog.call(ctx.console, cmdn + ' is a shell builtin') } else { const p = await resolveBinInPath(ctx, cmdn) if (p) origLog.call(ctx.console, cmdn + ' is ' + p) else { origErr.call(ctx.console, 'type: ' + cmdn + ': not found') ctx.exitCode = 1 } } } else if (name === 'logout') { const save = argv.includes('--save') if (typeof ctx.applyLogout === 'function') { try { await ctx.applyLogout({ save }) } catch (e) { origErr.call(ctx.console, (e && e.message) || String(e)) ctx.exitCode = 1 } } else { origErr.call( ctx.console, 'logout: not supported in this environment' ) ctx.exitCode = 1 } } else if (name === 'jobs') { const list = ctx.shellBackgroundJobs?.list || [] const ja = argv.slice(1) let showPgidOnly = false let longFmt = false for (const a of ja) { if (a === '-p') showPgidOnly = true else if (a === '-l') longFmt = true } if (!list.length) { origLog.call(ctx.console, '') } else if (showPgidOnly) { for (const j of list) { if (typeof j.pgid === 'number') origLog.call(ctx.console, String(j.pgid)) } } else { for (const j of list) { let st = 'Running' if (j.done) st = 'Done' else if (j.stopped) st = 'Stopped' const pg = typeof j.pgid === 'number' && typeof j.sid === 'number' ? ` sid=${j.sid} pgid=${j.pgid}` : '' const syn = longFmt && typeof j.id === 'number' ? ` pid=${4100 + j.id}` : '' origLog.call( ctx.console, `[${j.id}]+ ${st}${pg}${syn} ${j.label}` ) } } } else if (name === 'fg') { const list = ctx.shellBackgroundJobs?.list || [] let candidates = list.filter((j) => !j.done) const arg = argv[1] if (arg) { const raw = arg.startsWith('%') ? arg.slice(1) : arg const id = Number.parseInt(raw, 10) if (Number.isFinite(id)) { candidates = list.filter((j) => j.id === id) } } const j = candidates.length ? candidates[candidates.length - 1] : null if (!j) { origErr.call(ctx.console, 'fg: no such job') ctx.exitCode = 1 } else { if (ctx.shellSessionState && typeof j.pgid === 'number') { ctx.shellSessionState.foregroundPgid = j.pgid } if (j.stopped) j.stopped = false try { await j.promise } catch { /* background errors already logged */ } if (ctx.shellSessionState) { ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid ?? 1 } ctx.exitCode = 0 } } else if (name === 'bg') { const list = ctx.shellBackgroundJobs?.list || [] const stopped = list.filter((j) => j && !j.done && j.stopped) if (!stopped.length) { origErr.call(ctx.console, 'bg: no stopped jobs') ctx.exitCode = 1 } else { let targets = stopped const arg = argv[1] if (arg) { const raw = arg.startsWith('%') ? arg.slice(1) : arg const jid = Number.parseInt(raw, 10) if (Number.isFinite(jid)) { targets = stopped.filter((j) => j.id === jid) } } if (!targets.length) { origErr.call(ctx.console, 'bg: no stopped jobs') ctx.exitCode = 1 } else { for (const j of targets) j.stopped = false ctx.exitCode = 0 } } } else if (name === 'suspend-job') { const list = ctx.shellBackgroundJobs?.list || [] let candidates = list.filter((j) => j && !j.done && !j.stopped) const arg = argv[1] if (arg) { const raw = arg.startsWith('%') ? arg.slice(1) : arg const jid = Number.parseInt(raw, 10) if (Number.isFinite(jid)) { candidates = list.filter((j) => j && !j.done && j.id === jid) } } const j = candidates.length ? candidates[candidates.length - 1] : null if (!j) { origErr.call(ctx.console, 'suspend-job: no such job') ctx.exitCode = 1 } else { j.stopped = true ctx.exitCode = 0 } } else if (name === 'disown') { if (!ctx.shellBackgroundJobs || !Array.isArray(ctx.shellBackgroundJobs.list)) { origErr.call(ctx.console, 'disown: no job control') ctx.exitCode = 1 } else { const list = ctx.shellBackgroundJobs.list const arg = argv[1] /** @type {typeof list[number][]} */ let targets = [] if (!arg) { const running = list.filter((j) => j && !j.done) const j = running.length ? running[running.length - 1] : null if (j) targets = [j] } else { const raw = arg.startsWith('%') ? arg.slice(1) : arg const jid = Number.parseInt(raw, 10) if (Number.isFinite(jid)) { targets = list.filter((x) => x && x.id === jid) } } if (!targets.length) { origErr.call(ctx.console, 'disown: no such job') ctx.exitCode = 1 } else { for (const t of targets) { const idx = list.indexOf(t) if (idx >= 0) list.splice(idx, 1) } ctx.exitCode = 0 } } } else if (name === 'trap') { if (argv[1] === '-l' || argv[1] === '--list') { ctx.console.log('HUP INT KILL TERM PIPE CHLD USR1 USR2 EXIT') } else if (argv[1] === '-p') { const h = ctx.shellTrapHandlers && typeof ctx.shellTrapHandlers === 'object' ? ctx.shellTrapHandlers : {} for (const k of Object.keys(h)) { ctx.console.log( `trap -- '${String( /** @type {Record} */ (h)[k] ).replace(/'/g, `'\\''`)}' ${k}` ) } } else if (argv.length < 3) { origErr.call(ctx.console, 'trap: usage: trap COMMAND SIGNAL') ctx.exitCode = 1 } else { if (!ctx.shellTrapHandlers || typeof ctx.shellTrapHandlers !== 'object') ctx.shellTrapHandlers = Object.create(null) const cmd = argv[1] const sig = normalizeShellSignalName(argv[2] || '') if (cmd === '-' || cmd === '') { delete /** @type {Record} */ (ctx.shellTrapHandlers)[ sig ] } else { /** @type {Record} */ (ctx.shellTrapHandlers)[sig] = cmd } } } else if (name === 'wait') { const list = ctx.shellBackgroundJobs?.list || [] const shEnv = ctx.env || {} const posixShellMode = shEnv.BARE_OS_SHELL_POSIX_MODE === '1' || shEnv.BARE_OS_SHELL_POSIX_MODE === 'true' let jobArg = argv[1] let waitAny = false if (posixShellMode && jobArg === '-n') { waitAny = true jobArg = argv[2] } const syntheticPidToJobId = (pidText) => { const n = Number.parseInt(String(pidText || ''), 10) if (!Number.isFinite(n)) return null return n >= 4101 ? n - 4100 : null } /** @type {{ id: number, promise: Promise, done?: boolean }[]} */ let target = list.filter((j) => j && !j.done) if (waitAny && !jobArg) { if (!target.length) { ctx.exitCode = 0 } else { try { const doneJob = await Promise.race( target.map((j) => j.promise.then(() => j)) ) const ec = doneJob && typeof doneJob === 'object' && typeof doneJob.lastExitCode === 'number' ? doneJob.lastExitCode : 0 ctx.exitCode = ec } catch { ctx.exitCode = 1 } } } else { if (jobArg) { const argText = String(jobArg) if (argText === 'all') { target = list.filter((j) => j) } else { const raw = argText.startsWith('%') ? argText.slice(1) : argText const id = Number.parseInt(raw, 10) const pidMapped = syntheticPidToJobId(argText) const wantId = pidMapped != null ? pidMapped : Number.isFinite(id) ? id : null if (Number.isFinite(wantId)) { target = list.filter((j) => j && j.id === wantId) } } } if (!target.length) { origErr.call(ctx.console, 'wait: no such job') ctx.exitCode = 1 } else { try { await Promise.all(target.map((j) => j.promise)) let ec = 0 for (const j of target) { const c = j && typeof j.lastExitCode === 'number' ? j.lastExitCode : 0 if (c !== 0) ec = c } ctx.exitCode = ec } catch { ctx.exitCode = 1 } } } } else if (name === 'test' || name === '[') { /** @type {string[]} */ let testArgv if (name === '[') { if (argv.length < 2) { origErr.call(ctx.console, '[: missing ]') ctx.exitCode = 2 } else if (argv[argv.length - 1] !== ']') { origErr.call(ctx.console, '[: expected ]') ctx.exitCode = 2 } else { testArgv = ['test', ...argv.slice(1, -1)] } } else { testArgv = argv } if (testArgv) { const childCtx = bareOsPipelineChildCtx(ctx, env, stdinText, capOut) if (capOut) childCtx.bareOsBinWrite = pushOutRaw try { await runWithShellPipelineStageTimeout( env, () => runBinCommand(childCtx, testArgv), testArgv[0] || 'test' ) mergePipelineChildCtx(ctx, childCtx) if (childCtx.bareOsScriptCompletedWithCatch) { ctx.bareOsPipelineStageError = true delete childCtx.bareOsScriptCompletedWithCatch } } catch (e) { origErr.call(ctx.console, (e && e.message) || String(e)) ctx.exitCode = 1 ctx.bareOsPipelineStageError = true } } } else if (name === 'read') { await runShellReadBuiltin(ctx, argv, env, origErr) } else if (name === 'exit') { code = 'exit' let ec = 0 if (argv[1] !== undefined) { const n = Number.parseInt(argv[1], 10) ec = Number.isFinite(n) ? n : 0 } await dispatchShellTrapSignal(ctx, 'EXIT') ctx.exitCode = ec if (typeof ctx.requestBooterExit === 'function') { ctx.requestBooterExit(ec) } } else { const childCtx = bareOsPipelineChildCtx(ctx, env, stdinText, capOut) if (capOut) childCtx.bareOsBinWrite = pushOutRaw try { await runWithShellPipelineStageTimeout( env, () => runBinCommand(childCtx, argv), argv[0] || 'bin' ) mergePipelineChildCtx(ctx, childCtx) if (childCtx.bareOsScriptCompletedWithCatch) { ctx.bareOsPipelineStageError = true delete childCtx.bareOsScriptCompletedWithCatch } } catch (e) { origErr.call(ctx.console, (e && e.message) || String(e)) ctx.exitCode = 1 ctx.bareOsPipelineStageError = true } if (name === '/bin/exit' || name.endsWith('/exit')) { code = 'exit' } } appendShellAuditEvent(ctx, 'shell.command.finish', { command: name, stage: pi, exitCode: Number(ctx.exitCode) || 0 }) } finally { if (capOut || capErrSeparate || mergeErr) { ctx.console.log = origLog ctx.console.error = origErr } } if (code === 'exit') return 'exit' if (resolvedRedirErr) { const epath = resolvedRedirErr const edata = ctx.b4a.from(errChunks.join('')) if (cmd.redirErrAppend) { const prev = await vfs.readFile(epath) const merged = prev ? ctx.b4a.concat([prev, edata]) : edata await vfs.writeFile(epath, merged) } else { await vfs.writeFile(epath, edata) } } let pipeOut = outChunks.join('') if (resolvedRedirOut) { const path = resolvedRedirOut const data = ctx.b4a.from(pipeOut) if (cmd.redirAppend) { const prev = await vfs.readFile(path) const merged = prev ? ctx.b4a.concat([prev, data]) : data await vfs.writeFile(path, merged) } else { await vfs.writeFile(path, data) } pipeOut = '' } pipelineLastExit = Number(ctx.exitCode) || 0 pipelineStageExits.push(pipelineLastExit) if ( pipelineWantsPipefail && pipefailFirstNonZero === 0 && pipelineLastExit !== 0 ) { pipefailFirstNonZero = pipelineLastExit } if (ctx.bareOsPipelineStageError) { delete ctx.bareOsPipelineStageError return 'ok' } stdinText = isLast ? null : pipeOut ctx.shellStdin = stdinText ?? undefined } ctx.exitCode = pipelineWantsPipefail ? pipefailFirstNonZero !== 0 ? pipefailFirstNonZero : pipelineLastExit : pipelineLastExit if ( env.BARE_OS_SHELL_PIPESTATUS === '1' || env.BARE_OS_SHELL_PIPESTATUS === 'true' ) { env.BARE_OS_PIPESTATUS = pipelineStageExits.join(' ') } return 'ok' } catch (e) { ctx.console.error((e && e.message) || String(e)) ctx.exitCode = 1 return 'ok' } } /** * Split on `;` only at depth 0 (`if` / `fi` nesting). * @param {Token[]} tokens * @returns {Token[][]} */ function splitTopLevelStatements(tokens) { /** @type {Token[][]} */ const out = [] /** @type {Token[]} */ let cur = [] let depth = 0 let parenDepth = 0 let braceDepth = 0 for (const t of tokens) { if (t.type === 'word') { if (t.value === 'if') depth++ else if (t.value === 'fi') depth = Math.max(0, depth - 1) else if ( t.value === 'while' || t.value === 'until' || t.value === 'for' || t.value === 'select' ) depth++ else if (t.value === 'done') depth = Math.max(0, depth - 1) else if (t.value === 'case') depth++ else if (t.value === 'esac') depth = Math.max(0, depth - 1) } else if (t.type === 'op') { if (t.value === '(') parenDepth++ else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1) else if (t.value === '{') braceDepth++ else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1) } if ( t.type === 'op' && t.value === ';' && depth === 0 && parenDepth === 0 && braceDepth === 0 ) { if (cur.length) out.push(cur) cur = [] } else { cur.push(t) } } if (cur.length) out.push(cur) return out } /** * Split on `&` at depth 0 (outside `if`/`fi`). Each segment except the last runs in the background. * @param {Token[]} tokens * @returns {Token[][]} */ function splitTopLevelByAmpersand(tokens) { /** @type {Token[][]} */ const out = [] /** @type {Token[]} */ let cur = [] let depth = 0 let parenDepth = 0 let braceDepth = 0 for (const t of tokens) { if (t.type === 'word') { if (t.value === 'if') depth++ else if (t.value === 'fi') depth = Math.max(0, depth - 1) else if ( t.value === 'while' || t.value === 'until' || t.value === 'for' || t.value === 'select' ) depth++ else if (t.value === 'done') depth = Math.max(0, depth - 1) else if (t.value === 'case') depth++ else if (t.value === 'esac') depth = Math.max(0, depth - 1) } else if (t.type === 'op') { if (t.value === '(') parenDepth++ else if (t.value === ')') parenDepth = Math.max(0, parenDepth - 1) else if (t.value === '{') braceDepth++ else if (t.value === '}') braceDepth = Math.max(0, braceDepth - 1) } if ( t.type === 'op' && t.value === '&' && depth === 0 && parenDepth === 0 && braceDepth === 0 ) { out.push(cur) cur = [] } else { cur.push(t) } } out.push(cur) return out } /** * @param {Record} ctx * @param {Token[]} toks */ function scheduleBackgroundShell(ctx, toks) { if (!ctx.shellBackgroundJobs) { ctx.shellBackgroundJobs = { nextId: 1, list: [] } } if (!ctx.shellSessionState) { ctx.shellSessionState = { sid: 1, nextPgid: 300, foregroundPgid: 1, controllingTty: '/dev/console' } } else if (ctx.shellSessionState.foregroundPgid == null) { ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid } if (!ctx.shellSessionState.controllingTty) { const fifo = (ctx.env && ctx.env.BARE_OS_SESSION_FIFO) || (ctx.env && ctx.env.BARE_OS_IPC_SESSION_FIFO) || '' ctx.shellSessionState.controllingTty = fifo ? `ipc:${String(fifo).trim()}` : '/dev/console' } const id = ctx.shellBackgroundJobs.nextId++ const pgid = ctx.shellSessionState.nextPgid++ const sid = ctx.shellSessionState.sid const controlIpc = `job-${sid}-${pgid}-ctl` const dataIpc = `job-${sid}-${pgid}-data` const label = toks .filter((t) => t.type === 'word') .map((t) => t.value) .slice(0, 6) .join(' ') const childCtx = Object.assign({}, ctx) if (ctx.env && typeof ctx.env === 'object') { childCtx.env = { ...ctx.env } } if (ctx.vfs && typeof ctx.vfs === 'object' && ctx.vfs.env && typeof ctx.vfs.env === 'object') { childCtx.vfs = Object.assign( Object.create(Object.getPrototypeOf(ctx.vfs)), ctx.vfs, { env: childCtx.env && typeof childCtx.env === 'object' ? childCtx.env : { ...ctx.vfs.env } } ) } childCtx.shellBackgroundJobs = { nextId: 1, list: [] } if (ctx.shellSessionState && typeof ctx.shellSessionState === 'object') { childCtx.shellSessionState = { ...ctx.shellSessionState } } /** @type {{ id: number, label: string, promise: Promise, done: boolean, stopped: boolean, pgid: number, sid: number, jobControlModel: string, controlIpc: string, dataIpc: string, terminate?: () => Promise, _terminatePromise?: Promise, lastExitCode?: number }} */ const entry = { id, label, promise: /** @type {Promise} */ (Promise.resolve('pending')), done: false, stopped: false, pgid, sid, jobControlModel: 'logical_no_fork', controlIpc, dataIpc, lastExitCode: 0 } entry.promise = (async () => { const statements = splitTopLevelStatements(toks) for (const stmt of statements) { await waitWhileShellJobStopped(entry) if (!stmt.length) continue const r = await dispatchShellStatement(childCtx, stmt) if (r === 'exit') { entry.lastExitCode = typeof childCtx.exitCode === 'number' ? childCtx.exitCode : 0 return r } } syncBareOsExitStatusEnv(childCtx) entry.lastExitCode = typeof childCtx.exitCode === 'number' ? childCtx.exitCode : 0 return 'ok' })() ctx.shellBackgroundJobs.list.push(entry) entry.terminate = async () => { if (entry._terminatePromise) return entry._terminatePromise entry._terminatePromise = (async () => { entry.stopped = true entry.done = true })() return entry._terminatePromise } entry.promise.finally(() => { entry.done = true }) ctx.console.error(`[${id}] (sid=${sid} pgid=${pgid}) ${label || '(job)'} &`) } /** * @param {Token[]} tokens * @param {number} start index after `if` */ function findThenIndex(tokens, start) { let d = 1 for (let j = start; j < tokens.length; j++) { const t = tokens[j] if (t.type !== 'word') continue if (t.value === 'if') d++ else if (t.value === 'fi') d-- else if (t.value === 'then' && d === 1) return j } return -1 } /** * After `then`, find `else` at depth 1 or closing `fi` at depth 0. * @param {Token[]} tokens * @param {number} start index after `then` token * @returns {{ kind: 'else' | 'fi', idx: number } | null} */ function findElseOrFiAfterThen(tokens, start) { let d = 1 for (let j = start; j < tokens.length; j++) { const t = tokens[j] if (t.type !== 'word') continue if (t.value === 'if') d++ else if (t.value === 'fi') { d-- if (d === 0) return { kind: 'fi', idx: j } } else if (t.value === 'else' && d === 1) return { kind: 'else', idx: j } } return null } /** * @param {Token[]} tokens * @param {number} start index after `else` */ function findFiAfterElse(tokens, start) { let d = 1 for (let j = start; j < tokens.length; j++) { const t = tokens[j] if (t.type !== 'word') continue if (t.value === 'if') d++ else if (t.value === 'fi') { d-- if (d === 0) return j } } return -1 } /** * `if` COMPOUND `then` COMPOUND [ `else` COMPOUND ] `fi` * Condition / branch bodies use the same `&&` / `||` / `|` rules as a normal line. * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ /** * Run `;`-separated lists (same as outside `if`); last command sets exit status. * @param {Record} ctx * @param {Token[]} toks */ /** * @param {Record} ctx * @param {Token[]} toks * @param {{ suppressErrexit?: boolean }} [opts] */ async function execSemicolonLists(ctx, toks, opts) { const lists = splitTokensBySemicolon(toks) const shE = ctx.vfs?.env const suppressErrexit = opts && opts.suppressErrexit === true const errexitOn = !suppressErrexit && shE && (shE.BARE_OS_SHELL_ERREXIT === '1' || shE.BARE_OS_SHELL_ERREXIT === 'true') for (const list of lists) { if (!list.length) continue const r = await execAndOrList(ctx, list) if (r === 'exit') return 'exit' if (errexitOn && (Number(ctx.exitCode) || 0) !== 0) return 'ok' } return 'ok' } /** * @param {Record} ctx * @param {Token[]} bodyToks * @returns {Promise<'ok' | 'exit' | 'break' | 'continue'>} */ async function execLoopBody(ctx, bodyToks) { const lists = splitTokensBySemicolon(bodyToks) for (const list of lists) { if (!list.length) continue const first = list[0] if (first?.type === 'word' && first.value === 'break') { ctx.exitCode = 0 return 'break' } if (first?.type === 'word' && first.value === 'continue') { ctx.exitCode = 0 return 'continue' } const r = await dispatchShellStatement(ctx, list) if (r === 'exit') return 'exit' } return 'ok' } /** @param {string} name */ function isValidShellIdentifier(name) { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) } /** * @param {Token[]} stmt * @returns {{ name: string, body: Token[] } | null} */ function parseShellFunctionDeclaration(stmt) { const n = stmt.length if (n < 5) return null let name = '' let bodyStart = -1 if ( stmt[0]?.type === 'word' && stmt[0].value === 'function' && stmt[1]?.type === 'word' ) { name = stmt[1].value if ( stmt[2]?.type === 'op' && stmt[2].value === '(' && stmt[3]?.type === 'op' && stmt[3].value === ')' ) { if (stmt[4]?.type === 'op' && stmt[4].value === '{') bodyStart = 5 } else if (stmt[2]?.type === 'op' && stmt[2].value === '{') { bodyStart = 3 } } else if ( stmt[0]?.type === 'word' && stmt[1]?.type === 'op' && stmt[1].value === '(' && stmt[2]?.type === 'op' && stmt[2].value === ')' && stmt[3]?.type === 'op' && stmt[3].value === '{' ) { name = stmt[0].value bodyStart = 4 } if (!name || bodyStart < 0 || !isValidShellIdentifier(name)) return null let depth = 1 let close = -1 for (let i = bodyStart; i < stmt.length; i++) { const t = stmt[i] if (t.type === 'op' && t.value === '{') depth++ else if (t.type === 'op' && t.value === '}') { depth-- if (depth === 0) { close = i break } } } if (close < 0 || close !== stmt.length - 1) return null const body = stmt.slice(bodyStart, close) if (body.length && body[body.length - 1]?.type === 'op' && body[body.length - 1].value === ';') { body.pop() } return { name, body } } /** * @param {Token[]} tokens * @returns {number} */ function findWhileDoSplit(tokens) { for (let j = 1; j < tokens.length - 2; j++) { const t = tokens[j] if (t.type === 'op' && t.value === ';') { const n = tokens[j + 1] if (n && n.type === 'word' && n.value === 'do') return j } } return -1 } /** * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ async function execWhileConstruct(ctx, tokens) { const split = findWhileDoSplit(tokens) if (split < 0) { ctx.console.error('shell: while: expected "; do …; done"') ctx.exitCode = 2 return 'ok' } const last = tokens[tokens.length - 1] if (last.type !== 'word' || last.value !== 'done') { ctx.console.error('shell: while: missing done') ctx.exitCode = 2 return 'ok' } const condToks = tokens.slice(1, split) const bodyToks = tokens.slice(split + 2, tokens.length - 1) const maxIter = Number.parseInt( ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000', 10 ) const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000 for (let i = 0; i < cap; i++) { const r0 = await execSemicolonLists(ctx, condToks, { suppressErrexit: true }) if (r0 === 'exit') return 'exit' if ((Number(ctx.exitCode) || 0) !== 0) break const r1 = await execLoopBody(ctx, bodyToks) if (r1 === 'exit') return 'exit' if (r1 === 'break') break if (r1 === 'continue') continue } return 'ok' } /** * `until test-commands; do consequent-commands; done` — opposite exit test vs while (Issue 7–style). * Gated by **`BARE_OS_SHELL_UNTIL=1`** or **`true`**. * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ async function execUntilConstruct(ctx, tokens) { const split = findWhileDoSplit(tokens) if (split < 0) { ctx.console.error('shell: until: expected "; do …; done"') ctx.exitCode = 2 return 'ok' } const last = tokens[tokens.length - 1] if (last.type !== 'word' || last.value !== 'done') { ctx.console.error('shell: until: missing done') ctx.exitCode = 2 return 'ok' } const condToks = tokens.slice(1, split) const bodyToks = tokens.slice(split + 2, tokens.length - 1) const maxIter = Number.parseInt( ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000', 10 ) const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000 for (let i = 0; i < cap; i++) { const r0 = await execSemicolonLists(ctx, condToks, { suppressErrexit: true }) if (r0 === 'exit') return 'exit' if ((Number(ctx.exitCode) || 0) === 0) break const r1 = await execLoopBody(ctx, bodyToks) if (r1 === 'exit') return 'exit' if (r1 === 'break') break if (r1 === 'continue') continue } return 'ok' } /** * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ async function execForConstruct(ctx, tokens) { const last = tokens[tokens.length - 1] if (last.type !== 'word' || last.value !== 'done') { ctx.console.error('shell: for: missing done') ctx.exitCode = 2 return 'ok' } if (tokens.length < 7 || tokens[1].type !== 'word') { ctx.console.error('shell: for: invalid syntax') ctx.exitCode = 2 return 'ok' } if (tokens[2].type !== 'word' || tokens[2].value !== 'in') { ctx.console.error('shell: for: expected `in`') ctx.exitCode = 2 return 'ok' } /** @type {Token[]} */ const inToks = [] let semi = -1 for (let j = 3; j < tokens.length; j++) { const t = tokens[j] if (t.type === 'op' && t.value === ';') { semi = j break } inToks.push(t) } if (semi < 0) { ctx.console.error('shell: for: expected `;` before do') ctx.exitCode = 2 return 'ok' } if (tokens[semi + 1]?.type !== 'word' || tokens[semi + 1].value !== 'do') { ctx.console.error('shell: for: expected `do` after `;`') ctx.exitCode = 2 return 'ok' } const varName = tokens[1].value const bodyToks = tokens.slice(semi + 2, tokens.length - 1) const env = ctx.vfs.env /** @type {string[]} */ const words = [] for (const t of inToks) { if (t.type !== 'word') continue const xs = await expandShellWordTokens( ctx, /** @type {Extract} */ (t), env, {} ) if ( xs.length === 0 && (env.BARE_OS_STRICT_POSIX === '1' || env.BARE_OS_STRICT_POSIX === 'true') ) { ctx.console.error('shell: for `in`: pathname expansion produced no matches') ctx.exitCode = 1 return 'ok' } for (const x of xs) words.push(x) } const maxIter = Number.parseInt( ctx.vfs?.env?.BARE_OS_SHELL_LOOP_MAX || '10000', 10 ) const cap = Number.isFinite(maxIter) && maxIter > 0 ? maxIter : 10000 let total = 0 for (const w of words) { env[varName] = w if (++total > cap) { ctx.console.error('shell: for: exceeded BARE_OS_SHELL_LOOP_MAX') ctx.exitCode = 1 return 'ok' } const r = await execLoopBody(ctx, bodyToks) if (r === 'exit') return 'exit' if (r === 'break') break if (r === 'continue') continue } return 'ok' } /** * @param {Token[]} toks * @param {Record} env * @returns {string[]} */ function casePatternList(toks, env) { /** @type {string[]} */ const out = [] /** @type {Token[]} */ let cur = [] for (const t of toks) { if (t.type === 'op' && t.value === '|') { if (cur.length) { const s = cur.map((w) => w.value).join(' ') out.push(expandWord(s.trim(), env)) cur = [] } } else if (t.type === 'word') { cur.push(t) } } if (cur.length) { const s = cur.map((w) => w.value).join(' ') out.push(expandWord(s.trim(), env)) } return out.filter(Boolean) } /** * @param {string} subject * @param {string} pat */ function casePatternMatches(subject, pat) { if (pat.includes('|')) { return pat.split('|').some((p) => casePatternMatches(subject, p.trim())) } if (pat === '*') return true if (!/[*?\[]/.test(pat)) return subject === pat return bareOsFnmatch(subject, pat) } /** * `case WORD in pattern) list ;; … esac` — bounded branches; patterns support `|` alternation and `*`. * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ async function execCaseConstruct(ctx, tokens) { const last = tokens[tokens.length - 1] if (last.type !== 'word' || last.value !== 'esac') { ctx.console.error('shell: case: missing esac') ctx.exitCode = 2 return 'ok' } if ( tokens.length < 5 || tokens[1].type !== 'word' || tokens[2].type !== 'word' || tokens[2].value !== 'in' ) { ctx.console.error('shell: case: expected `case WORD in`') ctx.exitCode = 2 return 'ok' } const env = ctx.vfs.env const subj = expandWord(tokens[1].value, env) const maxBranches = Number.parseInt( ctx.vfs?.env?.BARE_OS_SHELL_CASE_MAX_BRANCHES || '32', 10 ) const cap = Number.isFinite(maxBranches) && maxBranches > 0 ? maxBranches : 32 let i = 3 let branches = 0 while (i < tokens.length - 1) { if (++branches > cap) { ctx.console.error( 'shell: case: too many branches (see BARE_OS_SHELL_CASE_MAX_BRANCHES)' ) ctx.exitCode = 2 return 'ok' } let paren = -1 for (let k = i; k < tokens.length - 1; k++) { const t = tokens[k] if (t.type === 'op' && t.value === ')') { paren = k break } } if (paren < 0) { ctx.console.error('shell: case: expected )') ctx.exitCode = 2 return 'ok' } const patToks = tokens.slice(i, paren) let dsemi = -1 let doubleSemiLen = 2 for (let k = paren + 1; k < tokens.length - 1; k++) { const t = tokens[k] const n = tokens[k + 1] if (t.type === 'op' && t.value === ';;') { dsemi = k doubleSemiLen = 1 break } if ( t.type === 'op' && t.value === ';' && n && n.type === 'op' && n.value === ';' ) { dsemi = k doubleSemiLen = 2 break } } if (dsemi < 0) { ctx.console.error('shell: case: expected ;;') ctx.exitCode = 2 return 'ok' } const bodyToks = tokens.slice(paren + 1, dsemi) const pats = casePatternList(patToks, env) const matched = pats.some((p) => casePatternMatches(subj, p)) if (matched) { const r = await execSemicolonLists(ctx, bodyToks) if (r === 'exit') return 'exit' return 'ok' } i = dsemi + doubleSemiLen } ctx.exitCode = 0 return 'ok' } /** * @param {Record} ctx * @param {Token[]} stmt * @returns {Promise<'exit' | 'ok'>} */ /** * @param {Record} ctx * @param {{ type: string, value: string }[]} rest */ async function execShellLocalBuiltin(ctx, rest) { const vfs = ctx.vfs const env = vfs?.env if (!env || typeof env !== 'object') { ctx.exitCode = 0 return 'ok' } let n = 0 for (const t of rest) { if (++n > 48) break if (t.type !== 'word') continue const eq = t.value.indexOf('=') if (eq <= 0) continue const name = t.value.slice(0, eq).trim() if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue env[name] = expandWord(t.value.slice(eq + 1), env) } ctx.exitCode = 0 return 'ok' } /** * @param {Record} ctx * @param {{ type: string, value: string }[]} rest * @returns {'ok' | null} */ function tryExecShellDeclareBuiltin(ctx, rest) { const vfs = ctx.vfs const env = vfs?.env if (!env || typeof env !== 'object') return 'ok' if (rest[0]?.type !== 'word' || rest[0].value !== '-r') return null let n = 0 for (let i = 1; i < rest.length; i++) { if (++n > 32) break const t = rest[i] if (t.type !== 'word') continue const eq = t.value.indexOf('=') if (eq <= 0) continue const name = t.value.slice(0, eq).trim() if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue env[name] = expandWord(t.value.slice(eq + 1), env) } ctx.exitCode = 0 return 'ok' } /** Reserved words that cannot begin a simple or compound statement (POSIX-style). */ const BARE_OS_SHELL_MISPLACED_STATEMENT_START = new Set([ 'then', 'else', 'elif', 'fi', 'do', 'done', 'esac', 'in' ]) /** * @param {Record} ctx * @param {Token[]} stmt * @returns {boolean} true when an error was reported (caller should return) */ function tryReportMisplacedReservedStatementStart(ctx, stmt) { const h = stmt[0] if (!h || h.type !== 'word') return false const w = h.value if (!BARE_OS_SHELL_MISPLACED_STATEMENT_START.has(w)) return false ctx.console.error( `shell: syntax error: reserved word '${w}' cannot start a statement` ) ctx.exitCode = 2 return true } /** * Minimal gated **`[[ … ]]`** — only **`[[ WORD == WORD ]]`** and **`[[ WORD != WORD ]]`**. * @param {Record} ctx * @param {Token[]} stmt * @returns {Promise<'exit' | 'ok'>} */ async function execDoubleBracketLimited(ctx, stmt) { const vfs = ctx.vfs const env = vfs?.env && typeof vfs.env === 'object' ? vfs.env : {} const last = stmt[stmt.length - 1] if (!last || last.type !== 'word' || last.value !== ']]') { ctx.console.error('shell: [[: missing closing ]]') ctx.exitCode = 2 return 'ok' } const bodyStart = stmt[0]?.value === '[[' ? 1 : 2 const inner = stmt.slice(bodyStart, -1) if ( inner.length === 3 && inner[0].type === 'word' && inner[1].type === 'word' && inner[2].type === 'word' ) { const op = inner[1].value if (op === '==' || op === '!=') { const a = expandWord(inner[0].value, env) const b = expandWord(inner[2].value, env) ctx.exitCode = op === '==' ? (a === b ? 0 : 1) : a !== b ? 0 : 1 return 'ok' } } ctx.console.error( 'shell: [[: only `[[ WORD == WORD ]]` and `[[ WORD != WORD ]]` are supported' ) ctx.exitCode = 2 return 'ok' } async function dispatchShellStatement(ctx, stmt) { /** Normalize inline brace-expression tokens like `{1..5}` into a word token so * they use normal shell word expansion instead of statement/group operators. */ const normalized = [] for (let i = 0; i < stmt.length; i++) { const a = stmt[i] const b = stmt[i + 1] const c = stmt[i + 2] if ( a?.type === 'op' && a.value === '{' && b?.type === 'word' && c?.type === 'op' && c.value === '}' && (/^-?\d+\.\.-?\d+$/.test(b.value) || b.value.includes(',')) ) { normalized.push({ type: 'word', value: '{' + b.value + '}', parts: [{ q: /** @type {'u'} */ ('u'), t: '{' + b.value + '}' }] }) i += 2 continue } normalized.push(a) } stmt = normalized const head = stmt[0] const shEnv = ctx.vfs?.env const posixMode = shEnv && (shEnv.BARE_OS_SHELL_POSIX_MODE === '1' || shEnv.BARE_OS_SHELL_POSIX_MODE === 'true') const groupingMode = shEnv && (shEnv.BARE_OS_SHELL_GROUPING === '1' || shEnv.BARE_OS_SHELL_GROUPING === 'true') const arithmeticCommandMode = shEnv && (shEnv.BARE_OS_SH_EXTENDED_PROFILE === '1' || shEnv.BARE_OS_SH_EXTENDED_PROFILE === 'true' || shEnv.BARE_OS_SHELL_POSIX_MODE === '1' || shEnv.BARE_OS_SHELL_POSIX_MODE === 'true') if ( arithmeticCommandMode && ((stmt.length >= 4 && stmt[0]?.type === 'op' && stmt[0].value === '(' && stmt[1]?.type === 'op' && stmt[1].value === '(' && stmt[stmt.length - 2]?.type === 'op' && stmt[stmt.length - 2].value === ')' && stmt[stmt.length - 1]?.type === 'op' && stmt[stmt.length - 1].value === ')') || (stmt.length >= 3 && stmt[0]?.type === 'word' && stmt[0].value === '((' && stmt[stmt.length - 1]?.type === 'word' && stmt[stmt.length - 1].value === '))')) ) { const body = stmt[0]?.type === 'word' && stmt[0].value === '((' ? stmt.slice(1, -1) : stmt.slice(2, -2) const expr = body .filter((t) => t.type !== 'op' || (t.value !== ';' && t.value !== '|')) .map((t) => t.value) .join(' ') .trim() if (!expr) { ctx.console.error('shell: arithmetic: empty expression') ctx.exitCode = 2 return 'ok' } try { const n = bareOsEvalArithmeticExpr(expr, shEnv || {}) ctx.exitCode = n === 0 ? 1 : 0 } catch (e) { ctx.console.error((e && e.message) || String(e)) ctx.exitCode = 2 } return 'ok' } if ((posixMode || groupingMode) && head?.type === 'op' && head.value === '(') { let depth = 0 let close = -1 for (let j = 0; j < stmt.length; j++) { const t = stmt[j] if (t.type === 'op' && t.value === '(') depth++ else if (t.type === 'op' && t.value === ')') { depth-- if (depth === 0) { close = j break } } } if (close < 0) { ctx.console.error('shell: grouped list: unmatched (') ctx.exitCode = 2 return 'ok' } if (close !== stmt.length - 1) { ctx.console.error( 'shell: grouped list must span the full statement (no trailing tokens after closing )' ) ctx.exitCode = 2 return 'ok' } return execSemicolonLists(ctx, stmt.slice(1, close)) } const localOn = shEnv && (shEnv.BARE_OS_SHELL_LOCAL_DECLARE === '1' || shEnv.BARE_OS_SHELL_LOCAL_DECLARE === 'true') if (localOn && head?.type === 'word' && head.value === 'local') { return execShellLocalBuiltin(ctx, stmt.slice(1)) } if (localOn && head?.type === 'word' && head.value === 'declare') { const dr = tryExecShellDeclareBuiltin(ctx, stmt.slice(1)) if (dr != null) return dr } if (head?.type === 'word' && head.value === 'select') { ctx.console.error('shell: select is unsupported') ctx.exitCode = 2 return 'ok' } const doubleBracketOn = shEnv && (shEnv.BARE_OS_SHELL_DOUBLE_BRACKET === '1' || shEnv.BARE_OS_SHELL_DOUBLE_BRACKET === 'true') const legacyDoubleBracketOpen = stmt[0]?.type === 'word' && stmt[0].value === '[' && stmt[1]?.type === 'word' && stmt[1].value === '[' const modernDoubleBracketOpen = stmt[0]?.type === 'word' && stmt[0].value === '[[' if (legacyDoubleBracketOpen || modernDoubleBracketOpen) { if (!doubleBracketOn) { ctx.console.error( 'shell: [[ … ]] is not supported (use /bin/test or [ … ]; set BARE_OS_SHELL_DOUBLE_BRACKET=1 for limited ==/!=)' ) ctx.exitCode = 2 return 'ok' } return execDoubleBracketLimited(ctx, stmt) } const fnDecl = parseShellFunctionDeclaration(stmt) if (fnDecl) { if (!ctx.shellFunctions || typeof ctx.shellFunctions !== 'object') { ctx.shellFunctions = Object.create(null) } ctx.shellFunctions[fnDecl.name] = { body: fnDecl.body } ctx.exitCode = 0 return 'ok' } if (head?.type === 'word' && head.value === 'if') return execIfConstruct(ctx, stmt) if (head?.type === 'word' && head.value === 'until') return execUntilConstruct(ctx, stmt) if (head?.type === 'word' && head.value === 'while') return execWhileConstruct(ctx, stmt) if (head?.type === 'word' && head.value === 'for') return execForConstruct(ctx, stmt) if (head?.type === 'word' && head.value === 'case') return execCaseConstruct(ctx, stmt) if (tryReportMisplacedReservedStatementStart(ctx, stmt)) return 'ok' return execAndOrList(ctx, stmt) } async function execIfConstruct(ctx, tokens) { const thenIdx = findThenIndex(tokens, 1) if (thenIdx < 0) { ctx.console.error('shell: syntax error: if without matching then') ctx.exitCode = 2 return 'ok' } const cond = tokens.slice(1, thenIdx) const tail = findElseOrFiAfterThen(tokens, thenIdx + 1) if (!tail) { ctx.console.error('shell: syntax error: if without fi') ctx.exitCode = 2 return 'ok' } let closingFiIdx if (tail.kind === 'fi') { closingFiIdx = tail.idx } else { closingFiIdx = findFiAfterElse(tokens, tail.idx + 1) if (closingFiIdx < 0) { ctx.console.error('shell: syntax error: else without fi') ctx.exitCode = 2 return 'ok' } } if (closingFiIdx !== tokens.length - 1) { ctx.console.error('shell: syntax error: unexpected tokens after fi') ctx.exitCode = 2 return 'ok' } const condLists = splitTokensBySemicolon(cond).filter((c) => c.length) if (!condLists.length) { ctx.console.error('shell: invalid null command') ctx.exitCode = 2 return 'ok' } for (const c of condLists) { const r = await execAndOrList(ctx, c) if (r === 'exit') return 'exit' } const condOk = (Number(ctx.exitCode) || 0) === 0 if (tail.kind === 'fi') { const thenToks = tokens.slice(thenIdx + 1, tail.idx) if (condOk) { const r = await execSemicolonLists(ctx, thenToks) if (r === 'exit') return 'exit' } else { ctx.exitCode = 0 } return 'ok' } const elseIdx = tail.idx const thenToks = tokens.slice(thenIdx + 1, elseIdx) const elseToks = tokens.slice(elseIdx + 1, closingFiIdx) if (condOk) { const r = await execSemicolonLists(ctx, thenToks) if (r === 'exit') return 'exit' } else { const r = await execSemicolonLists(ctx, elseToks) if (r === 'exit') return 'exit' } return 'ok' } /** * @param {Record} ctx * @param {Token[]} tokens * @returns {Promise<'exit' | 'ok'>} */ async function execAndOrList(ctx, tokens) { const { segments, ops } = splitTokensByAndOr(tokens) for (let s = 0; s < segments.length; s++) { if (!segmentHasCommand(segments[s])) { ctx.console.error('shell: invalid null command') ctx.exitCode = 2 return 'ok' } } let lastStatus = 0 for (let i = 0; i < segments.length; i++) { if (i > 0) { const op = ops[i - 1] if (op === '&&' && lastStatus !== 0) continue if (op === '||' && lastStatus === 0) continue } let pipeline try { pipeline = parsePipeline(segments[i]) } catch (e) { const code = e && /** @type {{ code?: string }} */ (e).code if (code === 'BARE_OS_SHELL_ERROR') { ctx.console.error((e && /** @type {Error} */ (e).message) || String(e)) ctx.exitCode = 2 syncBareOsExitStatusEnv(ctx) return 'ok' } throw e } const r = await execParsedPipeline(ctx, pipeline) if (r === 'exit') return 'exit' lastStatus = Number(ctx.exitCode) || 0 } return 'ok' } /** * @param {Record} env * @param {string[]} argv */ function assignShellFunctionPositionalEnv(env, argv) { env['0'] = argv[0] || '' const fnArgs = argv.slice(1) for (let i = 1; i <= 9; i++) env[String(i)] = fnArgs[i - 1] ?? '' env['#'] = String(fnArgs.length) } /** * @param {Record} ctx * @param {string} line * @returns {Promise<'exit' | 'ok'>} */ export async function execShellLine(ctx, line) { const raw = line.trim() if (!raw) return 'ok' const vfs = ctx.vfs const shellLocalOn = vfs?.env && (vfs.env.BARE_OS_SHELL_LOCAL_DECLARE === '1' || vfs.env.BARE_OS_SHELL_LOCAL_DECLARE === 'true') /** @type {Record | null} */ let savedEnv = null if (shellLocalOn && vfs?.env && typeof vfs.env === 'object') { savedEnv = vfs.env vfs.env = { ...savedEnv } } try { return await execShellLineInner(ctx, raw) } catch (e) { const code = e && /** @type {{ code?: string }} */ (e).code if (code === BARE_OS_SHELL_NOUNSET_ERROR) { ctx.console.error('shell: ' + ((e && e.message) || 'unbound variable')) ctx.exitCode = 1 syncBareOsExitStatusEnv(ctx) return 'ok' } throw e } finally { if (savedEnv != null && vfs) vfs.env = savedEnv } } /** * @param {Record} ctx * @param {string} rawTrimmed */ async function execShellLineInner(ctx, rawTrimmed) { if (typeof ctx.execLine !== 'function') { ctx.execLine = async (ln) => execShellLine(ctx, ln) } let execLine = collapseShellLineContinuations(rawTrimmed.trim()) const arithCmd = /^\(\((.*)\)\)$/.exec(execLine) if (arithCmd) { const envArith = ctx.vfs?.env && typeof ctx.vfs.env === 'object' ? ctx.vfs.env : {} try { const n = bareOsEvalArithmeticExpr(String(arithCmd[1] || '').trim(), envArith) ctx.exitCode = n === 0 ? 1 : 0 } catch (e) { ctx.console.error((e && e.message) || String(e)) ctx.exitCode = 2 } syncBareOsExitStatusEnv(ctx) return 'ok' } const env0 = ctx.vfs?.env && typeof ctx.vfs.env === 'object' ? ctx.vfs.env : {} if ( env0.BARE_OS_SHELL_EXEC_GRAPH_DUMP === '1' || env0.BARE_OS_SHELL_EXEC_GRAPH_DUMP === 'true' ) { try { ctx.shellLastExecGraph = buildShellExecutionGraph( collapseShellLineContinuations(rawTrimmed.trim()) ) } catch { /* best-effort diagnostic only */ } } if (!ctx.shellSessionState) { ctx.shellSessionState = { sid: 1, nextPgid: 300, foregroundPgid: 1, controllingTty: '/dev/console' } } else { if (ctx.shellSessionState.foregroundPgid == null) { ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid } if (!ctx.shellSessionState.controllingTty) { const fifo = (ctx.env && ctx.env.BARE_OS_SESSION_FIFO) || (ctx.env && ctx.env.BARE_OS_IPC_SESSION_FIFO) || '' ctx.shellSessionState.controllingTty = fifo ? `ipc:${String(fifo).trim()}` : '/dev/console' } } ctx.shellSessionState.foregroundPgid = ctx.shellSessionState.sid const readL = ctx.readLine if (typeof readL === 'function') { const hm = execLine.match(/^(.*?)<<-?\s*(?:'([^']+)'|"([^"]+)"|(\S+))\s*$/) if (hm) { const prefix = hm[1].trimEnd() if (!prefix) { ctx.console.error( 'shell: here-document requires a command before << on the same line' ) ctx.exitCode = 2 syncBareOsExitStatusEnv(ctx) return 'ok' } const delim = hm[2] ?? hm[3] ?? hm[4] const singleQuoted = hm[2] != null const posixHeredocCap = (() => { if ( ctx.env?.BARE_OS_SHELL_POSIX_MODE !== '1' && ctx.env?.BARE_OS_SHELL_POSIX_MODE !== 'true' ) { return null } const raw = String(ctx.env.BARE_OS_SHELL_HEREDOC_MAX_BYTES || '').trim() const n = parseInt(raw, 10) if (Number.isFinite(n) && n > 0) return Math.min(2_000_000, n) return 262144 })() /** @type {string[]} */ const bodyLines = [] let heredocAcc = 0 for (;;) { const ln = await readL('> ') if (ln == null) break if (ln === delim) break if (posixHeredocCap != null) { heredocAcc += ln.length + 1 if (heredocAcc > posixHeredocCap) { ctx.console.error( 'shell: here-document exceeds BARE_OS_SHELL_HEREDOC_MAX_BYTES cap (POSIX mode)' ) ctx.exitCode = 2 syncBareOsExitStatusEnv(ctx) return 'ok' } } bodyLines.push(ln) } const vfs = ctx.vfs const env = vfs?.env && typeof vfs.env === 'object' ? vfs.env : {} let body = bodyLines.join('\n') if (!singleQuoted) { body = bodyLines.map((l) => expandWord(l, env)).join('\n') } ctx.shellHeredocOnce = body if (body.length > 0 && !body.endsWith('\n')) { ctx.shellHeredocOnce += '\n' } execLine = prefix } } const tokens = tokenize(execLine) if (!tokens.length) return 'ok' const statements = splitTopLevelStatements(tokens) for (const stmt of statements) { if (!stmt.length) continue const ampParts = splitTopLevelByAmpersand(stmt) for (let ai = 0; ai < ampParts.length; ai++) { const part = ampParts[ai] if (!part.length) continue const isBg = ai < ampParts.length - 1 if (isBg) { scheduleBackgroundShell(ctx, part) continue } const r = await dispatchShellStatement(ctx, part) if (r === 'exit') { syncBareOsExitStatusEnv(ctx) return 'exit' } syncBareOsExitStatusEnv(ctx) const shE = ctx.vfs?.env if ( shE && (shE.BARE_OS_SHELL_ERREXIT === '1' || shE.BARE_OS_SHELL_ERREXIT === 'true') && (Number(ctx.exitCode) || 0) !== 0 ) { syncBareOsExitStatusEnv(ctx) return 'ok' } } } syncBareOsExitStatusEnv(ctx) return 'ok' } /** * Clear simulated background jobs on guest ↔ unlocked transitions (POSIX session model). * @param {Record} ctx */ export function bareOsResetShellIdentityState(ctx) { if ( ctx.shellBackgroundJobs && typeof ctx.shellBackgroundJobs === 'object' && Array.isArray(ctx.shellBackgroundJobs.list) ) { ctx.shellBackgroundJobs.list.length = 0 ctx.shellBackgroundJobs.nextId = 1 } }