UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

395 lines • 18.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 { 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