UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

460 lines • 19.7 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 { FileStat, FileType, LINK_TARGET_KEY, PathSpec } from "../../../types.js"; import { dotWalkError, eexist, enoent, isDotWalkError, isMissError, isMissingPath, } from "../../../errors/fs.js"; import { operandSpelling } from "../../../errors/render.js"; import {} from "../../../errors/types.js"; import { respelled } from "../../../utils/key_prefix.js"; import { CycleError, dotLookups, dotPrefixes, gnuBasename, norm, parent, resolvePath, } from "../../../utils/path.js"; import { rstripSlash, stripSlash } from "../../../utils/slash.js"; import { flagOccurrences } from "../../spec/flag_view.js"; /** * Opt in by assigning a debug sink, as Python's module logger is off until * configured: every failed read runs the probe, and stdout may carry data. */ export const logger = {}; // What a path is, asked on both channels a backend can answer on. // // A point lookup alone cannot decide. On a prefix store a directory is not // an object, it is the set of keys under it, so stat misses what readdir // would list. Absence therefore takes *both* channels coming back empty, // which is the only evidence that nothing is there. // // The listing has to be non-empty to count: those stores answer a missing // path with [] rather than raising, and cannot hold an empty directory // anyway (one with no keys under it does not exist). Measured across every // integ target: an implicit directory answers here, a missing path does not. // That holds only while a backend's readdir refuses a path it cannot prove: // postgres answered tables/views under any first segment, and every absent // schema read as a directory here. // // A link loop in the path is absence too: the walk reaches nothing there, // which is the question this answers, and a diagnostic that has to name // the errno asks missCondition. Mirrors Python's resolve_path_stat. export async function resolvePathStat(dispatch, path) { let stat = null; try { const [s] = await dispatch('stat', path); stat = s; } catch (exc) { if (!isMissError(exc) && exc.code !== 'ELOOP') throw exc; } if (stat !== null) return stat; let entries; try { const [raw] = await dispatch('readdir', path); entries = raw; } catch (exc) { if (!isMissError(exc) && exc.code !== 'ELOOP') throw exc; return null; } if (!Array.isArray(entries) || entries.length === 0) return null; return new FileStat({ name: gnuBasename(rstripSlash(path.virtual)), type: FileType.DIRECTORY, }); } // Stat via dispatch in the shape the generics' probes take: destKind and // its kin are written against a backend stat that raises on a miss, so a // dispatcher answer of nothing becomes ENOENT. Mirrors Python's // dispatch_stat. export function dispatchStat(dispatch) { return async (path) => { const [stat] = await dispatch('stat', path); if (stat === null || stat === undefined) throw enoent(path); return stat; }; } function statThrough(files, path) { return resolvePathStat(files.dispatch, PathSpec.fromStrPath(path, undefined, '/')); } async function readdirThrough(files, path) { const [entries] = await files.dispatch('readdir', PathSpec.fromStrPath(path, undefined, '/')); return entries; } // Stat one path through the session's files, null when absent, wherever it // lives (`find -L` crosses mounts); undefined outside a workspace. Mirrors // Python's vfs_stat_path. export function vfsStatPath(files) { return files === undefined ? undefined : (path) => statThrough(files, path); } // List one directory through the session's files, for a walk that crosses a // mount; undefined outside a workspace. Mirrors Python's vfs_readdir_path. export function vfsReaddirPath(files) { return files === undefined ? undefined : (path) => readdirThrough(files, path); } // A dispatcher lookup in the shape a chain walk reads. StatPath answers // null for a miss, while dotRefusal and its kin read a stat that throws, so // a command that holds only the lookup (`vfsStatPath`) wraps it here. // Mirrors Python's stat_or_enoent. export function statOrEnoent(statPath) { return async (path) => { const row = await statPath(path); if (row === null) throw enoent(path); return row; }; } /** * `path` keyed by its whole virtual path, the shape a command's paths take: * the dispatcher routes each one to its mount, so no command derives a mount * from a path's key. Mirrors Python's virtual_key. */ export function virtualKey(path) { return new PathSpec({ virtual: path.virtual, directory: path.directory, vfsPath: stripSlash(path.virtual), pattern: path.pattern, resolved: path.resolved, rawPath: path.rawPath, dotted: path.dotted, walkError: path.walkError, }); } /** * A command's flags with every path value keyed by its whole virtual path * (`virtualKey`): a single path (awk -f) or a list of them (repeated grep -f). * Everything else passes through unchanged. Mirrors Python's virtual_flags. */ export function virtualFlags(bag) { const keyed = { ...bag }; flagOccurrences(keyed).push(...flagOccurrences(bag)); for (const [key, value] of Object.entries(bag)) { if (value instanceof PathSpec) keyed[key] = virtualKey(value); else if (Array.isArray(value)) keyed[key] = value.map((item) => (item instanceof PathSpec ? virtualKey(item) : item)); } return keyed; } // `path` spelled from its operand as typed, the way GNU names it. Mirrors // Python's spelled_from. export function spelledFrom(path, operand) { return respelled(path, operandSpelling(path.virtual, operand)); } // A path at, under or above `root`, spelled the way `root` is. Mirrors // Python's descendant_path. export function descendantPath(root, virtual) { return spelledFrom(PathSpec.fromStrPath(virtual), root); } // Probe a path once for {exists, isDir}. ENOTDIR counts as "does not exist": // a path whose parent chain runs through a plain file cannot exist. This is // the probe for a path that is not an operand (an ancestor in a chain walk, // an overwrite target already paired); an operand itself goes through // sourceKind or destKind, which keep the ENOTDIR a slashed spelling earns. // isMissingPath stays ENOENT-only so read-family commands keep reporting // "Not a directory" verbatim. export async function entryKind(stat, path) { let info; try { info = await stat(path); } catch (err) { const code = err.code; if (!isMissingPath(err) && code !== 'ENOTDIR') throw err; return { exists: false, isDir: false }; } return { exists: true, isDir: info.type === FileType.DIRECTORY }; } // The nearest ancestor of `path` that exists, and whether it is a // directory. Walked upward from the immediate parent, as the kernel stops // resolving at the first component it cannot pass. The mount root always // exists as a directory and is never stat-ed: a backend that cannot stat // "/" must not fail every operand under it. Mirrors Python's // nearest_ancestor. export async function nearestAncestor(stat, path) { let node = parent(norm(path.virtual)); while (node !== '/') { const { exists, isDir } = await entryKind(stat, descendantPath(path, node)); if (exists) return [node, isDir]; node = parent(node); } return ['/', true]; } // Whether a path that failed with ENOENT is an implicit directory. Keyed // backends (RAM/Redis/S3) have no directory entries: stat/read of a prefix // that only exists through deeper keys raises ENOENT. The operand's own // readdir cannot serve as the probe: synthetic hierarchies fabricate // children for any name (postgres answers tables/views for a missing // schema) and database backends raise driver errors for missing tables. // The parent listing is authoritative instead: the operand is an implicit // directory only if its parent's readdir lists it. When the operand is the // mount root there is no parent to list, so its own readdir decides (root // listings are real in every backend). A listing that misses is a negative // probe (the original ENOENT stands); any other failure propagates, as // Python's MISS_ERRORS catch lets it. Mirrors Python's `is_implicit_dir`. export async function isImplicitDir(readdir, path) { const target = norm(path.virtual); const key = stripSlash(path.vfsPath); if (!key) { try { const entries = await readdir(path); return entries.length > 0; } catch (err) { if (!isMissError(err) && !isMissingPath(err)) throw err; return false; } } const parentKey = key.includes('/') ? key.slice(0, key.lastIndexOf('/')) : ''; const parentVirtual = parent(target); const parentPath = new PathSpec({ virtual: parentVirtual, directory: parentVirtual, vfsPath: parentKey, }); try { const entries = await readdir(parentPath); return entries.some((entry) => norm(entry) === target); } catch (err) { if (!isMissError(err) && !isMissingPath(err)) throw err; return false; } } /** * Whether a failed or empty read was a read of a directory. * * Asked after failure or EOF without bytes: some drivers return an empty * stream for directories. Nonempty reads need no extra probe. One that knows * says so (gdrive, box, dropbox and disk throw EISDIR), a keyed store answers * ENOENT because a directory there is a set of keys rather than an object, * and sftp answers with an error carrying no errno at all. * * Three ways the answer can be yes, in probe-cost order. The code itself * costs nothing. The stat is one call, and a stat that ANSWERS ends the * cascade either way: a file is a file, and the later probe only makes * sense for a path stat could not see. Reaching past a successful stat * read a rule-refused file as a directory, because its parent's listing * names it. The parent listing is one call and is the only thing that can * tell a missing key from a prefix that exists only through deeper keys. * * A no leaves the original error untouched, so nothing is swallowed: the * caller rethrows what the backend said. Both probes are broad for that * same reason: a probe that fails is a negative probe, never an error to * surface. Surfacing one would replace the read's error with one from a * call the user never made, and it is the read that failed. * `isImplicitDir` narrows to a miss because for its other caller the stat * IS the operation; here it is a probe, so the wider catch belongs on this * side of the call, and each failure it absorbs is logged. Mirrors Python's * `read_hit_a_dir`. */ export async function readHitADir(stat, readdir, path, err) { if (err?.code === 'EISDIR') return true; // The path did not resolve at all, which no reading turns into a // directory; its parent may well list the name it simplifies to. if (isDotWalkError(err)) return false; let st = null; try { st = await stat(path); } catch (probe) { logger.debug?.(`read probe: stat ${path.virtual} failed: ${String(probe)}`); // Only a stat that finds nothing leaves room for a directory of deeper // keys. One that fails otherwise (an upstream error) proves nothing, and // the parent listing would name the file whose backend failed. if (!isMissError(probe) && !isMissingPath(probe)) return false; st = null; } if (st !== null) return st.type === FileType.DIRECTORY; try { if (await isImplicitDir(readdir, path)) return true; } catch (probe) { logger.debug?.(`read probe: listing ${path.virtual} failed: ${String(probe)}`); } return false; } // The condition a create at an absent path meets in its parent chain: null // when the immediate parent is a directory, so the path can be made there; // ENOTDIR when a plain file stands in the chain; ENOENT when a directory // higher up is the nearest thing there, the components below it being // absent. For a caller that already knows `target` is not there, which is // what destKind finds out first. Mirrors Python's absent_dest_error. export async function absentDestError(stat, target) { const [node, isDir] = await nearestAncestor(stat, target); if (!isDir) return 'ENOTDIR'; return node === parent(norm(target.virtual)) ? null : 'ENOENT'; } // The link resolution a dot walk is handed, null while no link exists. // Mirrors Python's link_follow. export function linkFollow(links) { return links === null || links === undefined ? null : (path) => links.resolve(path); } // One link's target, the hop a canonicalizing walk reads, null while no // link exists. Mirrors Python's link_target. export function linkTarget(links) { if (links === null || links === undefined) return null; return (path) => { const row = links.statAt(path); return row === null ? null : row.extra[LINK_TARGET_KEY]; }; } // Whether `virtual` is the path a dotted spelling names: the textual // simplification, or that simplification taken through the links. The // kernel walk (`followPaths`) resolves an operand before its command runs, // every component or all but the last, and the operand it hands on is still // the one typed. Mirrors Python's _spells. function spells(dotted, virtual, follow) { const spelled = resolvePath(dotted, '/'); if (spelled === virtual) return true; if (follow === null) return false; const trimmed = rstripSlash(dotted); const cut = trimmed.lastIndexOf('/'); const head = trimmed.slice(0, cut); const name = trimmed.slice(cut + 1); try { const whole = resolvePath(follow(dotted), '/'); const above = follow(head === '' ? '/' : head); return virtual === whole || virtual === resolvePath(`${rstripSlash(above)}/${name}`, '/'); } catch (err) { if (err instanceof CycleError) return false; throw err; } } /** * The typed spelling, links before `..`, while it names the path. Mirrors * Python's walk_spelling. * * Without a trailing slash: that is a final `.`, which `dotRefusal` proves, * and a store that keeps no directories reads a slashed key as one, so * `cat reg/` there was ENOENT, not ENOTDIR. */ export function walkSpelling(path, follow) { const dotted = path.dotted; if (dotted !== null && spells(dotted, path.virtual, follow)) return rstripSlash(dotted) || '/'; return path.virtual; } /** * ENOENT when the walk of a path's dots looks up a name the session cannot * see, null when it sees every one. * * `virtual` simplified the dots away, so a hidden name in front of one * (`secret/../f`, a hidden link's `lnk/../f`) is gone from the path the * hides judge, while the walk still has to find it. It answers as a missing * name does, and before `dotRefusal` proves anything a directory, so the * refusal cannot tell a hide from an absence. The walk stops at the hidden * name, so a link there is never followed. Mirrors Python's hidden_walk. */ export function hiddenWalk(path, follow, visible) { const dotted = path.dotted; if (dotted === null || !spells(dotted, resolvePath(path.virtual, '/'), follow)) return null; try { for (const looked of dotLookups(dotted, follow)) { if (!visible(looked)) return dotWalkError(path, 'ENOENT'); } } catch (err) { if (err instanceof CycleError) return dotWalkError(path, 'ELOOP'); throw err; } return null; } /** * What a path's dot components answer, null when every one resolves. * * The kernel resolves `.` and `..` against the directory they sit in, so a * walk through a missing name fails ENOENT and one through a plain file * ENOTDIR (`cat nope/../f`, `cat f.txt/.`), where the textual simplification * in `virtual` reached `f` regardless. Each name in front of a dot is proved * a directory, in walk order, and one that is not is judged by its chain the * way a create is, so a miss under a plain file is ENOTDIR on every store. * A link in front of a dot is followed first, as the kernel walks (only * bash's `cd` reads `link/..` logically). A trailing slash is a final `.`: * an existing name in front of it has to be a directory too (`cat reg/`); a * call that creates that name (`creates`: mkdir, symlink) answers EEXIST * instead (`mkdir reg/`), however the store keeps the name. * * Only the path the spelling names is walked: a path derived from it (a * child a walker builds, a respelled match) carries the field along but no * longer spells it, while the operand the kernel walk followed through a * link (`lnk/nope/../f`) still does, which `follow` recognizes. The error * names the operand as typed and is a DotWalkError, final for every layer * that re-reads a miss. Mirrors Python's dot_refusal. */ export async function dotRefusal(stat, path, follow = null, creates = false) { const dotted = path.dotted; if (dotted === null || !spells(dotted, resolvePath(path.virtual, '/'), follow)) return null; const proved = []; let prefixes; try { prefixes = dotPrefixes(dotted, follow); } catch (err) { if (err instanceof CycleError) return dotWalkError(path, 'ELOOP'); throw err; } for (const prefix of prefixes) { if (proved.some((done) => done.startsWith(`${prefix}/`))) continue; const spec = PathSpec.fromStrPath(prefix); const { exists, isDir } = await entryKind(stat, spec); if (exists && isDir) { proved.push(prefix); continue; } if (!exists && (await nearestAncestor(stat, spec))[1]) return dotWalkError(path, 'ENOENT'); return dotWalkError(path, 'ENOTDIR'); } if (dotted.endsWith('/')) { const { exists, isDir } = await entryKind(stat, PathSpec.fromStrPath(path.virtual)); if (exists && !isDir) return creates ? eexist(path) : dotWalkError(path, 'ENOTDIR'); } return null; } // Resolve a file a transfer reads or writes (curl -o, wget -O) against the // session cwd. export function resolveTarget(o, cwd) { return PathSpec.fromStrPath(o, undefined, cwd); } // Default a command's path operands the way the shell would: explicit // operands pass through, otherwise the session cwd becomes the single // operand. Mirrors Python's default_paths. export function defaultPaths(paths, cwd) { if (paths.length > 0) return paths; return [PathSpec.fromStrPath(cwd)]; } //# sourceMappingURL=paths.js.map