Files
bare-operating-system/packages/bare-os-booter/lib/shell-glob.js
T
Raven Scott 27292f2ba6 Network/auth/delegate hardening
hdms
Added ls alias to list.
Made delegate failures explicit and nonzero in packages/bare-os-coreutils/src/hdms.js.
Added nonzero error exit in packages/bare-os-booter/lib/hdms-manager.js.
git-pear
Implemented clone subcommand routing to git clone in packages/bare-os-coreutils/src/git-pear.js.
trustctl
Added ls alias to status/policy output in packages/bare-os-coreutils/src/trustctl.js.
oidc-publish
Added explicit unknown-subcommand handling and publish subcommand compatibility in packages/bare-os-coreutils/src/oidc-publish.js.
ssh-keygen
Wrapped delegate invocation with explicit error propagation in packages/bare-os-coreutils/src/ssh-keygen.js.
Added success output on generated keypair in packages/bare-os-booter/lib/ssh-keygen-cli.js.
sshd
Added -t config test mode and explicit exit semantics in packages/bare-os-booter/lib/bare-openssh.js.
Ensured wrapper sets exit code consistently in packages/bare-os-openssh/src/sshd.js.
telnet
Changed connector preference to use net.createConnection first when available, then syscall bridge fallback, in packages/bare-os-coreutils/src/telnet.js.
crontab -e flow

Implemented edit flow with VISUAL/EDITOR fallback, unlocked-state checks, temp file handling, install, and cleanup in packages/bare-os-coreutils/src/crontab.js.
Shell/runtime hardcore semantics

Added numeric brace range expansion {1..5} in packages/bare-os-booter/lib/shell-glob.js.
Enabled brace expansion by default unless explicitly disabled.
Added normalization for inline brace-expression tokens in packages/bare-os-booter/lib/shell.js.
Added arithmetic command-form handling for (( ... )) in packages/bare-os-booter/lib/shell.js.
Extended shell signal trap dispatch support for USR1/USR2 (in addition to INT/TERM) in packages/bare-os-booter/index.js.
Hardened kill command delivery validation in packages/bare-os-coreutils/src/kill.js.
Regression tests

Added new: packages/bare-os-coreutils/test/hardcore-bugs.test.mjs.
Extended shell tests in packages/bare-os-booter/test.js for:
default cmdsub behavior,
brace range expansion,
arithmetic command form.
Existing regression files still pass after updates.
2026-04-27 08:54:50 -04:00

454 lines
13 KiB
JavaScript

/**
* Pathname expansion (globbing) for the Bare OS shell — Bare / VFS only (no node:fs).
*/
import unixPathResolve from 'unix-path-resolve'
/** @typedef {{ q: 'u' | 's' | 'd', t: string }} ShellWordPart */
const BAREOS_EMPTY = '.bareos_empty'
/**
* @param {string} name
* @param {string} pat POSIX-ish fnmatch pattern (* ? [...])
*/
export function bareOsFnmatch(name, pat) {
if (pat === '') return name === ''
let i = 0
let j = 0
while (j < pat.length) {
const c = pat[j]
if (c === '*') {
while (j < pat.length && pat[j] === '*') j++
if (j >= pat.length) return true
const rest = pat.slice(j)
for (let k = i; k <= name.length; k++) {
if (bareOsFnmatch(name.slice(k), rest)) return true
}
return false
}
if (c === '?') {
if (i >= name.length) return false
i++
j++
continue
}
if (c === '[') {
j++
const invert = pat[j] === '!'
if (invert) j++
const set = new Set()
if (j < pat.length && pat[j] === ']') {
set.add(']')
j++
}
while (j < pat.length && pat[j] !== ']') {
if (pat[j + 1] === '-' && j + 2 < pat.length && pat[j + 2] !== ']') {
const a = pat.charCodeAt(j)
const b = pat.charCodeAt(j + 2)
const lo = Math.min(a, b)
const hi = Math.max(a, b)
for (let cc = lo; cc <= hi; cc++) set.add(String.fromCharCode(cc))
j += 3
} else {
set.add(pat[j])
j++
}
}
if (j >= pat.length || pat[j] !== ']') return false
j++
if (i >= name.length) return false
const ch = name[i]
const hit = set.has(ch)
if (invert ? hit : !hit) return false
i++
continue
}
if (i >= name.length || name[i] !== c) return false
i++
j++
}
return i === name.length
}
/**
* Expand `{a,b,c}` when comma-separated (no nested braces).
* @param {string} s
* @returns {string[]}
*/
export function bareOsBraceExpand(s) {
const start = s.indexOf('{')
if (start < 0) return [s]
const end = s.indexOf('}', start + 1)
if (end < 0) return [s]
const prefix = s.slice(0, start)
const mid = s.slice(start + 1, end)
const suffix = s.slice(end + 1)
if (!mid.includes(',')) {
const m = /^(-?\d+)\.\.(-?\d+)$/.exec(mid)
if (!m) return [s]
const a = Number.parseInt(m[1], 10)
const b = Number.parseInt(m[2], 10)
if (!Number.isFinite(a) || !Number.isFinite(b)) return [s]
const step = a <= b ? 1 : -1
/** @type {string[]} */
const out = []
for (let n = a; step > 0 ? n <= b : n >= b; n += step) {
out.push(prefix + String(n) + suffix)
if (out.length > 65536) return [s]
}
return out.length ? out : [s]
}
const alts = mid.split(',').map((x) => x.trim())
if (alts.some((a) => a === '')) return [s]
return alts.map((a) => prefix + a + suffix)
}
/**
* Apply brace expansion to u-parts only; returns alternative part-arrays.
* @param {ShellWordPart[]} parts
* @param {boolean} braceOn
* @param {Record<string, string>} env
* @returns {ShellWordPart[][]}
*/
function explodeBraceParts(parts, braceOn, env) {
if (!braceOn) return [parts]
const maxBraceRaw = Number.parseInt(
String(env.BARE_OS_SHELL_BRACE_EXPANSION_MAX || '256'),
10
)
const maxBrace =
Number.isFinite(maxBraceRaw) && maxBraceRaw > 0
? Math.min(65536, maxBraceRaw)
: 256
/** @type {ShellWordPart[][]} */
const slotAlts = []
let product = 1
for (const p of parts) {
if (p.q !== 'u') {
slotAlts.push([p])
continue
}
const alts = bareOsBraceExpand(p.t)
if (alts.length === 1 && alts[0] === p.t) slotAlts.push([p])
else {
const mapped = alts.map((t) => ({ q: /** @type {'u'} */ ('u'), t }))
product *= mapped.length
if (product > maxBrace) return [parts]
slotAlts.push(mapped)
}
}
/** @type {ShellWordPart[][]} */
const out = []
function walk(i, cur) {
if (i >= slotAlts.length) {
out.push(cur.slice())
return
}
for (const opt of slotAlts[i]) {
cur.push(opt)
walk(i + 1, cur)
cur.pop()
}
}
walk(0, [])
if (out.length > maxBrace) return [parts]
return out.length ? out : [parts]
}
/**
* @param {Record<string, string>} env
*/
function parseGlobIgnore(env) {
const raw = env.BARE_OS_GLOB_IGNORE
if (raw == null || raw === '') return []
return String(raw)
.split(':')
.map((s) => s.trim())
.filter(Boolean)
}
/**
* @param {string} fullPath
* @param {string[]} patterns
*/
function globIgnoreHit(fullPath, patterns) {
const base = fullPath.replace(/^.*\//, '')
for (const pat of patterns) {
if (bareOsFnmatch(fullPath, pat) || bareOsFnmatch(base, pat)) return true
}
return false
}
/**
* Split merged fragments into path segments; each segment knows if it may glob.
* @param {{ text: string, glob: boolean }[]} fr
* @returns {{ seg: string, g: boolean }[]}
*/
export function fragmentsToPathSegments(fr) {
/** @type {{ seg: string, g: boolean }[]} */
const out = []
for (const f of fr) {
const raw = f.text
if (raw === '') continue
const lead = raw.startsWith('/')
const bits = raw.split('/')
for (let i = 0; i < bits.length; i++) {
const piece = bits[i]
if (piece === '' && i === 0 && lead) {
out.push({ seg: '', g: false })
continue
}
if (piece === '') continue
const g = Boolean(f.glob && /[*?\[]/.test(piece))
out.push({ seg: piece, g })
}
}
return out
}
/**
* @param {string} dirAbs
* @param {string} name
*/
function joinUnder(dirAbs, name) {
if (dirAbs === '/' || dirAbs === '') return '/' + name.replace(/^\/+/, '')
return dirAbs.replace(/\/+$/, '') + '/' + name.replace(/^\/+/, '')
}
/**
* @param {Record<string, string>} env
* @param {Record<string, unknown>} ctx
* @param {{ text: string, glob: boolean }[]} fr
*/
function applyTildeToFragments(fr, env, ctx) {
const home = String(env.HOME || ctx.vfs?.home || '/home/guest')
if (!fr.length) return fr
const f0 = fr[0]
const t = f0.text
if (f0.glob && t === '~') {
const next = [...fr]
next[0] = { text: home, glob: false }
return next
}
if (f0.glob && t.startsWith('~/')) {
const next = [...fr]
next[0] = { text: home + t.slice(1), glob: f0.glob }
return next
}
if (f0.glob && t.startsWith('~') && t !== '~' && !t.startsWith('~/')) {
const slash = t.indexOf('/', 1)
const login = slash < 0 ? t.slice(1) : t.slice(1, slash)
const rest = slash < 0 ? '' : t.slice(slash)
let resolved = t
if (/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/.test(login)) {
const base = env.USER && login === env.USER ? home : `/home/${login}`
resolved = base + rest
}
const next = [...fr]
next[0] = { text: resolved, glob: false }
return next
}
return fr
}
/**
* Literal join of expanded fragments (no pathname expansion).
* @param {{ text: string, glob: boolean }[]} fr
*/
function literalJoin(fr) {
return fr.map((f) => f.text).join('')
}
/**
* Expand one shell word to pathnames (or a single literal).
* @param {Record<string, unknown>} ctx
* @param {ShellWordPart[]} parts
* @param {Record<string, string>} env
* @param {{ redirect?: boolean, disablePathnameExpansion?: boolean }} [opts]
* @returns {Promise<string[]>}
*/
export async function pathnameExpandShellWord(ctx, parts, env, opts = {}) {
const vfs = ctx.vfs
if (!vfs || typeof vfs.readdir !== 'function') {
return [parts.map((p) => p.t).join('')]
}
const rawBrace = String(env.BARE_OS_SHELL_BRACE_EXPANSION || '').trim().toLowerCase()
const braceOn = !(rawBrace === '0' || rawBrace === 'false' || rawBrace === 'off')
const noglob =
opts.disablePathnameExpansion ||
env.BARE_OS_SHELL_NOGLOB === '1' || env.BARE_OS_SHELL_NOGLOB === 'true'
const strict =
env.BARE_OS_STRICT_POSIX === '1' || env.BARE_OS_STRICT_POSIX === 'true'
const dotglob =
env.BARE_OS_DOTGLOB === '1' || env.BARE_OS_DOTGLOB === 'true'
const globstar =
env.BARE_OS_GLOBSTAR === '1' ||
env.BARE_OS_GLOBSTAR === 'true' ||
env.BARE_OS_GLOBSTAR === 'on'
const globstarMaxDepth =
Number.parseInt(env.BARE_OS_GLOBSTAR_MAX_DEPTH || '16', 10) || 16
const nocaseglob =
env.BARE_OS_GLOBIGNORECASE === '1' ||
env.BARE_OS_GLOBIGNORECASE === 'true' ||
env.BARE_OS_NOCASEGLOB === '1' ||
env.BARE_OS_NOCASEGLOB === 'true'
const maxMatch =
Number.parseInt(env.BARE_OS_GLOB_MAX_MATCHES || '4096', 10) || 4096
const ignore = parseGlobIgnore(env)
/** @type {string[]} */
const collected = []
const variants = explodeBraceParts(parts, braceOn, env)
for (const pv of variants) {
/** @type {{ text: string, glob: boolean }[]} */
const fr = []
for (const p of pv) {
if (p.q === 's') fr.push({ text: p.t, glob: false })
else fr.push({ text: p.t, glob: p.q === 'u' })
}
const frTilde = applyTildeToFragments(fr, env, ctx)
const needGlob =
!noglob && frTilde.some((f) => f.glob && /[*?\[]/.test(f.text))
if (!needGlob) {
collected.push(literalJoin(frTilde))
continue
}
const segs = fragmentsToPathSegments(frTilde)
/** @type {string[]} */
const variantMatches = []
let hitCount = 0
const walk = async (idx, curAbs, depth = 0) => {
if (hitCount >= maxMatch) return
if (idx >= segs.length) {
const pth = curAbs === '' ? vfs.getcwd() : curAbs
variantMatches.push(pth)
hitCount++
return
}
const { seg, g } = segs[idx]
if (seg === '' && idx === 0) {
await walk(idx + 1, '/')
return
}
if (g && globstar && seg === '**') {
await walk(idx + 1, curAbs, depth)
if (depth >= globstarMaxDepth) return
const dirAbs =
curAbs === '' ? vfs.getcwd() : curAbs === undefined ? vfs.getcwd() : curAbs
let names = []
try {
names = await vfs.readdir(dirAbs)
} catch {
names = []
}
for (const n of names) {
if (n === BAREOS_EMPTY) continue
if (!dotglob && n.startsWith('.')) continue
const full = joinUnder(dirAbs, n)
try {
const st = await vfs.stat(full)
if (st && st.type === 'directory') await walk(idx, full, depth + 1)
} catch {
/* skip */
}
}
return
}
if (!g) {
const nextAbs =
curAbs === ''
? unixPathResolve(vfs.getcwd(), seg)
: curAbs === '/'
? joinUnder('/', seg)
: joinUnder(curAbs, seg)
const last = idx === segs.length - 1
if (last) {
if (opts.redirect) {
try {
const st = await vfs.stat(nextAbs)
if (st && st.type === 'directory') return
} catch {
return
}
}
variantMatches.push(nextAbs)
hitCount++
} else {
try {
const st = await vfs.stat(nextAbs)
if (st && st.type === 'directory') await walk(idx + 1, nextAbs, depth + 1)
} catch {
/* missing path */
}
}
return
}
const dirAbs =
curAbs === '' ? vfs.getcwd() : curAbs === undefined ? vfs.getcwd() : curAbs
let names = []
try {
names = await vfs.readdir(dirAbs)
} catch {
names = []
}
const filtered = names.filter((n) => {
if (n === BAREOS_EMPTY) return false
if (!dotglob && n.startsWith('.')) return false
const nn = nocaseglob ? n.toLowerCase() : n
const ss = nocaseglob ? seg.toLowerCase() : seg
if (!bareOsFnmatch(nn, ss)) return false
const full = joinUnder(dirAbs, n)
if (globIgnoreHit(full, ignore)) return false
return true
})
filtered.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
const last = idx === segs.length - 1
for (const n of filtered) {
if (hitCount >= maxMatch) return
const full = joinUnder(dirAbs, n)
if (last) {
if (opts.redirect) {
try {
const st = await vfs.stat(full)
if (st && st.type === 'directory') continue
} catch {
continue
}
}
variantMatches.push(full)
hitCount++
} else {
try {
const st = await vfs.stat(full)
if (st && st.type === 'directory') await walk(idx + 1, full, depth + 1)
} catch {
/* skip */
}
}
}
}
await walk(0, '')
if (variantMatches.length === 0) {
if (strict) return []
collected.push(literalJoin(frTilde))
} else {
for (const m of variantMatches) collected.push(m)
}
}
return collected
}