UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

505 lines • 24.5 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { modifiedTs } from "../../../core/generic/find.js"; import { bindTree, dropPruned, emitStartPath, argsToTree, printfKind, settlePendingPrunes, startBasename, unrespellRaw, } from "../../../core/generic/find_eval.js"; import {} from "../../../core/generic/types.js"; import { fsStrerror, isEnoent, isEnotdir, isMissError, strerrorOf, walkRefusal, } from "../../../errors/fs.js"; import { posixPhrase } from "../../../errors/posix.js"; import { IOResult } from "../../../io/types.js"; import { parseFindExpression } from "../../../rs/find.js"; import { FileType, Follow, LineFacts, PathSpec } from "../../../types.js"; import { respellOne, respellRaw } from "../../../utils/path.js"; import { rstripSlash, stripSlash } from "../../../utils/slash.js"; import { compareCodePoints } from "../../../utils/sort.js"; import { readerOf } from "../../../workspace/files.js"; import { UsageError } from "../../errors.js"; import { specOf } from "../../spec/builtins.js"; import { OPERAND } from "../../spec/constants.js"; import { FlagView } from "../../spec/flag_view.js"; import { linksOf } from "../utils/links.js"; import { dotRefusal, linkFollow, statOrEnoent, vfsStatPath } from "../utils/paths.js"; const ENC = new TextEncoder(); // The stat probe's path for one display row. function rowSpec(row) { return new PathSpec({ virtual: row, directory: row, resolved: false, vfsPath: stripSlash(row), }); } // The structured row the action layer acts on: the resolved path, spelled // as it prints. function matchedPath(row, root) { const virtual = unrespellRaw(row, root.virtual, root.rawPath || root.virtual); return new PathSpec({ virtual, directory: virtual.slice(0, virtual.lastIndexOf('/')) || '/', vfsPath: stripSlash(virtual), rawPath: row, resolved: true, }); } async function applyMtimeFilter(results, mtimeMin, mtimeMax, stat) { if (mtimeMin === null && mtimeMax === null) return results; const filtered = []; for (const r of results) { let st; try { st = await stat(rowSpec(r)); } catch (err) { if (isEnoent(err) || isEnotdir(err)) continue; throw err; } const mt = modifiedTs(st.modified); if (mt === null) continue; if (mtimeMin !== null && mt < mtimeMin) continue; if (mtimeMax !== null && mt > mtimeMax) continue; filtered.push(r); } return filtered; } // Epoch-second mtime of one row through the overlay-aware stat, null when it // has none or is gone. async function rowMtime(stat, row) { try { return modifiedTs((await stat(rowSpec(row))).modified); } catch (err) { if (!isEnoent(err) && !isEnotdir(err)) throw err; return null; } } // Results for a start point that is not a directory. // // GNU reports a non-directory start point when it matches the expression // and walks nothing, because there is no subtree to descend. The entry // sits at depth 0 and tests as `f`, offering its own size and mtime to // -size, -mtime and -empty. // // Asking a backend to walk one instead is what this replaces, and every // backend answered differently: an object store listed the key as a // prefix and returned nothing, Graph 404'd on the children of a file, and // Box raised ENOTDIR. function startPointResults(root, start, options, tree, usesEmpty, mtimeMin, mtimeMax) { const results = []; if (mtimeMin !== null || mtimeMax !== null) { const ts = modifiedTs(start.modified ?? null); if (ts === null || Number.isNaN(ts)) return results; if (mtimeMin !== null && ts < mtimeMin) return results; if (mtimeMax !== null && ts > mtimeMax) return results; } emitStartPath(results, rstripSlash(root.virtual) || '/', startBasename(root.virtual), { kind: printfKind(start), isEmpty: usesEmpty && printfKind(start) === 'f' ? start.size === 0 : usesEmpty ? false : null, exists: true, tree, maxDepth: options.maxDepth ?? null, minDepth: options.minDepth ?? null, size: start.size ?? null, minSize: options.minSize ?? null, maxSize: options.maxSize ?? null, }); return results; } // Results for the directory start point itself, at depth 0. // // GNU lists a directory start point before descending into it, so // `find <dir>` names the directory even when it holds nothing. The generic // already statted the start point to get here, so it decides this row and // the backend only has to answer for descendants (see withRootRow for why // the backend's own row is dropped). // // -mtime is deliberately not applied here: the caller either filters every // row against namespace-aware times afterwards, or pushed the window into // the backend, and re-testing it against the probe's own stat would drop // rows a touch had just matched. function rootDirResults(root, options, tree, isEmpty) { const results = []; emitStartPath(results, rstripSlash(root.virtual) || '/', startBasename(root.virtual), { kind: 'd', isEmpty, exists: true, tree, maxDepth: options.maxDepth ?? null, minDepth: options.minDepth ?? null, minSize: options.minSize ?? null, maxSize: options.maxSize ?? null, }); return results; } // Replace the backend's row for the start point with the generic's. // // Most native find ops emit the start path themselves, and each judged it // on the only facts it had: ssh calls every directory non-empty, an object // store calls one empty only when its own listing was empty, and a store // holding no directory marker reported nothing at all. Merging instead of // replacing would keep whichever of those a backend happened to say, so the // row is dropped and the generic's takes its place. Descendants are still // entirely the backend's answer. // // Compared with trailing slashes stripped, because a directory key is // spelled both ways across backends (chroma reports the root as `<root>/`). function withRootRow(rows, display, root) { return rows .filter((r) => (rstripSlash(r) || '/') !== display) .concat(root.length > 0 ? [display] : []); } // The strerror of a start point statPath found nothing at. statPath answers // null for both ways a lookup fails, because every other caller of it treats // them alike, while GNU names the one its stat met. So the mount's own stat // is asked which, on the failure path only: a start point under a plain file // is ENOTDIR. Mirrors _missing_start_detail in find.py. async function missingStartDetail(root, stat) { if (stat === undefined) return posixPhrase('ENOENT'); try { await stat(root); } catch (err) { if (isEnotdir(err)) return posixPhrase('ENOTDIR'); if (isMissError(err)) return posixPhrase('ENOENT'); throw err; } return posixPhrase('ENOENT'); } function parseFlags(bag) { const fl = new FlagView(bag, specOf('find')); return { name: fl.asStr('name') ?? null, type: fl.asStr('type') ?? null, size: fl.asStr('size') ?? null, mtime: fl.asStr('mtime') ?? null, maxdepth: fl.asStr('maxdepth') ?? null, iname: fl.asStr('iname') ?? null, path: fl.asStr('path') ?? null, mindepth: fl.asStr('mindepth') ?? null, empty: fl.asBool('empty'), }; } export function findGeneric(paths, texts, ctx, find, stat, dirEmpty, unreadable, unstatted, // A walker already evaluates times in its predicate tree. Its raw stat is // needed only to distinguish ENOTDIR from ENOENT at a missing start point. missingStat, // Under -L, each directory the walk reached again and the one above it // that it repeats. loops, // Where a walk ran past walkLimit, each line without the command's // name. stopped) { const parsed = parseFlags(ctx.flags); const nameFlag = parsed.name; const inameFlag = parsed.iname; const typeFlag = parsed.type; const pathFlag = parsed.path; const maxDepthFlag = parsed.maxdepth; const minDepthFlag = parsed.mindepth; const sizeFlag = parsed.size; const mtimeFlag = parsed.mtime; const targets = paths.length > 0 ? paths : [ new PathSpec({ vfsPath: '', virtual: '/', directory: '/', resolved: false, }), ]; // Passed through rather than narrowed to f/d: `-type l` is a real value // (namespace symlinks), and collapsing anything else to "no filter" would // make the flag form print every entry where python prints none. const findType = typeFlag; const expr = ctx.find ?? (texts.length > 0 ? parseFindExpression(texts) : null); const flagWords = [ ...(maxDepthFlag !== null ? ['-maxdepth', maxDepthFlag] : []), ...(minDepthFlag !== null ? ['-mindepth', minDepthFlag] : []), ...(sizeFlag !== null ? ['-size', sizeFlag] : []), ...(mtimeFlag !== null ? ['-mtime', mtimeFlag] : []), ]; let limits = null; try { limits = expr === null && flagWords.length > 0 ? parseFindExpression(flagWords) : null; } catch (err) { if (err instanceof UsageError) { return Promise.resolve([ null, new IOResult({ exitCode: 1, stderr: ENC.encode(`${err.message}\n`) }), ]); } throw err; } const maxDepth = limits?.maxDepth ?? null; const minDepth = limits?.minDepth ?? null; const minSize = limits?.minSize ?? null; const maxSize = limits?.maxSize ?? null; const mtimeMin = limits?.mtimeMin ?? null; const mtimeMax = limits?.mtimeMax ?? null; const emptyFlag = parsed.empty; // With a stat wired, the mtime window is applied by the overlay- // aware post-filter below, not pushed into the core: backend cores // only see native times and would drop files whose mtime lives in // the namespace (touch results, observed writes). const pushMtime = stat === undefined; const effMtimeMin = expr !== null ? expr.mtimeMin : mtimeMin; const effMtimeMax = expr !== null ? expr.mtimeMax : mtimeMax; const options = expr !== null ? { tree: expr.tree, ...(expr.maxDepth !== null ? { maxDepth: expr.maxDepth } : {}), ...(expr.minDepth !== null ? { minDepth: expr.minDepth } : {}), ...(expr.minSize !== null ? { minSize: expr.minSize } : {}), ...(expr.maxSize !== null ? { maxSize: expr.maxSize } : {}), ...(pushMtime && expr.mtimeMin !== null ? { mtimeMin: expr.mtimeMin } : {}), ...(pushMtime && expr.mtimeMax !== null ? { mtimeMax: expr.mtimeMax } : {}), ...(expr.usesEmpty ? { empty: true } : {}), } : { name: nameFlag, iname: inameFlag, type: findType, ...(maxDepth !== null ? { maxDepth } : {}), ...(minDepth !== null ? { minDepth } : {}), ...(minSize !== null ? { minSize } : {}), ...(maxSize !== null ? { maxSize } : {}), ...(pushMtime && mtimeMin !== null ? { mtimeMin } : {}), ...(pushMtime && mtimeMax !== null ? { mtimeMax } : {}), ...(pathFlag !== null ? { pathPattern: pathFlag } : {}), ...(emptyFlag ? { empty: true } : {}), }; const records = []; const io = new IOResult({ records }); async function* stream() { const missing = []; // Each row names the start point it was found under, in operand order: // the action layer acts on each traversal on its own and reads a row's // start point off it (-printf's %P and %d). for (const [run, root] of targets.entries()) { // `-path` matches the row as printed; stamp the operand's spelling onto // path nodes before the walk (#396). Bound per start point: options is // shared by every one of them and must stay unbound. const tree = bindTree(argsToTree(options), '', root.virtual, root.rawPath); const rootOptions = { ...options, tree }; // A start point that is itself a symlink is the walk's to report: the // table it walks answers the link's own row, and GNU's default -P // reports the link and stops there. The dispatcher's stat would follow // it, so the start point's checks below leave it alone. const rootIsLink = (linksOf(ctx) ?? null)?.statAt(root.virtual) != null; // What the start point is decides which walk is even possible, so it // is resolved once, ahead of all of them: a symlink is walked as // itself, a non-directory has no subtree, and nothing at all is GNU's // diagnostic. Statted through the dispatcher, // so a start point the router already resolved into another mount // answers there rather than on this command's mount. // The probe asks both channels a backend can answer on, so a directory // that exists only as its children still reports as one and null means // nothing is there (see resolvePathStat). That is what makes the // missing case answerable above every backend rather than only where // one wires a stat. const startStat = vfsStatPath(ctx.vfs); if (root.walkError !== null) { // The walk refused the start point before find ran (the empty // name, a link loop), and every probe below goes by the path it // simplifies to. Mirrors Python's resolve_start. missing.push(`find: '${root.rawPath}': ${fsStrerror(walkRefusal(root)) ?? posixPhrase('ENOENT')}`); continue; } // A start point's own `.` and `..` resolve first, link or not: the // lookup below asks about the path they simplify to. if (startStat !== undefined) { const refusal = await dotRefusal(statOrEnoent(startStat), root, linkFollow(linksOf(ctx))); if (refusal !== null) { const label = root.rawPath !== '' ? root.rawPath : root.virtual; missing.push(`find: '${label}': ${fsStrerror(refusal) ?? posixPhrase('ENOENT')}`); continue; } } let startIsDir = false; if (startStat !== undefined && !rootIsLink) { let start; try { start = await startStat(root.virtual); } catch (err) { // A start point the dispatcher refuses to stat is GNU's own // diagnostic for it, quoted like a missing one // (`find: 'P': Permission denied`), not an escaped error. const detail = fsStrerror(err); if (detail === null) throw err; missing.push(`find: '${root.rawPath !== '' ? root.rawPath : root.virtual}': ${detail}`); continue; } if (start === null) { // GNU names each start point it cannot stat, keeps going with the // rest, and exits 1. Reported as the operand was typed, falling // back to the resolved path for a synthesized root. const label = root.rawPath !== '' ? root.rawPath : root.virtual; missing.push(`find: '${label}': ${await missingStartDetail(root, missingStat ?? stat)}`); continue; } if (start.type !== FileType.DIRECTORY && root.rawPath.endsWith('/')) { // POSIX reads `x/` as `x/.`, so an operand typed with a trailing // slash has to name a directory; GNU refuses the rest with // ENOTDIR rather than reporting the entry itself. missing.push(`find: '${root.rawPath}': Not a directory`); continue; } if (start.type !== FileType.DIRECTORY) { const rows = startPointResults(root, start, rootOptions, argsToTree(rootOptions), expr !== null ? expr.usesEmpty : emptyFlag, effMtimeMin, effMtimeMax); // The only row possible is the start point itself, so its display // path is the operand, not a key that needs rebasing. if (rows.length > 0) { const display = root.virtual === '/' ? '/' : rstripSlash(root.virtual); const added = respellRaw([display], root.virtual, root.rawPath); yield ENC.encode(added.join('\n') + '\n'); for (const r of added) records.push({ path: matchedPath(r, root), run }); } continue; } startIsDir = true; } // The directory row is known before any native op fetches descendants. // Yielding it first lets a closed pipe prevent that remote traversal. const usesEmptyEarly = expr !== null ? expr.usesEmpty : emptyFlag; let first = []; if (startIsDir && !usesEmptyEarly && !(pushMtime && (effMtimeMin !== null || effMtimeMax !== null))) { const rootRows = rootDirResults(root, rootOptions, argsToTree(rootOptions), null).length > 0 ? [rstripSlash(root.virtual) || '/'] : []; const checked = stat !== undefined ? await applyMtimeFilter(rootRows, effMtimeMin, effMtimeMax, stat) : rootRows; first = respellRaw(checked, root.virtual, root.rawPath); for (const row of first) yield ENC.encode(row + '\n'); } let keys; try { keys = await find(root, rootOptions); } catch (err) { // GNU find reports missing roots and moves on; anything else // (rate limits, auth failures) must surface. if (isEnoent(err)) continue; throw err; } // GNU names a directory it may not open in the walk's own order, // lists the directory itself, and exits 1 like a start point it // could not read. Drained per start point, so the lines stay under // the operand that walked them. for (const shown of respellRaw(unreadable?.() ?? [], root.virtual, root.rawPath)) { missing.push(`find: '${shown}': Permission denied`); } // An entry the walk could not stat is named the same way, and stays // listed where no test needed its stat. for (const [path, err] of unstatted?.() ?? []) { const shown = respellOne(path, root.virtual, root.rawPath); missing.push(`find: '${shown}': ${strerrorOf(err)}`); } for (const [path, above] of loops?.() ?? []) { missing.push(`find: File system loop detected; '${respellOne(path, root.virtual, root.rawPath)}' ` + `is part of the same file system loop as '${respellOne(above, root.virtual, root.rawPath)}'.`); } for (const line of stopped?.() ?? []) missing.push(`find: ${line}`); const rootKey = rstripSlash(root.virtual) || '/'; const rootMatches = []; for (const key of keys) { const displayPath = root.virtual === '/' ? key : rootKey === '/' && key === '/' ? rstripSlash(root.virtual) : rstripSlash(root.virtual) + key.slice(rootKey === '/' ? 0 : rootKey.length); rootMatches.push(displayPath); } // GNU lists a directory start point itself before descending into it, so // it is named even when it holds nothing. Decided here rather than by // each backend, which read existence off its own listing. A pushed-down // mtime window is the one case left to the backend: this row never // passed through it. const mtimePushed = pushMtime && (effMtimeMin !== null || effMtimeMax !== null); // Emptiness is the one fact this row needs that a caller can decline to // offer (a bespoke wrapper wires no readdir), and that caller's op may // know it. Left alone in that case, so a backend's answer is never // traded for "unknown". const usesEmpty = expr !== null ? expr.usesEmpty : emptyFlag; const canProbe = !usesEmpty || dirEmpty !== undefined; let rows = rootMatches; if (startIsDir && !mtimePushed && canProbe) { const rootEmpty = usesEmpty && dirEmpty !== undefined ? await dirEmpty(root) : null; rows = withRootRow(rootMatches, root.virtual === '/' ? '/' : rstripSlash(root.virtual), rootDirResults(root, rootOptions, argsToTree(rootOptions), rootEmpty)); } const filtered = stat !== undefined ? await applyMtimeFilter(rows, effMtimeMin, effMtimeMax, stat) : rows; filtered.sort(compareCodePoints); // What -prune reached is known only once every row has been judged: a // flat listing meets a child before its parent, so the ledger the tree // kept is applied here, after every row is in. if (stat !== undefined) { await settlePendingPrunes(tree, (key) => rowMtime(stat, key)); } const unpruned = dropPruned(filtered, tree); // Hidden rows drop here, above the native-op/walk fork, so a mount's // visibility behavior cannot depend on whether its backend ships a // native find op: a walk through the dispatcher meets no hidden row, // but a backend's own find knows nothing of hides. const reader = ctx.vfs === undefined ? null : readerOf(ctx.vfs); const visibleRows = reader === null ? unpruned : unpruned.filter((row) => reader.visible(row)); const added = respellRaw(visibleRows, root.virtual, root.rawPath); for (const row of added) if (!first.includes(row)) yield ENC.encode(row + '\n'); for (const r of added) records.push({ path: matchedPath(r, root), run }); } if (missing.length > 0) { io.stderr = ENC.encode(missing.join('\n') + '\n'); io.exitCode = 1; } } return Promise.resolve([stream(), io]); } /** * A find line walks every mount below its start points, and follows a start * point that is a link under `-H` or `-L`: the last of `-P`, `-H` and `-L` * typed before the first start point wins, where GNU reads them (findutils * 4.10). Mirrors Python's `facts`. */ export function facts(fl, _operands) { let follows = Follow.NONE; for (const [name] of fl.occurrences(OPERAND, 'P', 'H', 'L')) { if (name === OPERAND) break; follows = name === 'P' ? Follow.NONE : Follow.LAST; } return new LineFacts({ follows, walks: true, readsSubtree: true }); } //# sourceMappingURL=find.js.map