Pass 6
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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> ')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user