@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
505 lines • 24.5 kB
JavaScript
// ========= 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