@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
498 lines • 20.7 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 { FileType, LINK_TARGET_KEY, PathSpec } from "../../../../types.js";
import { eexist, isEisdir, isEnotdir, isMissingPath } from "../../../../errors/fs.js";
import { compareCodePoints } from "../../../../utils/sort.js";
import { PERMISSION_BITS, SYMLINK_MODE } from "./constants.js";
import { MountInWayError } from "./errors.js";
import { entryName } from "../../../../utils/remnants.js";
import { rstripSlash } from "../../../../utils/slash.js";
/** Read one virtual path through the session's files. */
export async function readFile(vfs, path) {
return vfs.read(path);
}
/** What stands at a path, a link followed, null when nothing does. */
export async function statOf(vfs, path) {
try {
return await vfs.stat(path);
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err) || isEisdir(err))
return null;
throw err;
}
}
/**
* What sits at a path, a link reported as itself, null when nothing does.
*
* git lstats the working tree, so a symlink is an entry in its own right: it
* records mode 120000 and the target string, and reports a link to nothing as
* untracked rather than as absent.
*/
export async function lstat(vfs, path) {
try {
return await vfs.stat(path, undefined, { nofollow: true });
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err) || isEisdir(err))
return null;
throw err;
}
}
/** Whether a path names a symlink itself, the lstat a walk asks before it
* follows anything. */
export async function isLink(vfs, path) {
return (await lstat(vfs, path))?.type === FileType.SYMLINK;
}
/**
* The bytes git stores for one working-tree entry.
*
* A symlink's blob is its target string, not what the target holds, so reading
* through the link would stage a second copy of the target under mode 100644
* and then report the entry modified forever after (the staged blob and the
* bytes behind the link never match). The target is namespace state, which is
* why it arrives on the stat rather than from a read.
*/
export async function entryBytes(vfs, path, info) {
if (info.type === FileType.SYMLINK) {
const target = info.extra[LINK_TARGET_KEY];
if (typeof target === 'string')
return new TextEncoder().encode(target);
}
return readFile(vfs, path);
}
/**
* Materialize one tree entry into the working tree.
*
* A 120000 entry is a symlink whose blob is the target string, so it is
* restored through the namespace rather than written as content: writing the
* blob would leave a regular file spelling the target.
*
* Whatever is already there goes first, whichever kind it is, because git
* replaces a tree entry rather than merging with it. Two of the four
* combinations are the ones that corrupt state: writing a regular blob at a
* path the namespace holds a link for follows the link and lands the content in
* the file it points at, damaging a path no branch touched while the link
* stays; and linking over a regular file leaves that file behind the link,
* ready to reappear when the link goes. The fourth, a link over a link, is the
* retarget a checkout does when a branch moves where a link points: symlink(2)
* does not overwrite, so the old name is removed rather than replaced in place.
* The check is one lstat, which the listing the checkout walked usually
* answers.
*
* The permission bits are part of the entry, not decoration on it. git records
* exactly one of them, the owner's execute bit, and puts it back in both
* directions: `chmod -x` on a `100755` path is a modification `restore` undoes,
* and `chmod +x` on a `100644` one is a modification it undoes the other way.
* Writing the bytes alone left the bit as the working tree had it, so the file
* came back unrunnable and `status` went on calling it modified for ever. The
* write is unconditional rather than probed: a stat to decide costs the same op
* as the setattr it would save, and the backends git actually runs on apply it
* natively, so nothing reaches the overlay.
*/
export async function restoreEntry(vfs, path, mode, blob) {
const linked = await isLink(vfs, path);
if (mode === SYMLINK_MODE) {
await removeFile(vfs, path);
// symlink(2) needs the directory above the entry, as the write below
// does, so a link alone in a new directory gets one too.
await ensureDir(vfs, path.parent);
await vfs.symlink(path, new TextDecoder().decode(blob));
return;
}
if (linked)
await removeFile(vfs, path);
await writeFile(vfs, path, blob);
await vfs.setattr(path, { mode: Number.parseInt(mode, 8) & PERMISSION_BITS });
}
/**
* Leave a submodule's working tree alone, but make sure it has one.
*
* A 160000 entry names a commit in another repository, which this one does not
* hold: reading it as a blob is either a miss (an ordinary submodule keeps its
* objects in its own store) or, when the id does happen to resolve here, an
* empty string written over the directory. git does neither. It checks out no
* submodule content at all without `--recurse-submodules`, and all the entry
* asks of the working tree is that a directory stand at the name: an existing
* one is left exactly as it is, untracked work included, a regular file or a
* link is replaced by an empty one, and a missing one is created. Pinned
* against git 2.50.1.
*
* @param vfs the session's files
* @param path absolute virtual path of the submodule
*/
export async function keepGitlink(vfs, path) {
const linked = await isLink(vfs, path);
const info = linked ? null : await statOf(vfs, path);
if (info !== null && info.type === FileType.DIRECTORY)
return;
if (linked || info !== null)
await removeFile(vfs, path);
await ensureDir(vfs, path);
}
/**
* Take a submodule's directory away, or say why it stays.
*
* The other direction of `keepGitlink`, and it is not an unlink: what stands at
* a 160000 entry is a directory, so git calls `rmdir` and warns rather than
* failing when that cannot be done. An empty one goes; one still holding a
* checked-out submodule, or anything else untracked, stays and is named; a
* regular file or a link at the name is the `ENOTDIR` wording of the same
* warning; a name with nothing at it is silent. The switch itself succeeds
* either way, which is the whole point of warning instead of throwing. Pinned
* against git 2.50.1.
*
* @param vfs the session's files
* @param path absolute virtual path of the submodule
* @param name the path as git prints it, repository-relative
* @returns the warning line to write, null when there is nothing to say
*/
export async function dropGitlink(vfs, path, name) {
// A link is answered before the stat, which dereferences: rmdir never follows
// one, so a link to a directory is ENOTDIR here even though stat would call
// it a directory.
if (await isLink(vfs, path)) {
return `warning: unable to rmdir '${name}': Not a directory\n`;
}
const info = await statOf(vfs, path);
if (info === null)
return null;
if (info.type !== FileType.DIRECTORY) {
return `warning: unable to rmdir '${name}': Not a directory\n`;
}
if ((await readNames(vfs, path)).length > 0) {
return `warning: unable to rmdir '${name}': Directory not empty\n`;
}
await vfs.rmdir(path);
return null;
}
/** Read a byte range of one virtual path. */
export async function readRange(vfs, path, offset, size) {
return vfs.read(path, { offset, size });
}
/**
* Read a path that a repository may legitimately not have.
*
* `packed-refs` and `HEAD`-adjacent files are absent in perfectly valid
* repositories, so a miss is an answer rather than an error.
*/
export async function readOptional(vfs, path) {
try {
return await readFile(vfs, path);
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err) || isEisdir(err))
return null;
throw err;
}
}
/** List a directory, empty when it does not exist. */
export async function readNames(vfs, path) {
try {
return await vfs.readdir(path);
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err) || isEisdir(err))
return [];
throw err;
}
}
/**
* Create a directory and every missing directory above it.
*
* Written out rather than delegated to `mkdir -p` because the parents flag is a
* per-backend capability: the ops factory only wires `parents: true` for
* backends that declare it, so a plain `mkdir` of `objects/ab` fails on the
* rest.
*
* Existence is probed with a point stat, which on a prefix store misses a
* directory that has no object of its own. That false negative is harmless here
* and the reason this does not need the two-channel stat: on such a store a
* directory is the set of keys under it, so creating one again costs a no-op
* rather than an error.
*/
export async function ensureDir(vfs, path) {
const missing = [];
let current = path;
while (current.virtual !== '/') {
try {
await vfs.stat(current);
break;
}
catch (err) {
if (!(isMissingPath(err) || isEnotdir(err) || isEisdir(err)))
throw err;
missing.push(current);
current = current.parent;
}
}
for (const target of missing.reverse()) {
await vfs.mkdir(target);
}
}
/**
* Whether a path names a directory, false when nothing is there.
*
* Asked with a stat rather than inferred from a failed read: a read of a
* directory misses on a keyed store and raises EISDIR on disk, so "the read did
* not work" says nothing about what the path is.
*/
export async function isDirectory(vfs, path) {
try {
return (await vfs.stat(path)).type === FileType.DIRECTORY;
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err) || isEisdir(err))
return false;
throw err;
}
}
/** Whether a point lookup finds anything at a path. */
export async function exists(vfs, path) {
try {
await vfs.stat(path);
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err) || isEisdir(err))
return false;
throw err;
}
return true;
}
/**
* Take a file's lock and let it go, as git does before rewriting it.
*
* git creates `<path>.lock` before it rewrites the index or the config, even
* when nothing in them changes, so a read-only repository refuses the verb there
* before it looks any further. Creating the lock and removing it is that
* refusal, and it leaves the file itself alone, so a write another session makes
* to it is never undone. A lock already there is another writer's, which git
* refuses and so does this (EEXIST), untouched. The look and the create are two
* ops, since the dispatcher has no exclusive create.
*/
export async function takeLock(vfs, path) {
const lock = PathSpec.fromStrPath(`${path.dotted ?? path.virtual}.lock`, undefined, '/');
if (await exists(vfs, lock))
throw eexist(lock.virtual);
await vfs.write(lock, new Uint8Array());
await removeFile(vfs, lock);
}
/** Write one virtual path, creating the directories above it. */
export async function writeFile(vfs, path, data) {
await ensureDir(vfs, path.parent);
await vfs.write(path, data);
}
/**
* Delete one virtual path, tolerating one that is already gone.
*
* A miss is an answer rather than an error for every caller here: unstaging a
* path deletes whatever ref or lock may or may not exist, and a checkout removes
* files the other branch does not have.
*/
export async function removeFile(vfs, path) {
try {
await vfs.unlink(path);
}
catch (err) {
if (!(isMissingPath(err) || isEnotdir(err) || isEisdir(err)))
throw err;
}
}
/**
* The nearest component above an entry that is not a directory.
*
* An entry's path is only a way through the working tree while every component
* above it is a directory. Anything else standing on one -- a symlink, a
* regular file, tracked or not -- is not a way through, and the two directions
* take it differently. Writing the entry *replaces* it with the directory the
* entry needs, leaving whatever a link pointed at exactly as it was; removing
* the entry does nothing at all, because the path never led there. Both halves
* are what git does, probed against git 2.50.1.
*
* The lstat is asked before the stat, and the order is the whole point:
* `stat` dereferences, so a link to a directory stats as a directory and the
* walk would carry on straight through it.
*
* An exact-path lookup cannot see any of this, since what is in the way sits
* above the name being looked up rather than on it.
*
* @param vfs the session's files
* @param worktree absolute virtual path of the working tree root
* @param name the entry, repository-relative
* @returns the absolute virtual path of the nearest such component, null when
* every component above the entry is a directory
*/
export async function blockingAncestor(vfs, worktree, name) {
let current = worktree;
for (const part of name.split('/').slice(0, -1)) {
current = current.join(part);
if (await isLink(vfs, current))
return current;
const info = await statOf(vfs, current);
if (info !== null && info.type !== FileType.DIRECTORY)
return current;
}
return null;
}
/**
* Move one virtual path, file or directory, to another name.
*
* The mount's own rename, so a directory moves with everything under it,
* tracked or not, which is what `git mv` does with a directory. The
* destination's directory is not created: git's rename fails when it is
* missing, and the caller words that failure.
*/
export async function renamePath(vfs, source, target) {
await vfs.rename(source, target);
}
/**
* Ask of every destination first what the write loop would meet later.
*
* `removeTree` refuses a directory holding a mount, but the loop reaches one
* entry at a time, so a refusal there leaves the entries already written
* standing on the target's content with HEAD and the index still where they
* were. Asking first is the shape every other collision check in these verbs
* already has: name what is in the way and change nothing. The condition
* mirrors the write loop's exactly, a link included, so a destination the loop
* would not clear is not refused here either.
*
* @param vfs the session's files
* @param worktree absolute virtual path of the working tree root
* @param names repository-relative paths about to be written
*/
export async function refuseReplacedMounts(vfs, worktree, names) {
for (const name of [...names].sort(compareCodePoints)) {
const where = worktree.join(name);
if (await isLink(vfs, where))
continue;
const info = await statOf(vfs, where);
if (info !== null && info.type === FileType.DIRECTORY)
refuseMount(vfs, where);
}
}
/**
* Refuse a removal that would take a nested mount with it.
*
* A mount nested inside the working tree is served by another VFS, and
* `readdir` merges it into the parent's listing, so a walk that empties a
* directory walks straight into the child backend and unlinks what is in it.
* No branch ever recorded any of that, and the `rmdir` that follows takes the
* mount root itself. Asking the mount table is the only way to see the
* boundary: the parent backend cannot.
*
* A mount this session cannot see still blocks the removal: `submounts`
* reports it by the nearest folder above it the session sees, which is not a
* mount root, so only a visible one is named, naming a hidden mount being what
* the hide exists to prevent.
*
* @param vfs the session's files
* @param path absolute virtual path about to be removed
*/
export function refuseMount(vfs, path) {
if (vfs.isMount(path))
throw new MountInWayError(path.virtual, path.virtual);
const below = vfs.submounts(path);
if (below.length === 0)
return;
throw new MountInWayError(path.virtual, below.find((root) => vfs.isMount(root)) ?? null);
}
/**
* Delete a path and everything under it, tracked or not.
*
* git replaces a tree entry rather than merging with it, so a directory
* standing where the source keeps a file goes entirely. That is one of the few
* places git removes a file it never tracked: an untracked child keeps the
* directory alive after the tracked ones are gone, and restoring the file over
* it would otherwise fail with the index already changed.
*
* A file and an absent path both walk out through the same two steps, since
* `readdir` reads a non-directory as nothing there and `rmdir` refuses it.
*
* A child that is a symlink is unlinked, never descended into. Its lstat has
* to say so, because `readdir` dereferences: a link to a directory lists
* that directory's contents, and recursing on them deletes a tree outside the
* one being replaced. `rm -r` does not follow a link either, so a branch
* recording a file where the working tree has a directory takes the link away
* with the directory and leaves whatever it pointed at exactly as it was.
* Pinned against git 2.50.1.
*
* @param vfs the session's files
* @param path absolute virtual path to clear
*/
export async function removeTree(vfs, path) {
// Before the first deletion rather than at the boundary itself, so a mount
// deep under the directory costs the caller nothing: the scan sees every
// depth at once and the tree is still whole when it refuses.
refuseMount(vfs, path);
for (const entry of await readNames(vfs, path)) {
// A listing answers in whole paths, so the child is rebuilt from the
// basename the way every other walk here does.
const name = entryName(entry);
if (name === '')
continue;
const child = path.join(name);
if (await isLink(vfs, child)) {
await removeFile(vfs, child);
continue;
}
await removeTree(vfs, child);
}
try {
await vfs.rmdir(path);
}
catch (err) {
// A directory the walk above was supposed to empty is a real failure and
// stays thrown. Everything else means this was no directory (or is
// already gone), so the path is whatever one file it is and unlink
// tolerates an absent one.
if (err.code === 'ENOTEMPTY')
throw err;
await removeFile(vfs, path);
}
}
/**
* Drop the directories a deletion left empty, up to a root.
*
* git removes a directory the moment its last tracked file is deleted or
* restored away, so `rm -r docs` leaves no `docs/` behind. The walk stops at the
* first directory that still holds something and never touches `stop` itself.
*
* @param vfs the session's files
* @param path the file that was removed
* @param stop the working tree root
*/
export async function removeEmptyParents(vfs, path, stop) {
const root = rstripSlash(stop.virtual) || '/';
let current = path.parent;
while (current.virtual !== root && current.virtual.startsWith(root)) {
// A mount root is not a directory git made, and an empty one is still a
// whole backend: removing it here would destroy the store behind it as a
// side effect of tidying up. The walk stops rather than refusing, because
// nothing the caller asked for has failed.
if (vfs.isMount(current))
return;
if ((await readNames(vfs, current)).length > 0)
return;
try {
await vfs.rmdir(current);
}
catch (err) {
if (!(isMissingPath(err) || isEnotdir(err) || isEisdir(err)))
throw err;
return;
}
current = current.parent;
}
}
//# sourceMappingURL=io.js.map