/** * 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} 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} 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} env * @param {Record} 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} ctx * @param {ShellWordPart[]} parts * @param {Record} env * @param {{ redirect?: boolean, disablePathnameExpansion?: boolean }} [opts] * @returns {Promise} */ 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 }