From 783df844f1f36dc418fc951f33a331e364c361ab Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Tue, 18 Aug 2026 17:44:24 -0400 Subject: [PATCH] Pass 6 --- packages/bare-os-booter/index.js | 123 +-- packages/bare-os-booter/lib/cli-readline.js | 135 +++ packages/bare-os-booter/lib/shell-alias.js | 209 ++++ packages/bare-os-booter/lib/shell-expand.js | 215 ++++- packages/bare-os-booter/lib/shell-parse.js | 418 ++++++++ packages/bare-os-booter/lib/shell-token.js | 22 + packages/bare-os-booter/lib/shell.js | 893 +----------------- packages/bare-os-booter/lib/vfs-policy.js | 43 + packages/bare-os-booter/lib/vfs-warm-cache.js | 241 +++++ packages/bare-os-booter/lib/vfs.js | 259 +---- packages/bare-os-booter/test.posix-helpers.js | 88 +- 11 files changed, 1443 insertions(+), 1203 deletions(-) create mode 100644 packages/bare-os-booter/lib/shell-alias.js create mode 100644 packages/bare-os-booter/lib/shell-parse.js create mode 100644 packages/bare-os-booter/lib/shell-token.js create mode 100644 packages/bare-os-booter/lib/vfs-warm-cache.js diff --git a/packages/bare-os-booter/index.js b/packages/bare-os-booter/index.js index 2c483ee8..193a9a0c 100644 --- a/packages/bare-os-booter/index.js +++ b/packages/bare-os-booter/index.js @@ -127,7 +127,8 @@ import { packageRootDir, defaultBootCorestorePath } from './lib/paths.js' import { createBareReadlineQuestion, createStreamLineReader, - looksLikeInteractiveStdin + looksLikeInteractiveStdin, + createSessionReadMaskedLine } from './lib/cli-readline.js' import { resolveStdio } from './lib/resolve-stdio.js' import { createKernelReplSession } from './lib/repl-session.js' @@ -7204,118 +7205,14 @@ async function executeKernel(disk, store, swarm, initSource) { ctx.bareOsRegisterSuspendHook(() => bareInitdSuspendForBareMobile(ctx)) ctx.bareOsRegisterResumeHook(() => bareInitdResumeAfterBareMobile(ctx)) - /** - * Read one masked line from session stdin/stdout (shows * for printable chars). - * @param {string} prompt - * @returns {Promise} - */ - const sessionReadMaskedLine = async (prompt) => { - const stdin = sessionStdin - const stdout = sessionStdout - if ( - !stdin || - !stdout || - typeof stdin.on !== 'function' || - typeof stdout.write !== 'function' - ) { - return sessionReadLine(prompt) - } - const ttyIn = - /** @type {{ isTTY?: boolean, setRawMode?: (v: boolean) => void, resume?: () => void }} */ ( - stdin - ) - if (!ttyIn.isTTY || typeof ttyIn.setRawMode !== 'function') { - return sessionReadLine(prompt) - } - if (session.fishStdin) suspendFishStdinForSubprocess(session.fishStdin) - return await new Promise((resolve) => { - /** @type {string[]} */ - const chars = [] - let done = false - function cleanup() { - stdin.removeListener('data', onData) - stdin.removeListener('end', onEnd) - stdin.removeListener('error', onEnd) - try { - ttyIn.setRawMode(false) - } catch { - /* ignore */ - } - if (session.fishStdin) resumeFishStdinAfterSubprocess(session.fishStdin) - } - function finish(out) { - if (done) return - done = true - cleanup() - try { - stdout.write('\n') - } catch { - /* ignore */ - } - resolve(out) - } - /** @param {string | Uint8Array | Buffer} chunk */ - function onData(chunk) { - if (done) return - let s = '' - if (typeof chunk === 'string') s = chunk - else if (chunk instanceof Uint8Array) - s = new TextDecoder().decode(chunk) - else if ( - typeof Buffer !== 'undefined' && - typeof Buffer.isBuffer === 'function' && - Buffer.isBuffer(chunk) - ) { - s = chunk.toString('utf8') - } else s = String(chunk) - for (const ch of s) { - const code = ch.charCodeAt(0) - if (ch === '\r' || ch === '\n') { - finish(chars.join('')) - return - } - if (ch === '\u0003') { - finish(null) - return - } - if (ch === '\u007f' || ch === '\b') { - if (chars.length) { - chars.pop() - try { - stdout.write('\b \b') - } catch { - /* ignore */ - } - } - continue - } - if (code >= 32 && code !== 127) { - chars.push(ch) - try { - stdout.write('*') - } catch { - /* ignore */ - } - } - } - } - function onEnd() { - finish(chars.join('')) - } - try { - stdout.write('\x1b[?25h\x1b[0m' + String(prompt || '')) - ttyIn.setRawMode(true) - } catch { - if (session.fishStdin) resumeFishStdinAfterSubprocess(session.fishStdin) - resolve(sessionReadLine(prompt)) - return - } - stdin.on('data', onData) - stdin.once('end', onEnd) - stdin.once('error', onEnd) - if (typeof ttyIn.resume === 'function') ttyIn.resume() - }) - } + const sessionReadMaskedLine = createSessionReadMaskedLine({ + stdin: sessionStdin, + stdout: sessionStdout, + fallbackReadLine: sessionReadLine, + fishStdin: session.fishStdin, + suspendFishStdin: suspendFishStdinForSubprocess, + resumeFishStdin: resumeFishStdinAfterSubprocess + }) /** * @param {string} [prompt] diff --git a/packages/bare-os-booter/lib/cli-readline.js b/packages/bare-os-booter/lib/cli-readline.js index 73e6edf5..8d75dfc3 100644 --- a/packages/bare-os-booter/lib/cli-readline.js +++ b/packages/bare-os-booter/lib/cli-readline.js @@ -12,6 +12,141 @@ import { shouldPersistShellHistoryCommand } from './fish-readline.js' +/** + * Read one masked line from session stdin/stdout (shows * for printable chars). + * @param {{ + * stdin: unknown, + * stdout: unknown, + * fallbackReadLine: (prompt: string) => Promise, + * fishStdin?: unknown, + * suspendFishStdin?: (fish: unknown) => void, + * resumeFishStdin?: (fish: unknown) => void + * }} deps + */ +export function createSessionReadMaskedLine(deps) { + const { + stdin, + stdout, + fallbackReadLine, + fishStdin = null, + suspendFishStdin = null, + resumeFishStdin = null + } = deps + + /** + * @param {string} prompt + * @returns {Promise} + */ + return async function sessionReadMaskedLine(prompt) { + if ( + !stdin || + !stdout || + typeof stdin.on !== 'function' || + typeof stdout.write !== 'function' + ) { + return fallbackReadLine(prompt) + } + const ttyIn = + /** @type {{ isTTY?: boolean, setRawMode?: (v: boolean) => void, resume?: () => void }} */ ( + stdin + ) + if (!ttyIn.isTTY || typeof ttyIn.setRawMode !== 'function') { + return fallbackReadLine(prompt) + } + if (fishStdin && typeof suspendFishStdin === 'function') + suspendFishStdin(fishStdin) + return await new Promise((resolve) => { + /** @type {string[]} */ + const chars = [] + let done = false + function cleanup() { + stdin.removeListener('data', onData) + stdin.removeListener('end', onEnd) + stdin.removeListener('error', onEnd) + try { + ttyIn.setRawMode(false) + } catch { + /* ignore */ + } + if (fishStdin && typeof resumeFishStdin === 'function') + resumeFishStdin(fishStdin) + } + function finish(out) { + if (done) return + done = true + cleanup() + try { + stdout.write('\n') + } catch { + /* ignore */ + } + resolve(out) + } + /** @param {string | Uint8Array | Buffer} chunk */ + function onData(chunk) { + if (done) return + let s = '' + if (typeof chunk === 'string') s = chunk + else if (chunk instanceof Uint8Array) + s = new TextDecoder().decode(chunk) + else if ( + typeof Buffer !== 'undefined' && + typeof Buffer.isBuffer === 'function' && + Buffer.isBuffer(chunk) + ) { + s = chunk.toString('utf8') + } else s = String(chunk) + for (const ch of s) { + const code = ch.charCodeAt(0) + if (ch === '\r' || ch === '\n') { + finish(chars.join('')) + return + } + if (ch === '\u0003') { + finish(null) + return + } + if (ch === '\u007f' || ch === '\b') { + if (chars.length) { + chars.pop() + try { + stdout.write('\b \b') + } catch { + /* ignore */ + } + } + continue + } + if (code >= 32 && code !== 127) { + chars.push(ch) + try { + stdout.write('*') + } catch { + /* ignore */ + } + } + } + } + function onEnd() { + finish(chars.join('')) + } + try { + stdout.write('\x1b[?25h\x1b[0m' + String(prompt || '')) + ttyIn.setRawMode(true) + } catch { + if (fishStdin && typeof resumeFishStdin === 'function') + resumeFishStdin(fishStdin) + resolve(fallbackReadLine(prompt)) + return + } + stdin.on('data', onData) + stdin.once('end', onEnd) + stdin.once('error', onEnd) + if (typeof ttyIn.resume === 'function') ttyIn.resume() + }) + } +} + /** * Strip C0 controls; apply BS (0x08) and DEL (0x7F) for raw SSH line fallback. * @param {string} s diff --git a/packages/bare-os-booter/lib/shell-alias.js b/packages/bare-os-booter/lib/shell-alias.js new file mode 100644 index 00000000..a1c0c0ee --- /dev/null +++ b/packages/bare-os-booter/lib/shell-alias.js @@ -0,0 +1,209 @@ +/** + * Default aliases, alias/unalias builtins, and ~/.barerc loader. + */ +import { stripAliasQuotes } from './shell-syntax.js' +import { tokenize } from './shell-token.js' +import { expandWord } from './shell-expand.js' +import { + applyBareOsThemeFromEnv, + bareOsGetThemePreset +} from './bare-os-theme-presets.js' + +/** Max alias indirections (prevents cycles). */ +const MAX_ALIAS_DEPTH = 16 + +/** + * 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 +} + +/** + * @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`). + */ +export 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) +} diff --git a/packages/bare-os-booter/lib/shell-expand.js b/packages/bare-os-booter/lib/shell-expand.js index 3501c26b..8601bc28 100644 --- a/packages/bare-os-booter/lib/shell-expand.js +++ b/packages/bare-os-booter/lib/shell-expand.js @@ -1,10 +1,14 @@ /** - * Shell parameter / arithmetic word expansion (`$VAR`, `${…}`, `$((…))`). + * Shell parameter / arithmetic word expansion (`$VAR`, `${…}`, `$((…))`) + * and command-substitution / pathname expansion of tokenized words. */ import { findArithmeticClose } from './shell-lex.js' import { shellCheckUnboundParam } from './shell-nounset.js' import { bareOsEvalArithmeticExpr } from './shell-arithmetic.js' import { BARE_OS_EXIT_STATUS_ENV } from './shell-runtime.js' +import { pathnameExpandShellWord } from './shell-glob.js' + +/** @typedef {{ q: 'u' | 's' | 'd', t: string }} ShellWordPart */ /** * @param {string} inner @@ -364,3 +368,212 @@ export function expandWord(s, env, depth = 0) { } return out } + +/** + * @param {string} s + * @param {number} dollarIdx index of `$` in a `$(…)` command substitution + */ +function findCmdSubstCloseParen(s, dollarIdx) { + if (s[dollarIdx] !== '$' || s[dollarIdx + 1] !== '(') return -1 + let depth = 1 + for (let j = dollarIdx + 2; j < s.length; j++) { + if (s[j] === '(') depth++ + else if (s[j] === ')') { + depth-- + if (depth === 0) return j + } + } + return -1 +} + +/** + * Next `$(` that is command substitution (skip `$((` arithmetic regions). + * @param {string} s + * @param {number} from + */ +function findNextCmdSubstParen(s, from) { + let i = from + while (i < s.length) { + const j = s.indexOf('$(', i) + if (j < 0) return -1 + if (j + 2 < s.length && s[j + 2] === '(') { + const ac = findArithmeticClose(s, j + 3) + if (ac < 0) return -1 + i = ac + 2 + continue + } + return j + } + return -1 +} + +/** + * @param {string} s + * @param {number} from scan from here (first char inside / after opener) + */ +function findBacktickClose(s, from) { + return s.indexOf('`', from) +} + +/** + * @param {Record} ctx + * @param {string} inner + * @param {Record} env + */ +async function expandCmdsubstEmbedded(ctx, inner, env) { + const ex = typeof ctx.execLine === 'function' ? ctx.execLine : null + if (!ex) throw new Error('shell: cmdsubst: execLine unavailable') + const maxLen = + Number.parseInt(env.BARE_OS_SHELL_CMDSUBST_MAX_BYTES || '8192', 10) || 8192 + const lines = [] + const prev = ctx.console.log + ctx.console.log = (...a) => { + lines.push(a.map(String).join(' ')) + } + try { + await ex(inner.trim()) + } finally { + ctx.console.log = prev + } + let out = lines.join('\n').replace(/\n+$/, '') + if (out.length > maxLen) out = out.slice(0, maxLen) + const totalBudgetRaw = Number.parseInt( + String(env.BARE_OS_SHELL_EXPANSION_MAX_BYTES || '262144'), + 10 + ) + const totalBudget = + Number.isFinite(totalBudgetRaw) && totalBudgetRaw > 0 + ? Math.min(totalBudgetRaw, 8 * 1024 * 1024) + : 262144 + if (out.length > totalBudget) { + throw new Error( + `shell: expansion exceeds BARE_OS_SHELL_EXPANSION_MAX_BYTES (${totalBudget})` + ) + } + return out +} + +/** + * Optional trace hook for expansion ordering. + * Order is: parameter/command/arithmetic, then split/glob. + * @param {Record} ctx + * @param {Record} env + * @param {Record} row + */ +function maybeTraceShellExpansion(ctx, env, row) { + const on = + env.BARE_OS_SHELL_EXPANSION_TRACE === '1' || + env.BARE_OS_SHELL_EXPANSION_TRACE === 'true' + if (!on) return + if (!Array.isArray(ctx.shellExpansionTrace)) ctx.shellExpansionTrace = [] + ctx.shellExpansionTrace.push({ + ts: Date.now(), + ...row + }) +} + +/** + * @param {Record} ctx + * @param {string} s + * @param {Record} env + * @param {number} [depth] + */ +async function expandWordWithCmdSubst(ctx, s, env, depth = 0) { + const maxDepth = 2 + if (depth > maxDepth) throw new Error('shell: cmdsubst: nesting too deep') + const rawCmdOn = String(env.BARE_OS_SHELL_CMDSUBST || '').trim().toLowerCase() + const cmdOn = !(rawCmdOn === '0' || rawCmdOn === 'false' || rawCmdOn === 'off') + if (!cmdOn) return expandWord(s, env) + + const tick = findBacktickClose(s, 0) + const dol = findNextCmdSubstParen(s, 0) + + /** @type {'tick'|'dol'|null} */ + let kind = null + let pos = -1 + if (tick >= 0 && (dol < 0 || tick < dol)) { + kind = 'tick' + pos = tick + } else if (dol >= 0) { + kind = 'dol' + pos = dol + } else { + return expandWord(s, env) + } + + if (kind === 'dol') { + const closeParen = findCmdSubstCloseParen(s, pos) + if (closeParen < 0) return expandWord(s, env) + const inner = s.slice(pos + 2, closeParen) + const pre = s.slice(0, pos) + const post = s.slice(closeParen + 1) + const mid = await expandCmdsubstEmbedded(ctx, inner, env) + const merged = pre + mid + post + return expandWordWithCmdSubst(ctx, merged, env, depth + 1) + } + + const closeTick = findBacktickClose(s, pos + 1) + if (closeTick < 0) return expandWord(s, env) + const inner = s.slice(pos + 1, closeTick) + const pre = s.slice(0, pos) + const post = s.slice(closeTick + 1) + const mid = await expandCmdsubstEmbedded(ctx, inner, env) + const merged = pre + mid + post + return expandWordWithCmdSubst(ctx, merged, env, depth + 1) +} + +/** + * Param / command-substitution expansion per quote segment, then pathname expansion. + * Expansion ordering (declared profile): parameter/command/arithmetic -> word split -> glob. + * @param {Record} ctx + * @param {{ type: 'word', value: string, parts?: ShellWordPart[] }} wordTok + * @param {Record} env + * @param {{ redirect?: boolean, disablePathnameExpansion?: boolean }} [globOpts] + * @returns {Promise} + */ +export async function expandShellWordTokens(ctx, wordTok, env, globOpts) { + const parts = + wordTok.parts && wordTok.parts.length + ? wordTok.parts + : [{ q: /** @type {'u'} */ ('u'), t: wordTok.value }] + /** @type {ShellWordPart[]} */ + const ep = [] + for (const p of parts) { + if (p.q === 's') ep.push(p) + else { + maybeTraceShellExpansion(ctx, env, { + stage: 'expand-pre', + quote: p.q, + input: p.t + }) + const s = await expandWordWithCmdSubst(ctx, p.t, env, 0) + maybeTraceShellExpansion(ctx, env, { + stage: 'expand-post', + quote: p.q, + output: s + }) + ep.push({ q: p.q, t: s }) + } + } + const out = await pathnameExpandShellWord(ctx, ep, env, globOpts || {}) + maybeTraceShellExpansion(ctx, env, { + stage: 'split-glob', + outputCount: out.length, + output: out.slice(0, 8) + }) + const budgetRaw = Number.parseInt( + String(env.BARE_OS_SHELL_EXPANSION_MAX_BYTES || '262144'), + 10 + ) + const budget = + Number.isFinite(budgetRaw) && budgetRaw > 0 + ? Math.min(budgetRaw, 8 * 1024 * 1024) + : 262144 + const bytes = out.reduce((n, s) => n + String(s).length, 0) + if (bytes > budget) { + throw new Error( + `shell: expansion exceeds BARE_OS_SHELL_EXPANSION_MAX_BYTES (${budget})` + ) + } + return out +} diff --git a/packages/bare-os-booter/lib/shell-parse.js b/packages/bare-os-booter/lib/shell-parse.js new file mode 100644 index 00000000..1f9b787b --- /dev/null +++ b/packages/bare-os-booter/lib/shell-parse.js @@ -0,0 +1,418 @@ +/** + * Pipeline / redirection parse helpers and execution-graph snapshots. + */ +import { tokenizeBareShellLineDetailed } from './shell-tokenizer.js' +import { shellWordText, tokenize } from './shell-token.js' + +/** + * @typedef {import('./shell-token.js').Token} Token + * @typedef {{ + * argv: Extract[], + * assign: Record, + * redirIn: Extract | null, + * redirOut: Extract | null, + * redirAppend: boolean, + * redirErr: Extract | null, + * redirErrAppend: boolean, + * mergeStderrToStdout: boolean, + * redirHereDoc: string | null + * }} SimpleCmd + */ + +/** + * 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 +} + +/** @param {Token[]} seg */ +export 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 + } +} + +/** + * @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)) +} + +/** + * Snapshot-friendly parse artifact for deterministic grammar tests. + * @param {string} line + */ +export function bareOsShellAstSnapshot(line) { + const src = String(line || '') + return { + schema: 1, + line: src, + diagnosticTokens: tokenizeBareShellLineDetailed(src), + tokens: tokenize(src), + pipeline: parsePipeline(tokenize(src)) + } +} + +/** + * 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 */ +export 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 + } +} + +/** + * 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 +} diff --git a/packages/bare-os-booter/lib/shell-token.js b/packages/bare-os-booter/lib/shell-token.js new file mode 100644 index 00000000..0c3fbbbf --- /dev/null +++ b/packages/bare-os-booter/lib/shell-token.js @@ -0,0 +1,22 @@ +/** + * Token wrapper around {@link ./shell-lex.js} used by aliases, parse, and exec. + */ +import { lexShellLine } from './shell-lex.js' + +/** + * @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)) +} diff --git a/packages/bare-os-booter/lib/shell.js b/packages/bare-os-booter/lib/shell.js index fc5cc9e4..2fed3431 100644 --- a/packages/bare-os-booter/lib/shell.js +++ b/packages/bare-os-booter/lib/shell.js @@ -4,21 +4,11 @@ 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 } from './shell-glob.js' import { bareOsKernelMetricSet } from './bare-os-kernel-metrics.js' -import { - BARE_OS_SHELL_NOUNSET_ERROR, - shellCheckUnboundParam -} from './shell-nounset.js' +import { BARE_OS_SHELL_NOUNSET_ERROR } from './shell-nounset.js' import { bareOsEvalArithmeticExpr } from './shell-arithmetic.js' import { isExecLineBuiltinDenied, @@ -26,7 +16,6 @@ import { shellUnsafeRedirectPath } from './shell-policy.js' import { - stripAliasQuotes, collapseShellLineContinuations, splitTopLevelStatements, splitTopLevelByAmpersand, @@ -38,6 +27,21 @@ import { parseShellFunctionDeclaration, casePatternMatches } from './shell-syntax.js' +import { tokenize } from './shell-token.js' +import { + defaultShellAliases, + expandArgvAliases, + applyAliasDefinition, + runUnaliasBuiltin, + loadBarerc +} from './shell-alias.js' +import { + parsePipeline, + splitTokensBySemicolon, + splitTokensByAndOr, + segmentHasCommand, + buildShellExecutionGraph +} from './shell-parse.js' import { isShellBuiltin, runShellReadBuiltin @@ -49,7 +53,7 @@ import { appendShellAuditEvent, mergePipelineChildCtx } from './shell-runtime.js' -import { expandWord } from './shell-expand.js' +import { expandWord, expandShellWordTokens } from './shell-expand.js' export { BARE_OS_SHELL_NOUNSET_ERROR } export { @@ -65,851 +69,26 @@ export { getBareOsPipelineLimits } from './shell-runtime.js' export { expandWord } from './shell-expand.js' +export { tokenize, shellWordText } from './shell-token.js' +export { + defaultShellAliases, + expandArgvAliases, + applyAliasDefinition, + runUnaliasBuiltin, + BARERC_SKELETON, + loadBarerc +} from './shell-alias.js' +export { + parsePipeline, + bareOsShellError, + bareOsShellAstSnapshot, + splitTokensBySemicolon, + splitTokensByAndOr, + planShellRedirections, + buildShellExecutionGraph +} from './shell-parse.js' -/** Max alias indirections (prevents cycles). */ -const MAX_ALIAS_DEPTH = 16 - -/** - * 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 -} - -/** - * @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)) -} - -/** - * @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 -} +/** Shell planner/executor continues below. */ /** * @param {string} signal diff --git a/packages/bare-os-booter/lib/vfs-policy.js b/packages/bare-os-booter/lib/vfs-policy.js index 854406dd..5800b3a0 100644 --- a/packages/bare-os-booter/lib/vfs-policy.js +++ b/packages/bare-os-booter/lib/vfs-policy.js @@ -1,6 +1,49 @@ /** * VFS path-class policy and Hyperdrive batch/diff helpers. */ + +/** + * Deny writes under union overlay read paths when `BARE_OS_VFS_UNION_WRITE_DENY` applies. + * @param {string} abs logical absolute path + * @param {string[]} unionReadPrefixes + * @param {string[]} unionWriteDenyPrefixes + */ +export function assertUnionWriteNotDenied( + abs, + unionReadPrefixes, + unionWriteDenyPrefixes +) { + if (!unionReadPrefixes.length || !unionWriteDenyPrefixes.length) return + const underUnion = unionReadPrefixes.some( + (pre) => abs === pre || abs.startsWith(pre + '/') + ) + if (!underUnion) return + for (const d of unionWriteDenyPrefixes) { + if (abs === d || abs.startsWith(d + '/')) { + throw new Error( + 'EACCES: union write denied (BARE_OS_VFS_UNION_WRITE_DENY): ' + abs + ) + } + } +} + +/** + * Comma-separated absolute prefixes from `boot.policy` v3 (`denyVfsPrefixes`). + * @param {string} abs + * @param {string} op + * @param {Record} env + */ +export function assertNotBootPolicyDenyVfs(abs, op, env) { + const raw = env.BARE_OS_BOOT_POLICY_DENY_VFS || '' + if (!raw.trim()) return + for (const p of raw.split(',')) { + const pre = p.trim() + if (!pre.startsWith('/')) continue + if (abs === pre || abs.startsWith(pre + '/')) { + throw new Error(`EACCES: boot policy denies ${op} (${pre}): ` + abs) + } + } +} /** * Policy-oriented path class for VFS routing (system vs personal vs pseudo vs mount vs volatile). * Used by operator tooling and future policy engines; routing itself stays in `createVfs`. diff --git a/packages/bare-os-booter/lib/vfs-warm-cache.js b/packages/bare-os-booter/lib/vfs-warm-cache.js new file mode 100644 index 00000000..77974976 --- /dev/null +++ b/packages/bare-os-booter/lib/vfs-warm-cache.js @@ -0,0 +1,241 @@ +/** + * /bin and /lib/bare warm read-cache maps + eviction helpers. + */ +import { bareOsKernelMetricInc } from './bare-os-kernel-metrics.js' + +const BIN_READ_CACHE_MAX = 64 + +/** + * @param {{ + * env: Record, + * warmReadCacheStatsRef?: { current: Record | null } | null + * }} deps + */ +export function createVfsWarmReadCache(deps) { + const { env, warmReadCacheStatsRef = null } = deps + + /** + * Warm read-cache invalidation (decision matrix — operator + maintainer reference): + * | Trigger | Mechanism | Notes | + * | --- | --- | --- | + * | Full flush | `bareOsClearWarmReadCaches` | Clears `/bin` + `/lib/bare` maps; syscall proc cache reset in booter. | + * | Replication core growth | `BARE_OS_VFS_WARM_CACHE_INVALIDATE_ON_REPLICATION` + swarm hints | Prefix eviction via `bareOsInvalidateWarmReadCachesForReplicationPrefixes`. | + * | Batch `ctx.bareOsVfsBatchWrite` | `bareOsVfsBatchWrite` | Clears on `bin/` / `lib/bare/` puts. | + * | Manifest-only bundle rows | `bareOsEvictLibBareBundlesFromManifest` + `ctx.bareOsInvalidateWarmReadCachesFromBareManifestJson` | Targeted `/lib/bare/bundles/.js` + manifest path. | + * | Parse errors on manifest JSON | `bareOsEvictLibBareBundlesFromManifest` | Falls back to **full** clear. | + */ + const binCacheEnabled = + env.BARE_OS_VFS_BIN_CACHE === '1' || env.BARE_OS_VFS_BIN_CACHE === 'true' + const binCacheBlake2b = + binCacheEnabled && + (env.BARE_OS_VFS_BIN_CACHE_BLAKE2B === '1' || + env.BARE_OS_VFS_BIN_CACHE_BLAKE2B === 'true') + /** @type {Map | null} */ + const binReadCache = binCacheEnabled && !binCacheBlake2b ? new Map() : null + /** @type {Map | null} digest hex → bytes */ + const binDigestCache = binCacheBlake2b ? new Map() : null + /** @type {Map | null} logical path → digest */ + const binPathToBlakeDigest = binCacheBlake2b ? new Map() : null + /** @type {Map | null} digest refcount */ + const binDigestRefcount = binCacheBlake2b ? new Map() : null + /** @type {string[] | null} */ + const binBlake2bLruPaths = binCacheBlake2b ? [] : null + const libBareWarmCache = + binCacheEnabled && + (env.BARE_OS_VFS_LIB_BARE_CACHE === '1' || + env.BARE_OS_VFS_LIB_BARE_CACHE === 'true') + + const warmReadCacheStats = + binReadCache || binDigestCache + ? { + schema: 2, + hits: 0, + misses: 0, + binHits: 0, + libBareHits: 0, + libBareCacheEnabled: !!libBareWarmCache, + /** Last selective invalidation driven by replication core-length hints (metrics_live). */ + replicationPrefixEviction: /** @type {{ atMs: number, pathsEvicted: number, prefixes: string[] } | null} */ ( + null + ) + } + : null + if (warmReadCacheStatsRef && warmReadCacheStats) { + warmReadCacheStatsRef.current = warmReadCacheStats + } + + /** @param {string} abs */ + function bumpWarmReadCacheHit(absFollowed) { + if (!warmReadCacheStats) return + warmReadCacheStats.hits++ + if (absFollowed.startsWith('/lib/bare/')) warmReadCacheStats.libBareHits++ + else warmReadCacheStats.binHits++ + } + + /** @param {string} abs */ + function isWarmReadCachePath(abs) { + if (abs.startsWith('/bin/')) return true + return !!(libBareWarmCache && abs.startsWith('/lib/bare/')) + } + + function touchBinBlake2bLru(p) { + if (!binBlake2bLruPaths || !binPathToBlakeDigest) return + const i = binBlake2bLruPaths.indexOf(p) + if (i >= 0) binBlake2bLruPaths.splice(i, 1) + binBlake2bLruPaths.push(p) + while (binBlake2bLruPaths.length > BIN_READ_CACHE_MAX) { + const victim = binBlake2bLruPaths.shift() + if (!victim) continue + const hex = binPathToBlakeDigest.get(victim) + if (!hex) continue + binPathToBlakeDigest.delete(victim) + const n = (binDigestRefcount.get(hex) || 1) - 1 + if (n <= 0) { + binDigestRefcount.delete(hex) + binDigestCache.delete(hex) + } else { + binDigestRefcount.set(hex, n) + } + } + } + + /** Drop `/bin` and `/lib/bare` warm read caches (replication / OTA safety). */ + function bareOsClearWarmReadCaches() { + if (binReadCache) binReadCache.clear() + if (binDigestCache) binDigestCache.clear() + if (binPathToBlakeDigest) binPathToBlakeDigest.clear() + if (binDigestRefcount) binDigestRefcount.clear() + if (binBlake2bLruPaths) binBlake2bLruPaths.length = 0 + if (warmReadCacheStats) { + warmReadCacheStats.hits = 0 + warmReadCacheStats.misses = 0 + warmReadCacheStats.binHits = 0 + warmReadCacheStats.libBareHits = 0 + warmReadCacheStats.replicationPrefixEviction = null + } + bareOsKernelMetricInc('vfs.warm_read_cache_clear') + } + + /** + * Evict warm-cache rows whose logical paths start with one of the prefixes (replication / OTA). + * @param {string[]} prefixes + * @returns {number} paths evicted + */ + function bareOsEvictWarmReadPrefixes(prefixes) { + if (!binReadCache && !binDigestCache) return 0 + const ps = (Array.isArray(prefixes) ? prefixes : []) + .map((p) => String(p || '').replace(/\\/g, '/')) + .filter((p) => p.startsWith('/')) + if (!ps.length) return 0 + /** @type {string[]} */ + const victims = [] + const collect = (k) => { + if (!isWarmReadCachePath(k)) return + if (!ps.some((p) => k.startsWith(p))) return + victims.push(k) + } + if (binReadCache) { + for (const k of binReadCache.keys()) collect(k) + } + if (binPathToBlakeDigest) { + for (const k of binPathToBlakeDigest.keys()) collect(k) + } + for (const k of victims) bareOsEvictSingleWarmReadPath(k) + const n = victims.length + if (n > 0 && warmReadCacheStats) { + warmReadCacheStats.replicationPrefixEviction = { + atMs: Date.now(), + pathsEvicted: n, + prefixes: ps.slice(0, 16) + } + bareOsKernelMetricInc('vfs.warm_read_cache_invalidate_prefix') + } + return n + } + + /** @param {string} absFollowed */ + function bareOsEvictSingleWarmReadPath(absFollowed) { + if (!isWarmReadCachePath(absFollowed)) return + if (binReadCache) binReadCache.delete(absFollowed) + if (binDigestCache && binPathToBlakeDigest && binDigestRefcount) { + const oldHex = binPathToBlakeDigest.get(absFollowed) + if (oldHex) { + binPathToBlakeDigest.delete(absFollowed) + const n0 = (binDigestRefcount.get(oldHex) || 1) - 1 + if (n0 <= 0) { + binDigestRefcount.delete(oldHex) + binDigestCache.delete(oldHex) + } else { + binDigestRefcount.set(oldHex, n0) + } + } + } + if (binBlake2bLruPaths) { + const i = binBlake2bLruPaths.indexOf(absFollowed) + if (i >= 0) binBlake2bLruPaths.splice(i, 1) + } + } + + /** + * Invalidate warm-cache entries for `bare-module-manifest.json` and bundled + * `/lib/bare/bundles/.js` rows (parse errors fall back to full clear). + * @param {Uint8Array | ArrayBuffer} buf + */ + function bareOsEvictLibBareBundlesFromManifest(buf) { + const u8 = + buf instanceof Uint8Array ? buf : new Uint8Array(/** @type {ArrayBuffer} */ (buf)) + let text + try { + text = new TextDecoder().decode(u8) + } catch { + bareOsClearWarmReadCaches() + return + } + /** @type {unknown} */ + let o + try { + o = JSON.parse(text) + } catch { + bareOsClearWarmReadCaches() + return + } + if ( + !o || + typeof o !== 'object' || + !Array.isArray(/** @type {{ entries?: unknown }} */ (o).entries) + ) { + bareOsClearWarmReadCaches() + return + } + bareOsEvictSingleWarmReadPath('/lib/bare/bare-module-manifest.json') + for (const e of /** @type {{ entries: unknown[] }} */ (o).entries) { + if (!e || typeof e !== 'object') continue + const ent = /** @type {{ bundle?: boolean, ctxKey?: string }} */ (e) + if (ent.bundle !== true) continue + const ck = String(ent.ctxKey || '').trim() + if (!ck) continue + bareOsEvictSingleWarmReadPath(`/lib/bare/bundles/${ck}.js`) + } + bareOsKernelMetricInc('vfs.warm_read_cache_evict_manifest') + } + + return { + BIN_READ_CACHE_MAX, + binCacheEnabled, + binCacheBlake2b, + binReadCache, + binDigestCache, + binPathToBlakeDigest, + binDigestRefcount, + binBlake2bLruPaths, + libBareWarmCache, + warmReadCacheStats, + bumpWarmReadCacheHit, + isWarmReadCachePath, + touchBinBlake2bLru, + bareOsClearWarmReadCaches, + bareOsEvictWarmReadPrefixes, + bareOsEvictSingleWarmReadPath, + bareOsEvictLibBareBundlesFromManifest + } +} diff --git a/packages/bare-os-booter/lib/vfs.js b/packages/bare-os-booter/lib/vfs.js index 0e48d7f5..3a3eaf03 100644 --- a/packages/bare-os-booter/lib/vfs.js +++ b/packages/bare-os-booter/lib/vfs.js @@ -27,8 +27,11 @@ import { evaluateBareOsVfsPathPolicy, parseBareOsVfsPolicyRulesFromEnv, bareOsVfsBatchPut, - bareOsHyperdriveDiffCollect + bareOsHyperdriveDiffCollect, + assertUnionWriteNotDenied as assertUnionWriteNotDeniedPolicy, + assertNotBootPolicyDenyVfs as assertNotBootPolicyDenyVfsPolicy } from './vfs-policy.js' +import { createVfsWarmReadCache } from './vfs-warm-cache.js' import { BARE_OS_PROC_FILE_TO_ID_REPLICATION_OPERATOR_SURFACE, BARE_OS_PROC_FILE_TO_ID_PEAR_CORESTORE_HRPC, @@ -524,243 +527,37 @@ export function createVfs( ? String(env.BARE_OS_VFS_SYSTEM_RO_ALIAS).trim().replace(/\/+$/, '') : '' - /** - * Deny writes under union overlay read paths when `BARE_OS_VFS_UNION_WRITE_DENY` applies. - * @param {string} abs logical absolute path - */ function assertUnionWriteNotDenied(abs) { - if (!unionReadPrefixes.length || !unionWriteDenyPrefixes.length) return - const underUnion = unionReadPrefixes.some( - (pre) => abs === pre || abs.startsWith(pre + '/') + return assertUnionWriteNotDeniedPolicy( + abs, + unionReadPrefixes, + unionWriteDenyPrefixes ) - if (!underUnion) return - for (const d of unionWriteDenyPrefixes) { - if (abs === d || abs.startsWith(d + '/')) { - throw new Error( - 'EACCES: union write denied (BARE_OS_VFS_UNION_WRITE_DENY): ' + abs - ) - } - } } - /** Comma-separated absolute prefixes from `boot.policy` v3 (`denyVfsPrefixes`). */ function assertNotBootPolicyDenyVfs(abs, op) { - const raw = env.BARE_OS_BOOT_POLICY_DENY_VFS || '' - if (!raw.trim()) return - for (const p of raw.split(',')) { - const pre = p.trim() - if (!pre.startsWith('/')) continue - if (abs === pre || abs.startsWith(pre + '/')) { - throw new Error(`EACCES: boot policy denies ${op} (${pre}): ` + abs) - } - } + return assertNotBootPolicyDenyVfsPolicy(abs, op, env) } - /** - * Warm read-cache invalidation (decision matrix — operator + maintainer reference): - * | Trigger | Mechanism | Notes | - * | --- | --- | --- | - * | Full flush | `bareOsClearWarmReadCaches` | Clears `/bin` + `/lib/bare` maps; syscall proc cache reset in booter. | - * | Replication core growth | `BARE_OS_VFS_WARM_CACHE_INVALIDATE_ON_REPLICATION` + swarm hints | Prefix eviction via `bareOsInvalidateWarmReadCachesForReplicationPrefixes`. | - * | Batch `ctx.bareOsVfsBatchWrite` | `bareOsVfsBatchWrite` | Clears on `bin/` / `lib/bare/` puts. | - * | Manifest-only bundle rows | `bareOsEvictLibBareBundlesFromManifest` + `ctx.bareOsInvalidateWarmReadCachesFromBareManifestJson` | Targeted `/lib/bare/bundles/.js` + manifest path. | - * | Parse errors on manifest JSON | `bareOsEvictLibBareBundlesFromManifest` | Falls back to **full** clear. | - */ - const binCacheEnabled = - env.BARE_OS_VFS_BIN_CACHE === '1' || env.BARE_OS_VFS_BIN_CACHE === 'true' - const binCacheBlake2b = - binCacheEnabled && - (env.BARE_OS_VFS_BIN_CACHE_BLAKE2B === '1' || - env.BARE_OS_VFS_BIN_CACHE_BLAKE2B === 'true') - /** @type {Map | null} */ - const binReadCache = binCacheEnabled && !binCacheBlake2b ? new Map() : null - /** @type {Map | null} digest hex → bytes */ - const binDigestCache = binCacheBlake2b ? new Map() : null - /** @type {Map | null} logical path → digest */ - const binPathToBlakeDigest = binCacheBlake2b ? new Map() : null - /** @type {Map | null} digest refcount */ - const binDigestRefcount = binCacheBlake2b ? new Map() : null - /** @type {string[] | null} */ - const binBlake2bLruPaths = binCacheBlake2b ? [] : null - const BIN_READ_CACHE_MAX = 64 - const libBareWarmCache = - binCacheEnabled && - (env.BARE_OS_VFS_LIB_BARE_CACHE === '1' || - env.BARE_OS_VFS_LIB_BARE_CACHE === 'true') - - const warmReadCacheStats = - binReadCache || binDigestCache - ? { - schema: 2, - hits: 0, - misses: 0, - binHits: 0, - libBareHits: 0, - libBareCacheEnabled: !!libBareWarmCache, - /** Last selective invalidation driven by replication core-length hints (metrics_live). */ - replicationPrefixEviction: /** @type {{ atMs: number, pathsEvicted: number, prefixes: string[] } | null} */ ( - null - ) - } - : null - if (vfsOptions.warmReadCacheStatsRef && warmReadCacheStats) { - vfsOptions.warmReadCacheStatsRef.current = warmReadCacheStats - } - - /** @param {string} abs */ - function bumpWarmReadCacheHit(absFollowed) { - if (!warmReadCacheStats) return - warmReadCacheStats.hits++ - if (absFollowed.startsWith('/lib/bare/')) warmReadCacheStats.libBareHits++ - else warmReadCacheStats.binHits++ - } - - /** @param {string} abs */ - function isWarmReadCachePath(abs) { - if (abs.startsWith('/bin/')) return true - return !!(libBareWarmCache && abs.startsWith('/lib/bare/')) - } - - function touchBinBlake2bLru(p) { - if (!binBlake2bLruPaths || !binPathToBlakeDigest) return - const i = binBlake2bLruPaths.indexOf(p) - if (i >= 0) binBlake2bLruPaths.splice(i, 1) - binBlake2bLruPaths.push(p) - while (binBlake2bLruPaths.length > BIN_READ_CACHE_MAX) { - const victim = binBlake2bLruPaths.shift() - if (!victim) continue - const hex = binPathToBlakeDigest.get(victim) - if (!hex) continue - binPathToBlakeDigest.delete(victim) - const n = (binDigestRefcount.get(hex) || 1) - 1 - if (n <= 0) { - binDigestRefcount.delete(hex) - binDigestCache.delete(hex) - } else { - binDigestRefcount.set(hex, n) - } - } - } - - /** Drop `/bin` and `/lib/bare` warm read caches (replication / OTA safety). */ - function bareOsClearWarmReadCaches() { - if (binReadCache) binReadCache.clear() - if (binDigestCache) binDigestCache.clear() - if (binPathToBlakeDigest) binPathToBlakeDigest.clear() - if (binDigestRefcount) binDigestRefcount.clear() - if (binBlake2bLruPaths) binBlake2bLruPaths.length = 0 - if (warmReadCacheStats) { - warmReadCacheStats.hits = 0 - warmReadCacheStats.misses = 0 - warmReadCacheStats.binHits = 0 - warmReadCacheStats.libBareHits = 0 - warmReadCacheStats.replicationPrefixEviction = null - } - bareOsKernelMetricInc('vfs.warm_read_cache_clear') - } - - /** - * Evict warm-cache rows whose logical paths start with one of the prefixes (replication / OTA). - * @param {string[]} prefixes - * @returns {number} paths evicted - */ - function bareOsEvictWarmReadPrefixes(prefixes) { - if (!binReadCache && !binDigestCache) return 0 - const ps = (Array.isArray(prefixes) ? prefixes : []) - .map((p) => String(p || '').replace(/\\/g, '/')) - .filter((p) => p.startsWith('/')) - if (!ps.length) return 0 - /** @type {string[]} */ - const victims = [] - const collect = (k) => { - if (!isWarmReadCachePath(k)) return - if (!ps.some((p) => k.startsWith(p))) return - victims.push(k) - } - if (binReadCache) { - for (const k of binReadCache.keys()) collect(k) - } - if (binPathToBlakeDigest) { - for (const k of binPathToBlakeDigest.keys()) collect(k) - } - for (const k of victims) bareOsEvictSingleWarmReadPath(k) - const n = victims.length - if (n > 0 && warmReadCacheStats) { - warmReadCacheStats.replicationPrefixEviction = { - atMs: Date.now(), - pathsEvicted: n, - prefixes: ps.slice(0, 16) - } - bareOsKernelMetricInc('vfs.warm_read_cache_invalidate_prefix') - } - return n - } - - /** @param {string} absFollowed */ - function bareOsEvictSingleWarmReadPath(absFollowed) { - if (!isWarmReadCachePath(absFollowed)) return - if (binReadCache) binReadCache.delete(absFollowed) - if (binDigestCache && binPathToBlakeDigest && binDigestRefcount) { - const oldHex = binPathToBlakeDigest.get(absFollowed) - if (oldHex) { - binPathToBlakeDigest.delete(absFollowed) - const n0 = (binDigestRefcount.get(oldHex) || 1) - 1 - if (n0 <= 0) { - binDigestRefcount.delete(oldHex) - binDigestCache.delete(oldHex) - } else { - binDigestRefcount.set(oldHex, n0) - } - } - } - if (binBlake2bLruPaths) { - const i = binBlake2bLruPaths.indexOf(absFollowed) - if (i >= 0) binBlake2bLruPaths.splice(i, 1) - } - } - - /** - * Invalidate warm-cache entries for `bare-module-manifest.json` and bundled - * `/lib/bare/bundles/.js` rows (parse errors fall back to full clear). - * @param {Uint8Array | ArrayBuffer} buf - */ - function bareOsEvictLibBareBundlesFromManifest(buf) { - const u8 = - buf instanceof Uint8Array ? buf : new Uint8Array(/** @type {ArrayBuffer} */ (buf)) - let text - try { - text = new TextDecoder().decode(u8) - } catch { - bareOsClearWarmReadCaches() - return - } - /** @type {unknown} */ - let o - try { - o = JSON.parse(text) - } catch { - bareOsClearWarmReadCaches() - return - } - if ( - !o || - typeof o !== 'object' || - !Array.isArray(/** @type {{ entries?: unknown }} */ (o).entries) - ) { - bareOsClearWarmReadCaches() - return - } - bareOsEvictSingleWarmReadPath('/lib/bare/bare-module-manifest.json') - for (const e of /** @type {{ entries: unknown[] }} */ (o).entries) { - if (!e || typeof e !== 'object') continue - const ent = /** @type {{ bundle?: boolean, ctxKey?: string }} */ (e) - if (ent.bundle !== true) continue - const ck = String(ent.ctxKey || '').trim() - if (!ck) continue - bareOsEvictSingleWarmReadPath(`/lib/bare/bundles/${ck}.js`) - } - bareOsKernelMetricInc('vfs.warm_read_cache_evict_manifest') - } + const { + BIN_READ_CACHE_MAX, + binReadCache, + binDigestCache, + binPathToBlakeDigest, + binDigestRefcount, + binBlake2bLruPaths, + warmReadCacheStats, + bumpWarmReadCacheHit, + isWarmReadCachePath, + touchBinBlake2bLru, + bareOsClearWarmReadCaches, + bareOsEvictWarmReadPrefixes, + bareOsEvictSingleWarmReadPath, + bareOsEvictLibBareBundlesFromManifest + } = createVfsWarmReadCache({ + env, + warmReadCacheStatsRef: vfsOptions.warmReadCacheStatsRef || null + }) const sysClassNetLoText = typeof vfsOptions.sysClassNetLoText === 'function' diff --git a/packages/bare-os-booter/test.posix-helpers.js b/packages/bare-os-booter/test.posix-helpers.js index f00485fb..4c13cecd 100644 --- a/packages/bare-os-booter/test.posix-helpers.js +++ b/packages/bare-os-booter/test.posix-helpers.js @@ -44,7 +44,7 @@ import { mergePipelineChildCtx, DEFAULT_PIPELINE_MAX_STAGES } from './lib/shell-runtime.js' -import { expandWord } from './lib/shell-expand.js' +import { expandWord, expandShellWordTokens } from './lib/shell-expand.js' import { utf8Encode, createVfsPseudoLinux @@ -53,6 +53,25 @@ import { createBareOsVirtualSignalDeliverer } from './lib/bare-os-virtual-signal import { createKernelLoaderAuditAppend } from './lib/bare-os-loader-audit.js' import { createBooterBootEmitter } from './lib/bare-os-boot-phases.js' import { createBooterProcHelpers } from './lib/bare-os-booter-proc-helpers.js' +import { tokenize } from './lib/shell-token.js' +import { + defaultShellAliases, + expandArgvAliases, + applyAliasDefinition +} from './lib/shell-alias.js' +import { + parsePipeline, + splitTokensBySemicolon, + splitTokensByAndOr, + planShellRedirections, + buildShellExecutionGraph +} from './lib/shell-parse.js' +import { + assertUnionWriteNotDenied, + assertNotBootPolicyDenyVfs +} from './lib/vfs-policy.js' +import { createVfsWarmReadCache } from './lib/vfs-warm-cache.js' +import { createSessionReadMaskedLine } from './lib/cli-readline.js' test('posix env flags and caps', (t) => { t.ok(wantPosixSocketFdBridge({ BARE_OS_POSIX_SOCKET_FD_BRIDGE: '1' })) @@ -290,6 +309,25 @@ test('expandWord param and arithmetic', (t) => { t.is(expandWord('$(( $a + 1 ))', { a: '2' }), '3') }) +test('expandShellWordTokens traces and expands unquoted words', async (t) => { + const ctx = {} + const env = { + HOME: '/h', + BARE_OS_SHELL_EXPANSION_TRACE: '1', + BARE_OS_SHELL_CMDSUBST: '0' + } + const out = await expandShellWordTokens( + ctx, + { type: 'word', value: '$HOME', parts: [{ q: 'u', t: '$HOME' }] }, + env, + { disablePathnameExpansion: true } + ) + t.alike(out, ['/h']) + t.ok(Array.isArray(ctx.shellExpansionTrace)) + t.ok(ctx.shellExpansionTrace.some((r) => r.stage === 'expand-pre')) + t.ok(ctx.shellExpansionTrace.some((r) => r.stage === 'split-glob')) +}) + test('vfs linux-shaped /proc texts', (t) => { const linux = createVfsPseudoLinux({ env: { @@ -388,3 +426,51 @@ test('booter proc helpers chat/meshdrop off notes', (t) => { t.ok(helpers.bareOsChatProcSnapshotRecord().note) t.ok(helpers.bareOsMeshdropProcSnapshotRecord().note) }) + +test('shell token alias parse', (t) => { + const toks = tokenize('echo a; echo b') + t.ok(toks.length >= 3) + t.alike(expandArgvAliases(['ll'], defaultShellAliases()).slice(0, 1), ['ls']) + const ctx = { shellAliases: {} } + t.ok(applyAliasDefinition(ctx, "gst='git status'")) + t.is(ctx.shellAliases.gst, 'git status') + const pipe = parsePipeline(tokenize('echo hi > out')) + t.is(planShellRedirections(pipe[0]).stdout, 'truncate') + t.is(splitTokensBySemicolon(tokenize('a; b')).length, 2) + t.alike(splitTokensByAndOr(tokenize('a && b')).ops, ['&&']) + t.is(buildShellExecutionGraph('echo x').schema, 1) +}) + +test('vfs deny asserts and warm cache evict', (t) => { + t.exception( + () => + assertUnionWriteNotDenied('/mnt/x/a', ['/mnt/x'], ['/mnt/x']), + /union write denied/ + ) + assertUnionWriteNotDenied('/home/g/a', ['/mnt/x'], ['/mnt/x']) + t.exception( + () => + assertNotBootPolicyDenyVfs('/etc/shadow', 'read', { + BARE_OS_BOOT_POLICY_DENY_VFS: '/etc' + }), + /boot policy denies/ + ) + const ref = { current: null } + const cache = createVfsWarmReadCache({ + env: { BARE_OS_VFS_BIN_CACHE: '1' }, + warmReadCacheStatsRef: ref + }) + t.ok(cache.binReadCache) + cache.binReadCache.set('/bin/ls', new Uint8Array([1])) + t.is(cache.bareOsEvictWarmReadPrefixes(['/bin/']), 1) + t.is(cache.binReadCache.size, 0) +}) + +test('session masked line falls back without TTY', async (t) => { + const read = createSessionReadMaskedLine({ + stdin: {}, + stdout: {}, + fallbackReadLine: async (p) => 'fb:' + p + }) + t.is(await read('pw> '), 'fb:pw> ') +})