UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

1,153 lines • 46.5 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { substringOperands } from "./substring.js"; import { badSubstitution, scanParameter } from "../../shell/parameter.js"; import { nextRandom } from "../session/state.js"; import { arrayExtent, arrayGet, arrayHas, arrayIndices, arraySlice, arrayValues, } from "../../shell/array.js"; import { YieldBudget } from "../../io/yield_budget.js"; import { RANDOM } from "../../shell/constants.js"; import { ArithError, BadSubstitution, DiscardSignal, ExitSignal, named, ReadonlyVariableError, UnboundVariable, } from "../../shell/errors.js"; import { NodeType as NT } from "../../shell/types.js"; import { PolicyDenied } from "../../policy/errors.js"; import { compareCodePoints } from "../../utils/sort.js"; import { assignElement, landedArith } from "../session/elements.js"; import { ensureVarVisible, visibleArrays, visibleAssocs, visibleEnv, deref, namerefTarget, positionalParams, subscriptIndex, } from "../session/state.js"; import { homeDir } from "../session/shell_dirs.js"; import { decodeAnsiC } from "../../shell/escapes.js"; import { sourceParts } from "../../shell/helpers.js"; import { Glob } from "../../rs/glob.js"; import { escapeGlob, markGlobs } from "../../utils/glob_walk.js"; import { expandTilde } from "../../utils/path.js"; import { OPERAND_DQUOTE_ESCAPES } from "./constants.js"; import { chunksText, ifsJoiner, splatChunks, valuePiece } from "./fields.js"; import { piece } from "./types.js"; import { encodeText } from "../../shell/bytes.js"; const PARAM_OPS = new Set([ ':-', '-', ':+', '+', ':?', '?', ':=', '=', '#', '##', '%', '%%', '/', '//', '/#', '/%', ':', '^', '^^', ',', ',,', '!', ]); const REPLACE_OPS = new Set(['/', '//', '/#', '/%']); const STRIP_OPS = new Set(['#', '##', '%', '%%']); const CASE_OPS = new Set(['^', '^^', ',', ',,']); // Ops whose first operand is a glob pattern that must keep its literal // spelling (no unescaping) while still expanding nested $-expansions. const PATTERN_OPS = new Set([...REPLACE_OPS, ...STRIP_OPS, ...CASE_OPS]); const LITERAL_ARG_TYPES = new Set([NT.WORD, NT.NUMBER, 'regex']); // Quote-carrying operand nodes: in pattern position their value matches // literally, exactly as a quoted case pattern does. const QUOTED_ARG_TYPES = new Set([ NT.STRING, NT.RAW_STRING, NT.ANSI_C_STRING, NT.TRANSLATED_STRING, ]); // Operators that handle unset themselves, so `set -u` must not fire // on the lookup that feeds them. const UNSET_GUARD_OPS = new Set(['-', ':-', '+', ':+', '=', ':=', '?', ':?']); /** * Refuse expansion-time writes that name hidden variables. * * `${X:=d}` and `$((X=5))` land on the raw session env rather than the * async session view, so the hidden half of the session view * (`ensureVarVisible`) is applied here, and the refusal takes the * fatal expansion-error shape `${var:?}` uses. */ function guardExpansionWrite(session, ...names) { for (const name of names) { try { ensureVarVisible(session, name); } catch (err) { if (!(err instanceof PolicyDenied)) throw err; throw new DiscardSignal(encodeText(`bash: ${err.message}\n`)); } } } /** * Resolve one variable name to its value. * * `strict` honors `set -u`: an unset plain name or positional raises; * the defaulting operators (`:-` family) pass false because they handle * unset themselves. Specials (`@ * # ? $ ! 0`) never raise, matching * bash >= 4.4. */ export function lookupVar(name, session, callStack, strict = true) { const env = visibleEnv(session); const lastExitCode = session.lastExitCode; const positional = positionalParams(session, callStack); const nounset = strict && session.shellOptions.nounset === true; if (name === '@' || name === '*') { // Read where nothing splits: `$@` joins on a space and `$*` on the // first character of IFS, as `v=$*` stores them. const joiner = name === '@' ? ' ' : ifsJoiner(ifsValue(session, callStack)); return positional.join(joiner); } if (name === '#') return String(positional.length); if (name === '?') { return String(lastExitCode); } if (name === '$') { return String(session.shellPid ?? session.processId ?? 0); } if (name === '!') { return session.lastBgJobId !== null ? String(session.lastBgJobId) : ''; } if (/^\d+$/.test(name)) { const idx = parseInt(name, 10); if (idx === 0) return session.argv0; if (idx <= positional.length) return positional[idx - 1] ?? ''; if (nounset) throw new UnboundVariable(name); return ''; } if (callStack) { const localVal = callStack.getLocal(name); if (localVal !== null) return localVal; } if (name === RANDOM) { const drawn = nextRandom(session, env[RANDOM]); if (drawn !== null) return String(drawn); } // A name reference resolves to its target before the store is read. name = deref(session, name) || name; const fromArray = visibleArrays(session)[name]; if (fromArray !== undefined) { return arrayGet(fromArray, 0); } const fromAssoc = visibleAssocs(session)[name]; if (fromAssoc !== undefined) { // `$m` on an associative array is `${m["0"]}`, the literal key. return fromAssoc['0'] ?? ''; } // $PWD is deliberately absent here: `cd` writes it into the env like any // exported variable, so it can be assigned, unset and printed by `env`, // exactly as bash allows. Resolving it here instead would make `PWD=/x` // and `unset PWD` silently do nothing. if (name === 'HOME') return homeDir(session) ?? ''; if (!(name in env)) { if (nounset) throw new UnboundVariable(name); return ''; } return env[name] ?? ''; } /** Whether `name` is a positional parameter the current count reaches. */ function positionalSet(name, session, callStack) { if (!/^\d+$/.test(name)) return false; const idx = parseInt(name, 10); return idx === 0 || idx <= positionalParams(session, callStack).length; } /** * The IFS in scope, a function's `local IFS` first; null when IFS is * unset, which splits the way the default does. */ export function ifsValue(session, callStack) { if (callStack) { const local = callStack.getLocal('IFS'); if (local !== null) return local; } return visibleEnv(session).IFS ?? null; } /** * One `$name` reference as pieces of the word it stands in. `$@` is one * field per positional parameter, quoted or not, and so is an unquoted * `$*`; inside double quotes `$*` is the parameters joined on the first * character of IFS. */ export async function parameterChunks(name, session, callStack, quoted) { if (name !== '@' && name !== '*') return [valuePiece(lookupVar(name, session, callStack), quoted)]; const params = positionalParams(session, callStack); const joiner = name === '@' ? ' ' : ifsJoiner(ifsValue(session, callStack)); if (name === '*' && quoted) return [valuePiece(params.join(joiner), true)]; return await splatChunks(params, joiner, quoted); } function groupSeparator(op) { if (op !== null && REPLACE_OPS.has(op)) return '/'; if (op === ':') return ':'; return null; } function parseBraces(node) { let varName = null; let subscript = null; let subscriptNodes = []; let lengthOp = false; let indirectOp = false; let op = null; const groups = []; let seenVar = false; for (const c of sourceParts(node)) { if (typeof c === 'string') { if (op !== null) groups[groups.length - 1]?.push(c); continue; } if (c.type === '${' || c.type === '}') continue; if (c.type === '#' && !seenVar) { lengthOp = true; continue; } if (c.type === '!' && !seenVar) { indirectOp = true; continue; } if ((c.type === NT.VARIABLE_NAME || c.type === NT.SPECIAL_VARIABLE_NAME) && !seenVar) { varName = c.text; seenVar = true; continue; } if (c.type === 'subscript' && !seenVar) { const subNodes = []; for (const sc of c.namedChildren) { if (sc.type === NT.VARIABLE_NAME && varName === null) { varName = sc.text; } else { subNodes.push(sc); } } subscriptNodes = subNodes; if (varName !== null) { // The raw slice, not the first child's text: a subscript // holding several words (`${m[two words]}`) or a quoted key // keeps its whole spelling this way. subscript = c.text.slice(varName.length + 1, -1); } seenVar = true; continue; } if (PARAM_OPS.has(c.type) && op === null) { op = c.text; groups.push([]); continue; } if (op !== null && c.isNamed !== true && c.type === groupSeparator(op)) { groups.push([]); continue; } if (op !== null) { groups[groups.length - 1]?.push(c); } } if (lengthOp && varName === null) { // A `#` naming nothing after it is the parameter itself: `${#}` is the // count and `${!#}` the last positional parameter. varName = '#'; lengthOp = false; } return { varName, subscript, lengthOp, indirectOp, op, groups, subscriptNodes }; } // Index of the next unescaped `quote`, -1 when it never closes. function escapedFind(text, start, quote) { let i = start; const n = text.length; while (i < n) { if (text[i] === '\\' && i + 1 < n) { i += 2; continue; } if (text[i] === quote) return i; i += 1; } return -1; } // A double-quoted pattern segment: everything in it is literal. function dquotedPattern(inner, session, callStack) { const out = []; let i = 0; const n = inner.length; while (i < n) { const ch = inner[i] ?? ''; if (ch === '\\' && i + 1 < n && '$`"\\'.includes(inner[i + 1] ?? '')) { out.push(escapeGlob(inner[i + 1] ?? '')); i += 2; continue; } if (ch === '$' && i + 1 < n) { const ref = scanParameter(inner, i); if (ref !== null) { out.push(escapeGlob(lookupVar(ref[0], session, callStack))); i = ref[1]; continue; } } out.push(escapeGlob(ch)); i += 1; } return out.join(''); } // Render an opaque pattern token with bash quoting semantics. // Pattern operands (${f%$ext}, ${v#x"a*"}) arrive as opaque `regex` nodes // the parser does not parse further, but bash still honors quoting inside // them: quoted segments (single, double, or ANSI-C) match literally, a // backslash binds the next character, an unquoted $-reference splices a // live pattern while a double-quoted one splices literal text, and every // other character - glob syntax included - stays live. Literal text is // spelled in one-character classes because fnmatch has no escape character. function patternText(text, session, callStack) { if (!text.includes('$') && !text.includes('\\') && !text.includes("'") && !text.includes('"')) { return text; } const out = []; let i = 0; const n = text.length; while (i < n) { const ch = text[i] ?? ''; if (ch === '\\' && i + 1 < n) { out.push(escapeGlob(text[i + 1] ?? '')); i += 2; continue; } if (ch === "'") { const end = text.indexOf("'", i + 1); if (end !== -1) { out.push(escapeGlob(text.slice(i + 1, end))); i = end + 1; continue; } } if (ch === '"') { const end = escapedFind(text, i + 1, '"'); if (end !== -1) { out.push(dquotedPattern(text.slice(i + 1, end), session, callStack)); i = end + 1; continue; } } if (ch === '$' && i + 1 < n) { if (text[i + 1] === "'") { const end = escapedFind(text, i + 2, "'"); if (end !== -1) { out.push(escapeGlob(decodeAnsiC(text.slice(i + 2, end)))); i = end + 1; continue; } } const ref = scanParameter(text, i); if (ref !== null) { out.push(lookupVar(ref[0], session, callStack)); i = ref[1]; continue; } } out.push(ch); i += 1; } return out.join(''); } /** A nested node's text where nothing splits, glob marks removed. */ async function childText(expandChild, node) { return chunksText(await expandChild(node, false)); } async function patternOperand(node, expandChild, session, callStack) { if (node.type === NT.CONCATENATION) { return patternGroup([...sourceParts(node)], expandChild, session, callStack); } if (QUOTED_ARG_TYPES.has(node.type)) { // Quoted pattern text matches literally, the same rule case // patterns follow: the value, inner expansions included, is // escaped so its glob characters match themselves. return escapeGlob(await childText(expandChild, node)); } if (LITERAL_ARG_TYPES.has(node.type)) { return patternText(node.text, session, callStack); } return childText(expandChild, node); } // Expand one pattern operand, the source text between its nodes included. // That text is only ever the scanner's extras: blanks, a line // continuation, which vanishes, and an escaped blank, which is the blank // as in an unquoted word. async function patternGroup(parts, expandChild, session, callStack) { const pieces = []; for (const part of parts) { pieces.push(typeof part === 'string' ? part.replaceAll('\\\n', '').replaceAll('\\', '') : await patternOperand(part, expandChild, session, callStack)); } return pieces.join(''); } /** * Literal operand text as pieces; the rules are `wordChunks`'. `home` is * what a leading `~` names, null where no tilde prefix can stand. */ async function operandLiteral(text, quoted, session, callStack, home) { if (!quoted && home !== null && !text.includes('\\') && !text.includes('$')) { const tilde = expandTilde(text, home); if (tilde !== text) return [piece(tilde)]; } const out = []; let run = ''; const flush = () => { if (run !== '') out.push(valuePiece(run, quoted)); run = ''; }; let index = 0; while (index < text.length) { const char = text[index] ?? ''; if (char === '\\' && index + 1 < text.length) { const escaped = text[index + 1] ?? ''; index += 2; if (escaped === '\n') continue; if (!quoted) { flush(); out.push(piece(markGlobs(escaped))); } else if (OPERAND_DQUOTE_ESCAPES.has(escaped)) { run += escaped; } else { run += char + escaped; } continue; } const ref = char === '$' ? scanParameter(text, index) : null; if (ref !== null) { flush(); for (const c of await parameterChunks(ref[0], session, callStack, quoted)) out.push(c); index = ref[1]; continue; } run += char; index += 1; } flush(); return out; } /** An operand word's source parts, concatenations opened up. */ function* flatParts(parts) { for (const part of parts) { if (typeof part !== 'string' && part.type === NT.CONCATENATION) { yield* flatParts([...sourceParts(part)]); } else { yield part; } } } /** Text whose every backslash quotes the character after it. */ function unescapeAll(text) { let out = ''; let index = 0; while (index < text.length) { if (text[index] === '\\' && index + 1 < text.length) { if (text[index + 1] !== '\n') out += text[index + 1] ?? ''; index += 2; continue; } out += text[index] ?? ''; index += 1; } return out; } /** * A double-quoted string inside the word of a quoted expansion. bash * reads the inner pair as leaving the outer quotes, so a backslash there * quotes any character, as in an unquoted word (`"${u:-"a\ b"}"` is * `a b`); the text is quoted all the same, a single quote and a glob * character literal and nothing splitting. */ async function nestedString(node, expandChild) { const out = [piece('')]; const inside = node.text.slice(1, -1); for (const part of sourceParts(node)) { let text; if (typeof part === 'string') text = part; else if (part.type === NT.STRING_CONTENT) text = part.text; else if (part.type === NT.DQUOTE) text = part.text.slice(0, -1); else { for (const c of await named(inside, expandChild(part, true))) out.push(c); continue; } out.push(piece(markGlobs(unescapeAll(text)))); } return out; } /** * Expand an operator's word to pieces of the word it stands in. * * Inside double quotes the word follows double-quote rules: a backslash * escapes only `$ ` " \ }` and a newline, a single-quoted string is * literal text, quotes and all, and nothing splits. Unquoted, an escaped * character and a quoted string are quoted text, which never splits, * while the word's literal text and its expansions split the way the * expansion's value would. The literal text runs between nodes are read * whole, source text between nodes included, since the grammar can split * one escape across two of them (`\\` arrives as a gap and a word); `$*`, * `$#` and the other special parameters arrive as literal text too, which * the grammar leaves unlexed inside an operand. */ async function wordChunks(parts, expandChild, quoted, session, callStack) { const home = homeDir(session); const out = []; let literal = ''; for (const part of flatParts(parts)) { if (typeof part === 'string' || LITERAL_ARG_TYPES.has(part.type)) { literal += typeof part === 'string' ? part : part.text; continue; } for (const c of await operandLiteral(literal, quoted, session, callStack, out.length === 0 ? home : null)) out.push(c); literal = ''; if (quoted && part.type === NT.RAW_STRING) out.push(piece(markGlobs(part.text))); else { const chunks = quoted && part.type === NT.STRING ? await nestedString(part, expandChild) : await expandChild(part, quoted); for (const c of chunks) out.push(c); } } for (const c of await operandLiteral(literal, quoted, session, callStack, out.length === 0 ? home : null)) out.push(c); return out; } async function globStrip(value, pattern, greedy, prefix, extglob = false, signal) { if (pattern === '') return value; const glob = new Glob(pattern, extglob, false, 'shell', signal); try { const offset = await glob.extent(value, prefix, greedy); if (offset === null) return value; const chars = Array.from(value); return (prefix ? chars.slice(offset) : chars.slice(0, offset)).join(''); } finally { glob.close(); } } async function globReplace(value, pattern, replacement, replaceAll, anchor, extglob = false, signal) { if (pattern === '') return value; const glob = new Glob(pattern, extglob, false, 'shell', signal); try { const spans = await glob.spans(value, replaceAll, anchor); const chars = Array.from(value); const out = []; let cursor = 0; // A pattern that matches every character of a long value is millions // of spans: the rebuild yields between them, as the search that found // them did. const budget = new YieldBudget(signal); for (const [start, end] of spans) { out.push(chars.slice(cursor, start).join(''), replacement); cursor = end; await budget.run(); } out.push(chars.slice(cursor).join('')); return out.join(''); } finally { glob.close(); } } async function caseMod(op, val, pattern, extglob = false, signal) { if (val === '') return val; const all = op === '^^' || op === ',,'; const glob = pattern === '' ? null : new Glob(pattern, extglob, false, 'shell', signal); try { let out = ''; for (const [i, ch] of Array.from(val).entries()) { if ((!all && i > 0) || (glob !== null && !(await glob.matchesAsync(ch)))) { out += ch; continue; } out += op === '^' || op === '^^' ? ch.toUpperCase() : ch.toLowerCase(); } return out; } finally { glob?.close(); } } /** Evaluate and apply one substring bound before expanding the next. */ class ArithOperand { session; view; ref = ''; constructor(session, view) { this.session = session; this.view = view; } async value(text) { const nounset = this.session.shellOptions.nounset === true; try { return Number(await landedArith(this.session, this.view ?? null, text, landArithWrites, nounset)); } catch (err) { if (err instanceof ReadonlyVariableError) throw err.signal(); if (err instanceof ArithError) throw err.signal(this.ref); throw err; } } } /** Expand/evaluate bounds left to right, stopping at an invalid offset. */ async function sliceBounds(node, expandChild, operand, extent, allowEnd = false) { const values = []; for await (const text of substringOperands(node, (n) => childText(expandChild, n))) { let value = await operand.value(text); if (values.length === 0) { if (value < 0) value += extent; if (value < 0 || value > extent || (value === extent && !allowEnd)) return null; } values.push(value); } return [values[0] ?? 0, values[1] ?? null]; } async function substring(val, node, expandChild, operand) { const bounds = await sliceBounds(node, expandChild, operand, val.length, true); if (bounds === null) return ''; const [offset, length] = bounds; if (length === null) return val.slice(offset); if (length < 0) return val.slice(offset, Math.max(offset, val.length + length)); return val.slice(offset, offset + length); } async function sliceArray(arr, node, expandChild, operand) { const bounds = await sliceBounds(node, expandChild, operand, arrayExtent(arr)); return bounds === null ? [] : arraySlice(arr, ...bounds); } // Whether a parsed "${...}" splats one word per element. Two spellings // mean the same thing: an `@` subscript on a name (`${a[@]}`) and the // positional parameters themselves (`${@}`, which bash word-splits // exactly like the bare `$@`). `${*}` and `${a[*]}` are excluded // because they join. function isAtSplatParse(p) { if (p.subscript === '@') return true; return p.subscript === null && p.varName === '@'; } /** * Whether an expansion is a `$@`-style splat. Inside double quotes such * a splat yields one field per element and no field at all when there is * none: `"$@"` with no parameters is no word, where `"$*"` is one empty * word. `${#a[@]}` is a count, so it is one word like any other. */ export function isAtSplat(node) { if (node.type === NT.SIMPLE_EXPANSION) return node.text.trim() === '$@'; if (node.type !== NT.EXPANSION) return false; const p = parseBraces(node); return isAtSplatParse(p) && !p.lengthOp; } const SUBSCRIPT_LITERAL_TYPES = new Set([NT.WORD, NT.NUMBER]); // The operators whose word bash expands only once the parameter's state // selects it (a default, an alternate, an assignment, a message). const LAZY_OPS = new Set(['?', ':?', '=', ':=', ':-', '-', ':+', '+']); /** The word of a conditional operator, expanded now that it is needed. */ async function operatorWord(p, expandChild, quoted, session, callStack) { const group = p.groups[0]; if (group === undefined) return []; return named(source(group), wordChunks(group, expandChild, quoted, session, callStack)); } /** An operand's text as written, the word a bad substitution names. */ function source(parts) { return parts.map((part) => (typeof part === 'string' ? part : part.text)).join(''); } /** * An operator's word standing in for a splat. A quoted splat that * selects its word yields that word even when it is empty: `"${e[@]:-}"` * is one empty word where `"${e[@]}"` is none. */ function wordResult(chunks, quoted) { return quoted ? [piece(''), ...chunks] : chunks; } /** * The death of a line whose `${v:?word}` found v unset or null. The word * is the message, read with unquoted rules even inside double quotes, as * bash reads it. GNU: fatal at top level with status 127; a containing * subshell/pipeline segment reports 1. A subscripted reference is named * whole: `bash: m[zz]: nope`. */ async function unsetError(p, expandChild, session, callStack) { const word = chunksText(await operatorWord(p, expandChild, false, session, callStack)); const message = word !== '' ? word : p.op === '?' ? 'parameter not set' : 'parameter null or not set'; const ref = p.subscript === null ? (p.varName ?? '') : `${p.varName ?? ''}[${p.subscript}]`; return new ExitSignal(127, encodeText(`bash: ${ref}: ${message}\n`), null, 1); } /** The refusal of a `:=` that names no single element. */ function badSubscript(p) { return new DiscardSignal(encodeText(`bash: ${p.varName ?? ''}[${p.subscript ?? ''}]: bad array subscript\n`)); } /** * The associative key one subscript spells. * * A purely literal subscript keeps its raw spelling, spaces included, * which is what bash stores for `m[ k ]`; anything carrying an * expansion or quoting expands node by node (`${m[$k]}`, `${m["a b"]}`) * so substitution and quote removal land. */ async function expandSubscriptKey(p, expandChild) { const nodes = p.subscriptNodes; if (nodes.length === 0 || nodes.every((n) => SUBSCRIPT_LITERAL_TYPES.has(n.type))) { return p.subscript ?? ''; } const parts = []; for (const n of nodes) parts.push(await childText(expandChild, n)); return parts.join(''); } async function valueOp(op, val, groups, extglob = false, signal) { if (STRIP_OPS.has(op)) { const pattern = groups[0] ?? ''; return globStrip(val, pattern, op === '##' || op === '%%', op === '#' || op === '##', extglob, signal); } if (REPLACE_OPS.has(op)) { const pattern = groups[0] ?? ''; const replacement = groups[1] ?? ''; let anchor = null; if (op === '/#') anchor = '#'; else if (op === '/%') anchor = '%'; return globReplace(val, pattern, replacement, op === '//', anchor, extglob, signal); } if (CASE_OPS.has(op)) { return caseMod(op, val, groups[0] ?? '', extglob, signal); } return val; } /** * The line's death for a refused expansion-time write: the gate's own * reason discards the line, as a readonly name's does, and so does the * `-i` coercion refusing the text, as `n=1+` does. */ function writeRefusal(err) { return err instanceof PolicyDenied ? new DiscardSignal(encodeText(`bash: ${err.message}\n`)) : err.signal('', true); } /** * `subscriptIndex` in the expansion's voice: the subscript's assignments * land as the index resolves (`${a[x=3]}` leaves x at 3, `${a[RANDOM=42]}` * seeds), and a refused one dies the way `expansionWrite`'s does. */ async function expansionIndex(session, view, subscript) { try { return await subscriptIndex(session, subscript, view ?? null); } catch (err) { if (err instanceof PolicyDenied || err instanceof ArithError) throw writeRefusal(err); throw err; } } /** * Land an arithmetic expansion's assignments and settle its draws. Each * write goes through `expansionWrite` in evaluation order; then the * `RANDOM` reader replays the draws the expression made after it seeded * the generator, now that the session view holds the seed. One entry point for a * completed expression and for one that failed partway, since bash * binds each assignment as it is made. */ export async function landArithWrites(session, view, writes, reader) { try { for (const write of writes) { await expansionWrite(session, view ?? undefined, write.name, write.key, write.value); } } finally { reader.settle(); } } /** * One expansion-time write, through the session view. * * `${X:=d}`, `${a[i]:=d}` and `$((X=5))` are assignments the shell * performs while expanding a word rather than while running a command, * and they used to land on the raw session env. That made a `preSession` * rule one `${X:=d}` away from irrelevant: a deployment refusing `AWS_*` * still had `${AWS_PROFILE:=prod}` write it. They go through the session view * now, so one rule covers every spelling. * * Without a session view (a unit test outside a workspace) the write lands * directly, with the hidden half still applied: skipping that would let * the write-back clobber a value the host's wiring reads. * * The element mechanics are `assignElement`'s: a bare name (null key) * over an array takes the write at element 0 and keeps its other * elements (`a=(1 2 3)` then `$((a=5))` leaves `5 2 3`), an associative * one writes the literal key "0", and a subscripted target arrives with * its key already canonical. Throws ExitSignal when the name is hidden, * a preSession rule refuses, the subscript is bad, or the name carries * `-i` and the text does not evaluate (the line dies with status 1, the * shape `${var:?}` uses); a readonly name discards the line too, and ends a * `( )` subshell with `contained`. */ export async function expansionWrite(session, view, name, key, value, contained = 1) { guardExpansionWrite(session, name); let status; try { status = await assignElement(session, view ?? null, name, key, value); } catch (err) { // A PolicyDenied is the gate; an ArithError is the name carrying // `-i` refusing the text. Both die as `n=1+` does, in that voice. if (!(err instanceof PolicyDenied) && !(err instanceof ArithError)) throw err; throw writeRefusal(err); } if (status === 'readonly') { throw new DiscardSignal(encodeText(`bash: ${name}: readonly variable\n`), contained); } if (status !== 'ok') { throw new DiscardSignal(encodeText(`bash: ${name}[${key ?? ''}]: bad array subscript\n`)); } } /** * Expand `${VAR}`, `${VAR<op>...}`, `${a[i]}`, `${#a[@]}`, etc. to pieces. * * An offset, length or slice bound is arithmetic and may assign * (`${v:x=1:y=2}`) or seed (`${v:RANDOM%10:1}`); those land through the * session view before the next bound expands, including its nested substitutions. * `quoted` says whether the expansion sits inside double quotes, which * decides the rules an operator's word follows and the shape a * `$*`-style splat takes. */ export async function expandBraces(node, session, callStack, expandChild, view, quoted = false, signal) { return expandBracesIn(node, session, callStack, expandChild, view, new ArithOperand(session, view), quoted, signal); } async function expandBracesIn(node, session, callStack, expandChild, view, operand, quoted, signal) { const text = node.text.trimStart(); if (badSubstitution(text)) throw new BadSubstitution(text); const p = parseBraces(node); const env = visibleEnv(session); const arrays = visibleArrays(session); operand.ref = (p.varName ?? '') + (p.subscript === null ? '' : `[${p.subscript}]`); // A conditional operator's word expands only if the parameter's state // selects it, as bash's does: `${RANDOM:-$RANDOM}` draws once and // `${x:-$(cmd)}` runs cmd only when x is unset. Every other operator's // words are needed whatever the value, and expand here: the pattern as // a pattern, the replacement with unquoted rules even inside double // quotes, as bash reads it. const groups = []; if (p.op !== ':' && (p.op === null || !LAZY_OPS.has(p.op))) { for (let gi = 0; gi < p.groups.length; gi++) { const group = p.groups[gi] ?? []; if (gi === 0 && p.op !== null && PATTERN_OPS.has(p.op)) { groups.push(await named(source(group), patternGroup(group, expandChild, session, callStack))); } else { groups.push(chunksText(await named(source(group), wordChunks(group, expandChild, false, session, callStack)))); } } } // A subscripted reference reads and writes through a name reference // the way a bare one does, so the target is resolved once here. const baseName = p.varName === null ? null : deref(session, p.varName) || p.varName; const amap = baseName !== null ? visibleAssocs(session)[baseName] : undefined; const splat = splatSource(p, session, callStack, env, arrays, baseName, amap); if (splat !== null) { return expandSplat(p, splat[0], splat[1], node, expandChild, operand, session, callStack, quoted, groups, signal); } let val = ''; let varInEnv = false; // The subscript as `:=` would write it: the key itself for an // associative name, the resolved index for an indexed one, null for a // negative index past the front, which bash refuses to assign through. let writeKey = null; if (p.subscript !== null && baseName !== null && amap !== undefined) { // A key, not an expression: `${m[1+1]}` reads the key "1+1", never // element 2. An empty key reads as unset (GNU warns "bad array // subscript" on stderr and expands empty; expansion has no warning // channel, so the empty answer stands alone). const key = await named(p.subscript, expandSubscriptKey(p, expandChild)); val = amap[key] ?? ''; varInEnv = amap[key] !== undefined; writeKey = key; } else if (p.subscript !== null && baseName !== null) { let arr = arrays[baseName]; if (arr === undefined) { // A scalar is element 0 of a one-element array, even when empty: // ${#x[@]} is 1 for x="" but 0 for an unset name. const scalar = env[baseName]; arr = scalar === undefined ? [] : [scalar]; } const subText = await named(p.subscript, expandSubscriptKey(p, expandChild)); let idx = await expansionIndex(session, view, subText); if (idx < 0) idx += arrayExtent(arr); val = arrayGet(arr, idx); varInEnv = arrayHas(arr, idx); if (idx >= 0) writeKey = String(idx); } else if (p.varName !== null) { if (callStack) { const localVal = callStack.getLocal(p.varName); if (localVal !== null) { val = localVal; varInEnv = true; } } if (!varInEnv && p.varName in arrays) { val = arrayGet(arrays[p.varName] ?? [], 0); varInEnv = true; } if (!varInEnv && amap !== undefined) { // A bare `$m` on an associative array is `${m["0"]}`, the // literal key, exactly as bash reads it. val = amap['0'] ?? ''; varInEnv = amap['0'] !== undefined; } if (!varInEnv && p.varName === RANDOM) { // `${RANDOM}` draws as `$RANDOM` does: the env holds the last word, // which a read must not hand back unchanged. const drawn = nextRandom(session, env[RANDOM]); if (drawn !== null) { val = String(drawn); varInEnv = true; } } if (!varInEnv && p.varName in env) { val = env[p.varName] ?? ''; varInEnv = true; } if (!varInEnv) { // Specials, positionals, PWD/HOME fall back to the shared // lookup; set-ness follows value presence, except that a // positional parameter is set whenever the count reaches it, // empty or not (`set -- ""` sets $1). val = lookupVar(p.varName, session, callStack, p.op === null || !UNSET_GUARD_OPS.has(p.op)); varInEnv = val !== '' || positionalSet(p.varName, session, callStack); } } // `set -u` refuses an element or key that holds nothing, named as typed // (`a[i]`, `m[$k]`), unless the operator handles unset itself; a length // is 0 (bash 5.2.37). A scalar's refusal is lookupVar's. if (p.subscript !== null && !varInEnv && session.shellOptions.nounset === true && !p.lengthOp && !p.indirectOp && (p.op === null || !UNSET_GUARD_OPS.has(p.op))) { throw new UnboundVariable(`${p.varName ?? ''}[${p.subscript}]`); } if (p.indirectOp) { // `${!r}` on a name reference is the target's *name*, not an // indirection through the value. const target = p.varName !== null ? namerefTarget(session, p.varName) : null; if (target !== null) return [valuePiece(target, quoted)]; return [valuePiece(val !== '' ? lookupVar(val, session, callStack) : '', quoted)]; } if (p.lengthOp) return [valuePiece(String(val.length), quoted)]; if (p.op === null) return [valuePiece(val, quoted)]; if (p.op === '?' || p.op === ':?') { const triggered = p.op === '?' ? !varInEnv : val === ''; if (!triggered) return [valuePiece(val, quoted)]; throw await unsetError(p, expandChild, session, callStack); } if (p.op === '=' || p.op === ':=') { const triggered = p.op === '=' ? !varInEnv : val === ''; if (!triggered) return [valuePiece(val, quoted)]; const defaultVal = chunksText(await operatorWord(p, expandChild, quoted, session, callStack)); // A refused default ends a `( )` subshell, or a forked compound command, // with 2, unless `set -e` ends it first with 1; a line loop reads it as // a discard. const contained = callStack?.paren === true && session.shellOptions.errexit !== true ? 2 : 1; if (p.varName !== null && p.subscript !== null) { // The default lands on the element the reference named, never on // element 0: `${m[k]:=v}` writes key k and `${a[3]:=v}` writes // index 3, as bash does. An index before the front is refused in // bash's words. if (writeKey === null) throw badSubscript(p); await expansionWrite(session, view, p.varName, writeKey, defaultVal, contained); } else if (callStack !== null && callStack.getLocal(p.varName ?? '') !== null) { callStack.setLocal(p.varName ?? '', defaultVal); } else if (p.varName !== null) { await expansionWrite(session, view, p.varName, null, defaultVal, contained); } return [valuePiece(defaultVal, quoted)]; } if (p.op === ':-' || p.op === '-') { if (p.op === ':-' ? val !== '' : varInEnv) return [valuePiece(val, quoted)]; return operatorWord(p, expandChild, quoted, session, callStack); } if (p.op === ':+' || p.op === '+') { if (!(p.op === ':+' ? val !== '' : varInEnv)) return []; return operatorWord(p, expandChild, quoted, session, callStack); } if (p.op === ':') { // bash slices only a set parameter: an unset one expands empty and // its bounds are never evaluated, so `${a[i]:.2f}` is nothing while // a[i] is unset and an arithmetic error once it is set (5.2.37). if (!varInEnv) return [valuePiece('', quoted)]; return [valuePiece(await substring(val, node, expandChild, operand), quoted)]; } return [ valuePiece(await valueOp(p.op, val, groups, session.shopts.extglob ?? false, signal), quoted), ]; } /** * The elements a `$@`/`$*`-style splat walks, and their keys. * * The positional parameters for `${@...}` and `${*...}`, which a slice * numbers from 1 so that index 0 is the shell's own name (`"${@:0}"` * yields it ahead of $1; pinned on bash 5.2.37, macOS bash 3.2 drops * it). Every element of an array for `${a[@]...}` and `${a[*]...}`, * holes left by `unset a[i]` included so a slice keeps its indices; an * associative array walks its keys sorted, since bash's hash order is * unpredictable and a deterministic answer beats reproducing noise. A * scalar is element 0 of a one-element array. Null for any other * expansion. */ function splatSource(p, session, callStack, env, arrays, baseName, amap) { if (p.subscript === null) { if (p.varName !== '@' && p.varName !== '*') return null; const params = positionalParams(session, callStack); const keys = params.map((_, i) => String(i + 1)); return [p.op === ':' ? [session.argv0, ...params] : [...params], keys]; } if (baseName === null || (p.subscript !== '@' && p.subscript !== '*')) return null; if (amap !== undefined) { const keys = Object.keys(amap).sort(compareCodePoints); return [keys.map((k) => amap[k] ?? ''), keys]; } let arr = arrays[baseName]; if (arr === undefined) { const scalar = env[baseName]; arr = scalar === undefined ? [] : [scalar]; } return [arr, arrayIndices(arr).map((i) => String(i))]; } /** * Expand a splat: one field per element, whatever the operator. * * `@` keeps its elements apart inside double quotes too, and a quoted * `*` joins them on IFS's first character. A slice, the per-element * strip, replace and case operators and `${!a[@]}`'s keys all stay a * splat; `${#a[@]}` is the count. A conditional operator tests the * elements as one: set when there is any, null when they join to * nothing, a space joining `@`'s as IFS joins `*`'s, so `("" "")` is * null for `*` alone under `IFS=`. Unselected, `:+` yields no field over * no elements and one empty field over empty ones, as bash does. */ async function expandSplat(p, arr, keys, node, expandChild, operand, session, callStack, quoted, groups, signal) { const star = (p.subscript ?? p.varName) === '*'; const joiner = star ? ifsJoiner(ifsValue(session, callStack)) : ' '; const values = arrayValues(arr); if (p.lengthOp) return [valuePiece(String(values.length), quoted)]; let items = values; const op = p.op; if (p.indirectOp) { items = keys; } else if (op === ':') { // An array with no element is unset to a slice, as a scalar is: // empty, bounds unevaluated. The positional parameters always // evaluate theirs, since `$0` stands at their front. const unset = p.subscript !== null && values.length === 0; items = unset ? [] : await sliceArray(arr, node, expandChild, operand); } else if (op !== null && (STRIP_OPS.has(op) || REPLACE_OPS.has(op) || CASE_OPS.has(op))) { items = []; for (const el of values) items.push(await valueOp(op, el, groups, session.shopts.extglob ?? false, signal)); } else if (op !== null && UNSET_GUARD_OPS.has(op)) { const triggered = op === '-' || op === '+' || op === '=' || op === '?' ? values.length === 0 : values.join(joiner) === ''; if (op === '+' || op === ':+') { if (triggered) return values.length > 0 ? await splatChunks([''], joiner, quoted) : []; return wordResult(await operatorWord(p, expandChild, quoted, session, callStack), quoted); } if (triggered && (op === '-' || op === ':-')) { return wordResult(await operatorWord(p, expandChild, quoted, session, callStack), quoted); } if (triggered && (op === '?' || op === ':?')) { throw await unsetError(p, expandChild, session, callStack); } if (triggered && p.subscript !== null) throw badSubscript(p); if (triggered) { throw new DiscardSignal(encodeText(`bash: $${p.varName ?? ''}: cannot assign in this way\n`)); } } if (star && quoted) return [valuePiece(items.join(joiner), true)]; return await splatChunks(items, joiner, quoted); } //# sourceMappingURL=variable.js.map