454 lines
13 KiB
JavaScript
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
|
|
}
|