@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
395 lines • 18.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 { classify } from "./classify.js";
import { ERRNO_ARMS, OPERAND_CONDITIONS } from "./constants.js";
import { posixPhrase } from "./posix.js";
import { CommandTimeoutError } from "./types.js";
import { stripSlash } from "../utils/slash.js";
// Accepts a PathSpec (reads .rawPath, the word's spelling, which defaults
// to .virtual) or a bare virtual-path string. Taking a structural shape
// avoids importing the PathSpec class (no import cycle). .rawPath is always
// a virtual-space path, never a real fs path.
export function virtualOf(path) {
if (typeof path === 'string')
return path;
return path.rawPath ?? path.virtual;
}
// The error a mount raises for one code at one path: the code stamped, the
// operand as virtualPath, and the path itself as the message unless the
// caller names what was refused. Every constructor below is this one.
// Mirrors Python's fs_error.
export function fsError(path, code, message) {
const virtual = virtualOf(path);
const err = new Error(message ?? virtual);
err.code = code;
err.virtualPath = virtual;
return err;
}
// An error for a code with no path to name, so a command names its operand.
// Python raises the built-in (PermissionError) without a filename.
export function unnamedFsError(code, message) {
return Object.assign(new Error(message), { code });
}
// Mirrors Python's enoent. The strerror suffix ("No such file or directory")
// is appended once at the command chokepoints.
export function enoent(path) {
return fsError(path, 'ENOENT');
}
/** EBADF for a read from a descriptor that is closed or write-only, named
* as the reader names it (`-` for standard input). Mirrors Python's ebadf. */
export function ebadf(path) {
return fsError(path, 'EBADF');
}
/** EFBIG: a read the backend refuses to render whole (a records file past its
* mount's record cap); python's FileTooLargeError. Per-operand, like ENOENT,
* so a read-family command reports it and moves on. */
export function efbig(path) {
return fsError(path, 'EFBIG');
}
/** EDQUOT: a write past its mount's capacity; python's QuotaExceededError.
* Per-operand, so a command reports it and moves on. */
export function edquot(path) {
return fsError(path, 'EDQUOT');
}
/** A conditional write the backend refused: the file changed since it was
* read. Per-operand, so a command reports it and moves on; never a read
* failure. Python's StaleWriteError. */
export function staleWrite(path, landed = false) {
return Object.assign(fsError(path, 'STALE_WRITE'), { landed });
}
/**
* The part of a failure's path below `operand`, '' when it is the operand: a
* recursive command that fails on a file inside its operand names that
* file, as GNU does (`rm: cannot remove 'd/s/b'`). Mirrors Python's
* `inner_suffix`.
*/
export function innerSuffix(operand, err) {
const name = err.virtualPath;
const base = operand.virtual.replace(/\/+$/, '');
if (typeof name !== 'string' || !name.startsWith(`${base}/`))
return '';
return name.slice(base.length);
}
/** An operand as typed, or naming the file inside it that failed. Mirrors Python's `with_inner`. */
export function withInner(raw, inner) {
return inner === '' ? raw : raw.replace(/\/+$/, '') + inner;
}
// Python's twin is `except StaleWriteError`.
export function isStaleWrite(err) {
return hasCode(err, 'STALE_WRITE');
}
export function isLandedMove(err) {
return isStaleWrite(err) && err.landed === true;
}
export function ebusy(path) {
return fsError(path, 'EBUSY');
}
// ENOTDIR: a component of the path is a plain file. What open(2) and stat(2)
// answer for `a.txt/x`, and what a lookup there answers on ram, redis, disk
// and OPFS too: the keyed stores walk the parents on a miss (their
// lookupError), the filesystems hear it from the kernel. Deliberate
// divergence: object stores and SFTP answer ENOENT, because telling the two
// apart costs a request per ancestor on every miss, a stat miss is the
// ordinary case of a copy's destination probe, and an object store may hold
// `a.txt` and `a.txt/x` at once. Mirrors Python's enotdir.
export function enotdir(path) {
return fsError(path, 'ENOTDIR');
}
export function dotWalkError(path, code) {
return Object.assign(fsError(path, code), { dotWalk: true });
}
/** What an op raises for an operand the kernel walk did not resolve, named
* as typed (the empty name included), since that is what the command
* reports and `virtual` names the working directory for it. Mirrors
* Python's walk_refusal. */
export function walkRefusal(path) {
return dotWalkError(path, path.walkError ?? 'ENOENT');
}
/** ELOOP: a link loop stands in the path's walk. A walk refusal, so final
* for every layer that re-reads a miss, and a coded fs error, so a
* per-operand catch words it where the namespace's own CycleError escaped
* every one. The dispatcher throws it for a loop above any name it is handed.
* Mirrors Python's eloop. */
export function eloop(path) {
return dotWalkError(path, 'ELOOP');
}
export function isDotWalkError(err) {
return err instanceof Error && err.dotWalk === true;
}
export function eisdir(path) {
return fsError(path, 'EISDIR');
}
export function eexist(path) {
return fsError(path, 'EEXIST');
}
// A refused mutation that is not absence and not a mode: a lock or a policy
// in practice. A caller may name what was refused (the s3 batch delete names
// its count).
export function eacces(path, message) {
return fsError(path, 'EACCES', message);
}
// The below-mode refusal, stamped EROFS + operand so fs chokepoints render
// 'Read-only file system', matching Python's erofs from the same guard: the
// mode voice, distinct from both the hide voice (ENOENT) and the policy voice
// (EACCES). The message may say which mount (executor builtins sniff
// 'read-only').
export function erofs(path, message) {
return fsError(path, 'EROFS', message);
}
// An extended attribute the path does not carry. ENODATA is linux's
// spelling; classify reads it, and macOS's ENOATTR, as NO_XATTR.
export function noXattr(path) {
return fsError(path, 'ENODATA');
}
export function enotempty(path) {
return fsError(path, 'ENOTEMPTY');
}
// readlink on a path that exists but is not a symlink. Mirrors Python's
// OSError(errno.EINVAL).
export function einval(path, message) {
return fsError(path, 'EINVAL', message);
}
// A rename whose two ends sit on different mounts. POSIX's answer for a
// rename across filesystems, and the one a caller reads as "copy and
// unlink instead" (that is what makes `mv` work over a FUSE mount).
export function exdev(path) {
return fsError(path, 'EXDEV');
}
// The errno a failed directory listing should report. opendir reports ENOTDIR
// only when a component of the path exists and is not a directory
// (`ls /f.txt/x` -> 'Not a directory'); a component that does not exist at all
// is ENOENT (`ls /nope` -> 'No such file or directory'), however deep it is.
// Store-backed backends have no kernel to draw that line for them, so they
// walk the ancestors and ask here instead of collapsing both cases into one
// errno. `key` is the mount-local normalized path that was looked up, isFile
// probes whether a mount-local path exists as a non-directory and isDir
// whether it exists as a directory. The walk stops at the first component
// that is neither, the way the kernel stops resolving there: a store can hold
// a key whose parent is not a directory, and looking past that gap would
// report ENOTDIR for a path the kernel never reaches.
// A component is tested as a directory FIRST, because a keyed store can hold
// both an object `a` and a prefix `a/` and traversal only ever reaches an
// intermediate component through the directory: with an object `a` and a key
// `a/x`, `ls /a/never` must report ENOENT, not ENOTDIR. On a store where the
// two are mutually exclusive the order is immaterial, so ram/redis/disk are
// unaffected.
// Every component is walked, the listed path included, because the walk is the
// only thing that can see a gap above it. A backend whose store cannot hold
// such a gap should call listingError instead, which settles the common case in
// one probe.
// Mirrors Python's readdir_error.
export async function readdirError(path, key, isFile, isDir) {
const segments = key.split('/').filter((s) => s !== '');
for (let i = 1; i <= segments.length; i++) {
const component = `/${segments.slice(0, i).join('/')}`;
if (await isDir(component))
continue;
if (await isFile(component))
return enotdir(path);
return enoent(path);
}
return enoent(path);
}
// readdirError for a store that cannot hold an orphan. An object store's key
// implies every prefix of it, and a hierarchy the backend addresses by path
// implies every folder above it, so on those backends a path that exists proves
// its ancestors are directories and the answer for one that is not a directory
// is ENOTDIR outright. Probing it first is what keeps a readdir on a plain file
// to one round trip where each probe is an API request rather than a map lookup.
// That premise is exactly what a flat store breaks: ram and redis rename without
// creating the destination's ancestors, so they can hold `/missing/a.txt` with
// `/missing` absent, where resolution stops and the answer is ENOENT. Those call
// readdirError directly. The walk ends at the listed path itself, which the
// first probe has already found is not a file, so it is not asked again.
// Mirrors Python's listing_error.
export async function listingError(path, key, isFile, isDir) {
const leaf = stripSlash(key);
if (leaf === '')
return readdirError(path, key, isFile, isDir);
if (await isFile(key))
return enotdir(path);
return readdirError(path, key, (p) => stripSlash(p) !== leaf && isFile(p), isDir);
}
export function noMount(path) {
const err = new Error(`no mount matches path: ${path}`);
err.noMount = true;
return err;
}
// The registry's miss and nothing else: catch sites that cope with an
// unmounted path test this instead of swallowing every error, mirroring
// Python's `except NoMountError`.
export function isNoMount(err) {
if (err === null || typeof err !== 'object')
return false;
return err.noMount === true;
}
// What an existence probe reads as "nothing here": the path is absent, or
// a component of it is not traversable. Wider than isMissingPath below,
// which is the ENOENT-only swallow set, and still deliberately narrower
// than a walk's tolerance, because a permission or missing-capability
// error is not absence and mapping it to one would report a path that
// exists as missing. Mirrors python MISS_ERRORS, and lives here for the
// same reason that tuple does: the dispatcher and the executor's probes both
// read it and neither may import the other.
export function isMissError(exc) {
const code = exc.code;
if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR')
return true;
const msg = exc instanceof Error ? exc.message : String(exc);
return /not found|no such file|not a directory|is a directory/i.test(msg);
}
// True when the error means the path is simply not there: a stamped ENOENT,
// or a path outside every mount. This is the whole swallow set for the
// exists-family probes, mirroring Python's `(FileNotFoundError, ValueError)`.
// Everything else — auth failures, transport errors, timeouts, ENOTDIR,
// backend bugs — must propagate instead of reading back as "missing".
export function isMissingPath(err) {
if (err === null || typeof err !== 'object')
return false;
return err.code === 'ENOENT' || isNoMount(err);
}
// A mount was asked for an op its backend does not register (e.g. unlink on
// a mail mount). ENOTSUP is the honest POSIX spelling for a capability gap:
// the fs chokepoints render 'Operation not supported' against the
// operand, while the message keeps VFS + op for tracebacks. Mirrors
// Python's OperationNotSupportedError/enotsup.
export function enotsup(vfs, op, path) {
return Object.assign(fsError(path, 'ENOTSUP', `no op registered: ${op} for VFS ${vfs}`), {
op,
});
}
// The dispatcher's refusal of a one-call walk the caller makes itself.
// Mirrors Python's walk_declined.
export function walkDeclined(vfs, op, path) {
return Object.assign(enotsup(vfs, op, path), { declined: true });
}
// True when the error is the missing-op stamp for this specific op — the
// capability probe used by fallback paths (metadata setattr, FUSE
// create/truncate) to distinguish "backend lacks the op" from a real
// failure inside it.
export function isMissingOp(err, op) {
const stamped = err;
return stamped.code === 'ENOTSUP' && stamped.op === op;
}
/**
* What a diagnostic says about a failure, whatever threw it: the strerror
* text for a condition the vocabulary names (EIO reads 'Input/output
* error', a path no mount owns 'No such file or directory'), and a store's
* own error (an SDK's, an HTTP status on upload) by its message, the only
* cause it carries. Never the message of a stamped error, which may name a
* real host path. Mirrors Python's `strerror_of`.
*/
export function strerrorOf(err) {
const condition = classify(err);
if (condition !== null)
return posixPhrase(condition);
if (err instanceof Error)
return err.message || err.name;
return String(err);
}
/**
* Whether a failure ends the invocation rather than one write. A catch that
* reports a failed write and keeps the line going takes whatever the backend
* threw (an SDK's own error carries no code) except this: an abort or a
* timeout is the caller's outcome. Mirrors Python's `is_aborting`.
*/
export function isAborting(err) {
return err instanceof CommandTimeoutError || (err instanceof Error && err.name === 'AbortError');
}
// The phrase a command line ends with for a failed operand, read from the
// error's stamped code (Python's fs_strerror). Null for anything outside
// OPERAND_CONDITIONS, so the chokepoint leaves the raw message untouched.
export function fsStrerror(err) {
if (err === null || typeof err !== 'object')
return null;
const code = err.code;
if (typeof code !== 'string')
return null;
const condition = ERRNO_ARMS[code];
if (condition === undefined || !OPERAND_CONDITIONS.has(condition))
return null;
return posixPhrase(condition);
}
// The user-facing path for an error: the stamped virtualPath when present,
// else the raw message. Never a real fs path (backends never stamp those).
export function errorVirtualPath(err) {
const v = err.virtualPath;
if (typeof v === 'string')
return v;
return err instanceof Error ? err.message : String(err);
}
// True when the error carries a recognized filesystem code, i.e. it is the
// per-operand kind a read-family command skips, going on with the remaining
// operands. Anything else keeps propagating. Wider than Python's FS_ERRORS,
// which leaves out ENOTEMPTY, EXDEV and EPIPE.
export function isFsError(err) {
return fsStrerror(err) !== null;
}
function hasCode(err, code) {
return err instanceof Error && err.code === code;
}
// `enoent()` puts the *path* in the message, so matching on message text never
// fires; the stamped code is the only reliable signal. Python's twin is
// `except FileNotFoundError`. Three modules had grown their own copy of this.
export function isEnoent(err) {
return hasCode(err, 'ENOENT');
}
// Python's twin is `except NotADirectoryError`: a component of the path is
// a plain file, the other way a lookup fails besides ENOENT.
export function isEnotdir(err) {
return hasCode(err, 'ENOTDIR');
}
// Python's twin is `except FileTooLargeError`.
export function isEfbig(err) {
return hasCode(err, 'EFBIG');
}
// Python's twin is `except IsADirectoryError`. A command sometimes spells a
// directory read as something other than the EISDIR strerror (checksum
// --check says the literal "read error"), so callers need the code, not
// just the walk-error class.
export function isEisdir(err) {
return hasCode(err, 'EISDIR');
}
// Python's twin is `except FileExistsError`: the name is taken, which is
// the dispatcher's answer to a create that will not overwrite (symlink(2)).
export function isEexist(err) {
return hasCode(err, 'EEXIST');
}
// Python's twin is `except PermissionError`: a refusal (a rule at the
// command guard or the dispatcher, a read-only mount), which a walk reports
// per entry the way it reports an unreadable one.
export function isEacces(err) {
return hasCode(err, 'EACCES');
}
// The mode gate's refusal (Python's `except ReadOnlyError`).
export function isErofs(err) {
return hasCode(err, 'EROFS');
}
// A refusal, not an absence: a rule's or a policy's deny (EACCES), an
// operation the store does not permit (EPERM) or a read-only mount (EROFS),
// which a command reports for the one operand and goes on past. Python's
// twin is `except PermissionError`.
export function isRefusal(err) {
return hasCode(err, 'EACCES') || hasCode(err, 'EPERM') || hasCode(err, 'EROFS');
}
// The per-entry swallow set for walk-and-warn commands (ls, tree, rg):
// every stamped filesystem code plus the unstamped no-mount refusal.
// Mirrors Python's `except (OSError, ValueError)`, where ValueError is
// the no-mount spelling. Anything else — auth failures, transport
// errors, backend bugs — must propagate instead of vanishing from a
// listing or being laundered into a 'cannot access' line.
export function isWalkError(err) {
return isFsError(err) || isNoMount(err);
}
//# sourceMappingURL=fs.js.map