This commit is contained in:
2026-08-18 17:44:24 -04:00
parent 54d9cd5db8
commit 783df844f1
11 changed files with 1443 additions and 1203 deletions
+10 -113
View File
@@ -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<string | null>}
*/
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]
+135
View File
@@ -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<string | null>,
* 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<string | null>}
*/
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
+209
View File
@@ -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<string, string>}
*/
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<string, string> | 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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)
}
+214 -1
View File
@@ -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<string, unknown>} ctx
* @param {string} inner
* @param {Record<string, string>} env
*/
async function expandCmdsubstEmbedded(ctx, inner, env) {
const ex = typeof ctx.execLine === 'function' ? ctx.execLine : null
if (!ex) throw new Error('shell: cmdsubst: execLine unavailable')
const maxLen =
Number.parseInt(env.BARE_OS_SHELL_CMDSUBST_MAX_BYTES || '8192', 10) || 8192
const lines = []
const prev = ctx.console.log
ctx.console.log = (...a) => {
lines.push(a.map(String).join(' '))
}
try {
await ex(inner.trim())
} finally {
ctx.console.log = prev
}
let out = lines.join('\n').replace(/\n+$/, '')
if (out.length > maxLen) out = out.slice(0, maxLen)
const totalBudgetRaw = Number.parseInt(
String(env.BARE_OS_SHELL_EXPANSION_MAX_BYTES || '262144'),
10
)
const totalBudget =
Number.isFinite(totalBudgetRaw) && totalBudgetRaw > 0
? Math.min(totalBudgetRaw, 8 * 1024 * 1024)
: 262144
if (out.length > totalBudget) {
throw new Error(
`shell: expansion exceeds BARE_OS_SHELL_EXPANSION_MAX_BYTES (${totalBudget})`
)
}
return out
}
/**
* Optional trace hook for expansion ordering.
* Order is: parameter/command/arithmetic, then split/glob.
* @param {Record<string, unknown>} ctx
* @param {Record<string, string>} env
* @param {Record<string, unknown>} row
*/
function maybeTraceShellExpansion(ctx, env, row) {
const on =
env.BARE_OS_SHELL_EXPANSION_TRACE === '1' ||
env.BARE_OS_SHELL_EXPANSION_TRACE === 'true'
if (!on) return
if (!Array.isArray(ctx.shellExpansionTrace)) ctx.shellExpansionTrace = []
ctx.shellExpansionTrace.push({
ts: Date.now(),
...row
})
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} s
* @param {Record<string, string>} env
* @param {number} [depth]
*/
async function expandWordWithCmdSubst(ctx, s, env, depth = 0) {
const maxDepth = 2
if (depth > maxDepth) throw new Error('shell: cmdsubst: nesting too deep')
const rawCmdOn = String(env.BARE_OS_SHELL_CMDSUBST || '').trim().toLowerCase()
const cmdOn = !(rawCmdOn === '0' || rawCmdOn === 'false' || rawCmdOn === 'off')
if (!cmdOn) return expandWord(s, env)
const tick = findBacktickClose(s, 0)
const dol = findNextCmdSubstParen(s, 0)
/** @type {'tick'|'dol'|null} */
let kind = null
let pos = -1
if (tick >= 0 && (dol < 0 || tick < dol)) {
kind = 'tick'
pos = tick
} else if (dol >= 0) {
kind = 'dol'
pos = dol
} else {
return expandWord(s, env)
}
if (kind === 'dol') {
const closeParen = findCmdSubstCloseParen(s, pos)
if (closeParen < 0) return expandWord(s, env)
const inner = s.slice(pos + 2, closeParen)
const pre = s.slice(0, pos)
const post = s.slice(closeParen + 1)
const mid = await expandCmdsubstEmbedded(ctx, inner, env)
const merged = pre + mid + post
return expandWordWithCmdSubst(ctx, merged, env, depth + 1)
}
const closeTick = findBacktickClose(s, pos + 1)
if (closeTick < 0) return expandWord(s, env)
const inner = s.slice(pos + 1, closeTick)
const pre = s.slice(0, pos)
const post = s.slice(closeTick + 1)
const mid = await expandCmdsubstEmbedded(ctx, inner, env)
const merged = pre + mid + post
return expandWordWithCmdSubst(ctx, merged, env, depth + 1)
}
/**
* Param / command-substitution expansion per quote segment, then pathname expansion.
* Expansion ordering (declared profile): parameter/command/arithmetic -> word split -> glob.
* @param {Record<string, unknown>} ctx
* @param {{ type: 'word', value: string, parts?: ShellWordPart[] }} wordTok
* @param {Record<string, string>} env
* @param {{ redirect?: boolean, disablePathnameExpansion?: boolean }} [globOpts]
* @returns {Promise<string[]>}
*/
export async function expandShellWordTokens(ctx, wordTok, env, globOpts) {
const parts =
wordTok.parts && wordTok.parts.length
? wordTok.parts
: [{ q: /** @type {'u'} */ ('u'), t: wordTok.value }]
/** @type {ShellWordPart[]} */
const ep = []
for (const p of parts) {
if (p.q === 's') ep.push(p)
else {
maybeTraceShellExpansion(ctx, env, {
stage: 'expand-pre',
quote: p.q,
input: p.t
})
const s = await expandWordWithCmdSubst(ctx, p.t, env, 0)
maybeTraceShellExpansion(ctx, env, {
stage: 'expand-post',
quote: p.q,
output: s
})
ep.push({ q: p.q, t: s })
}
}
const out = await pathnameExpandShellWord(ctx, ep, env, globOpts || {})
maybeTraceShellExpansion(ctx, env, {
stage: 'split-glob',
outputCount: out.length,
output: out.slice(0, 8)
})
const budgetRaw = Number.parseInt(
String(env.BARE_OS_SHELL_EXPANSION_MAX_BYTES || '262144'),
10
)
const budget =
Number.isFinite(budgetRaw) && budgetRaw > 0
? Math.min(budgetRaw, 8 * 1024 * 1024)
: 262144
const bytes = out.reduce((n, s) => n + String(s).length, 0)
if (bytes > budget) {
throw new Error(
`shell: expansion exceeds BARE_OS_SHELL_EXPANSION_MAX_BYTES (${budget})`
)
}
return out
}
+418
View File
@@ -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<Token, { type: 'word' }>[],
* assign: Record<string, string>,
* redirIn: Extract<Token, { type: 'word' }> | null,
* redirOut: Extract<Token, { type: 'word' }> | null,
* redirAppend: boolean,
* redirErr: Extract<Token, { type: 'word' }> | 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<string, string>} */
const assign = {}
/** @type {Extract<Token, { type: 'word' }> | null} */
let redirIn = null
/** @type {Extract<Token, { type: 'word' }> | null} */
let redirOut = null
let redirAppend = false
/** @type {Extract<Token, { type: 'word' }> | null} */
let redirErr = null
let redirErrAppend = false
let mergeStderrToStdout = false
/** @type {string | null} */
let redirHereDoc = null
/** @type {Extract<Token, { type: 'word' }>[]} */
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<Token, { type: 'word' }>} */ (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
}
@@ -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))
}
+36 -857
View File
@@ -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<string, string>}
*/
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<string, string> | 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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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<Token, { type: 'word' }>[],
* assign: Record<string, string>,
* redirIn: Extract<Token, { type: 'word' }> | null,
* redirOut: Extract<Token, { type: 'word' }> | null,
* redirAppend: boolean,
* redirErr: Extract<Token, { type: 'word' }> | 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<typeof tokenizeBareShellLineDetailed>,
* tokens: ReturnType<typeof tokenize>,
* pipeline: ReturnType<typeof parsePipeline>
* }}
*/
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<string, string>} */
const assign = {}
/** @type {Extract<Token, { type: 'word' }> | null} */
let redirIn = null
/** @type {Extract<Token, { type: 'word' }> | null} */
let redirOut = null
let redirAppend = false
/** @type {Extract<Token, { type: 'word' }> | null} */
let redirErr = null
let redirErrAppend = false
let mergeStderrToStdout = false
/** @type {string | null} */
let redirHereDoc = null
/** @type {Extract<Token, { type: 'word' }>[]} */
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<Token, { type: 'word' }>} */ (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<string, unknown>} ctx
* @param {string} inner
* @param {Record<string, string>} env
*/
async function expandCmdsubstEmbedded(ctx, inner, env) {
const ex = typeof ctx.execLine === 'function' ? ctx.execLine : null
if (!ex) throw new Error('shell: cmdsubst: execLine unavailable')
const maxLen =
Number.parseInt(env.BARE_OS_SHELL_CMDSUBST_MAX_BYTES || '8192', 10) || 8192
const lines = []
const prev = ctx.console.log
ctx.console.log = (...a) => {
lines.push(a.map(String).join(' '))
}
try {
await ex(inner.trim())
} finally {
ctx.console.log = prev
}
let out = lines.join('\n').replace(/\n+$/, '')
if (out.length > maxLen) out = out.slice(0, maxLen)
const totalBudgetRaw = Number.parseInt(
String(env.BARE_OS_SHELL_EXPANSION_MAX_BYTES || '262144'),
10
)
const totalBudget =
Number.isFinite(totalBudgetRaw) && totalBudgetRaw > 0
? Math.min(totalBudgetRaw, 8 * 1024 * 1024)
: 262144
if (out.length > totalBudget) {
throw new Error(
`shell: expansion exceeds BARE_OS_SHELL_EXPANSION_MAX_BYTES (${totalBudget})`
)
}
return out
}
/**
* Optional trace hook for expansion ordering.
* Order is: parameter/command/arithmetic, then split/glob.
* @param {Record<string, unknown>} ctx
* @param {Record<string, string>} env
* @param {Record<string, unknown>} row
*/
function maybeTraceShellExpansion(ctx, env, row) {
const on =
env.BARE_OS_SHELL_EXPANSION_TRACE === '1' ||
env.BARE_OS_SHELL_EXPANSION_TRACE === 'true'
if (!on) return
if (!Array.isArray(ctx.shellExpansionTrace)) ctx.shellExpansionTrace = []
ctx.shellExpansionTrace.push({
ts: Date.now(),
...row
})
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} s
* @param {Record<string, string>} env
* @param {number} [depth]
*/
async function expandWordWithCmdSubst(ctx, s, env, depth = 0) {
const maxDepth = 2
if (depth > maxDepth) throw new Error('shell: cmdsubst: nesting too deep')
const rawCmdOn = String(env.BARE_OS_SHELL_CMDSUBST || '').trim().toLowerCase()
const cmdOn = !(rawCmdOn === '0' || rawCmdOn === 'false' || rawCmdOn === 'off')
if (!cmdOn) return expandWord(s, env)
const tick = findBacktickClose(s, 0)
const dol = findNextCmdSubstParen(s, 0)
/** @type {'tick'|'dol'|null} */
let kind = null
let pos = -1
if (tick >= 0 && (dol < 0 || tick < dol)) {
kind = 'tick'
pos = tick
} else if (dol >= 0) {
kind = 'dol'
pos = dol
} else {
return expandWord(s, env)
}
if (kind === 'dol') {
const closeParen = findCmdSubstCloseParen(s, pos)
if (closeParen < 0) return expandWord(s, env)
const inner = s.slice(pos + 2, closeParen)
const pre = s.slice(0, pos)
const post = s.slice(closeParen + 1)
const mid = await expandCmdsubstEmbedded(ctx, inner, env)
const merged = pre + mid + post
return expandWordWithCmdSubst(ctx, merged, env, depth + 1)
}
const closeTick = findBacktickClose(s, pos + 1)
if (closeTick < 0) return expandWord(s, env)
const inner = s.slice(pos + 1, closeTick)
const pre = s.slice(0, pos)
const post = s.slice(closeTick + 1)
const mid = await expandCmdsubstEmbedded(ctx, inner, env)
const merged = pre + mid + post
return expandWordWithCmdSubst(ctx, merged, env, depth + 1)
}
/**
* Param / command-substitution expansion per quote segment, then pathname expansion.
* Expansion ordering (declared profile): parameter/command/arithmetic -> word split -> glob.
* @param {Record<string, unknown>} ctx
* @param {Extract<Token, { type: 'word' }>} wordTok
* @param {Record<string, string>} env
* @param {{ redirect?: boolean, disablePathnameExpansion?: boolean }} [globOpts]
* @returns {Promise<string[]>}
*/
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
+43
View File
@@ -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<string, string | undefined>} 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`.
@@ -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<string, string | undefined>,
* warmReadCacheStatsRef?: { current: Record<string, unknown> | 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/<ctxKey>.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<string, Uint8Array> | null} */
const binReadCache = binCacheEnabled && !binCacheBlake2b ? new Map() : null
/** @type {Map<string, Uint8Array> | null} digest hex → bytes */
const binDigestCache = binCacheBlake2b ? new Map() : null
/** @type {Map<string, string> | null} logical path → digest */
const binPathToBlakeDigest = binCacheBlake2b ? new Map() : null
/** @type {Map<string, number> | 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/<ctxKey>.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
}
}
+28 -231
View File
@@ -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/<ctxKey>.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<string, Uint8Array> | null} */
const binReadCache = binCacheEnabled && !binCacheBlake2b ? new Map() : null
/** @type {Map<string, Uint8Array> | null} digest hex → bytes */
const binDigestCache = binCacheBlake2b ? new Map() : null
/** @type {Map<string, string> | null} logical path → digest */
const binPathToBlakeDigest = binCacheBlake2b ? new Map() : null
/** @type {Map<string, number> | 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/<ctxKey>.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'
+87 -1
View File
@@ -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> ')
})