@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
257 lines • 11.2 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 { mountKey } from "../../../../utils/key_prefix.js";
import { isRefusal } from "../../../../errors/fs.js";
import { classify } from "../../../../errors/classify.js";
import { CycleError } from "../../../../utils/path.js";
import { rstripSlash, stripSlash } from "../../../../utils/slash.js";
import { compareCodePoints } from "../../../../utils/sort.js";
import { posixPhrase } from "../../../../errors/posix.js";
// A mount boundary is a filesystem boundary, so both archivers stop at
// one and say so in GNU tar's --one-file-system wording. Descending
// would archive by accident exactly what the mount-root refusal forbids
// on purpose.
export const OTHER_FILESYSTEM = 'file is on a different filesystem; not dumped';
function linkTarget(stat) {
const target = stat.extra[LINK_TARGET_KEY];
return typeof target === 'string' ? target : '';
}
// A PathSpec for a walked descendant of one operand. The walk reports
// absolute virtual paths; reading their bytes needs the backend key too,
// which is the virtual path with this mount's prefix removed.
function childSpec(virtual, root) {
const cut = rstripSlash(root.virtual).length - stripSlash(root.vfsPath).length;
const prefix = rstripSlash(root.virtual.slice(0, cut));
const slash = virtual.lastIndexOf('/');
return new PathSpec({
virtual,
directory: slash >= 0 ? virtual.slice(0, slash + 1) : '/',
vfsPath: mountKey(virtual, prefix),
rawPath: virtual,
});
}
// Whether two virtual paths are served by the same mount. Outside a
// workspace (no MountView) there is only one mount.
function sameMount(mounts, one, other) {
if (mounts === null)
return true;
return mounts.rootOf(one) === mounts.rootOf(other);
}
// Every entry under one directory, named under `nameBase`.
//
// Three sources have to be merged because no single one can see them
// all: the backend walk (files and directories), the namespace (its
// symlinks, which a walk for either kind passes over), and the mount table
// (a nested mount, whose keys live in another VFS entirely).
//
// `base` and `nameBase` differ only when a link is being followed: the
// walk runs over the target while the members keep the link's own name.
async function subtree(root, base, nameBase, deps) {
const walked = base !== root.virtual ? childSpec(base, root) : root;
const found = new Map();
const dirs = await deps.walk(walked, 'd');
for (const virtual of dirs.paths) {
if (rstripSlash(virtual) !== base)
found.set(rstripSlash(virtual), ['dir', '']);
}
const files = await deps.walk(walked, 'f');
for (const virtual of files.paths) {
found.set(rstripSlash(virtual), ['file', '']);
}
// Named under nameBase like the entries, once each (both listings
// meet the same refusal).
const unreadable = [...new Set([...(dirs.unreadable ?? []), ...(files.unreadable ?? [])])].map((u) => rstripSlash(nameBase) + rstripSlash(u).slice(rstripSlash(base).length));
const links = deps.links ?? null;
if (links !== null) {
for (const [virtual, stat] of links.subtree(base)) {
found.set(rstripSlash(virtual), ['link', linkTarget(stat)]);
}
}
const mounts = deps.mounts ?? null;
// Two lists, because pruning and naming are different questions. A
// mount the session cannot see still shadows the parent backend's
// keys, so every descendant prunes; only the ones it may be told
// about become a member and a "different filesystem" warning, since
// both hand back the name a hide exists to withhold.
const crossings = mounts !== null ? mounts.descendants(base) : [];
const visibleCrossings = mounts !== null ? mounts.visibleDescendants(base) : [];
for (const crossing of visibleCrossings) {
// The mountpoint itself is still an entry, exactly as GNU's
// --one-file-system keeps the directory and drops its contents.
found.set(rstripSlash(crossing), ['dir', '']);
}
const below = crossings.map((c) => `${rstripSlash(c)}/`);
const entries = [];
for (const [virtual, [kind, target]] of found) {
if (below.some((c) => virtual.startsWith(c)))
continue;
const named = rstripSlash(nameBase) + virtual.slice(rstripSlash(base).length);
entries.push({
namePath: named,
kind,
target,
read: kind === 'file' ? childSpec(virtual, root) : null,
});
}
entries.sort((a, b) => compareCodePoints(a.namePath, b.namePath));
return [entries, visibleCrossings.map((c) => rstripSlash(c)), unreadable];
}
// What dereferencing puts in the archive in place of one symlink.
//
// The member keeps the link's own name and takes the target's content,
// which is what dereferencing means. Two links resolving to the same
// file are not a loop and both are archived; a real loop is whatever
// `resolve` refuses to resolve, since the namespace already walks the
// chain under a hop limit and raises ELOOP at the end of it. The third
// return value is why the link was unreachable, empty when it was not;
// the fourth names the directories below the target the walk could not
// open.
async function follow(virtual, root, deps) {
const links = deps.links ?? null;
if (links === null)
return [[], [], '', []];
let target;
try {
target = links.resolve(virtual);
}
catch (e) {
if (!(e instanceof CycleError))
throw e;
return [[], [], posixPhrase('ELOOP'), []];
}
if (!sameMount(deps.mounts ?? null, virtual, target))
return [[], [OTHER_FILESYSTEM], '', []];
const spec = childSpec(target, root);
let targetStat;
try {
targetStat = await deps.stat(spec);
}
catch (err) {
return [[], [], statProblem(err), []];
}
if (targetStat.type !== FileType.DIRECTORY) {
return [[{ namePath: virtual, kind: 'file', read: spec, size: targetStat.size }], [], '', []];
}
if (!deps.recurse)
return [[{ namePath: virtual, kind: 'dir' }], [], '', []];
const [entries, crossings, unreadable] = await subtree(root, target, virtual, deps);
return [
[{ namePath: virtual, kind: 'dir' }, ...entries],
crossings.map(() => OTHER_FILESYSTEM),
'',
unreadable,
];
}
// The problems for directories a walk could not open. A rule refused each
// one. tar prints the reason after "Cannot open: " and fails the run;
// Info-ZIP stores the directory and is silent.
// The phrase a member whose stat failed is reported with: a plain file in
// its chain or a refusal keeps its errno, anything else reads as missing.
// Mirrors Python's posix_phrase(classify(exc) or ENOENT).
function statProblem(err) {
const why = classify(err);
return posixPhrase(why !== null && (why === 'ENOTDIR' || isRefusal(err)) ? why : 'ENOENT');
}
function unreadableProblems(closed) {
return closed.map((path) => ({ path, reason: posixPhrase('EACCES'), unreadable: true }));
}
/**
* Everything one operand contributes to an archive.
*
* This is the whole of what tar and zip share: which paths go in, what each
* one is, and which of them could not be reached. The two formats disagree
* about the defaults, not the traversal, so both are parameters: tar stores a
* symlink unless `-h` says to follow it and always descends, zip follows
* unless `-y` says otherwise and only descends under `-r`.
*/
export async function scanOperand(path, deps) {
const base = rstripSlash(path.virtual) || '/';
let entries = [];
let crossings = [];
const problems = [];
const links = deps.links ?? null;
const linkStat = links !== null ? links.statAt(path.virtual) : null;
if (linkStat !== null && !deps.dereference) {
entries.push({ namePath: base, kind: 'link', target: linkTarget(linkStat) });
}
else if (linkStat !== null) {
const [followed, why, unreachable, closed] = await follow(base, path, deps);
if (unreachable !== '') {
return {
entries: [],
crossings: [],
problems: [{ path: base, reason: unreachable, fatal: true }],
missing: true,
};
}
for (const item of followed)
entries.push(item);
for (const item of why.map((reason) => ({ path: base, reason })))
problems.push(item);
for (const item of unreadableProblems(closed))
problems.push(item);
}
else {
let rootStat;
try {
rootStat = await deps.stat(path);
}
catch (err) {
return {
entries: [],
crossings: [],
problems: [{ path: base, reason: statProblem(err), fatal: true }],
missing: true,
};
}
if (rootStat.type !== FileType.DIRECTORY) {
entries.push({ namePath: base, kind: 'file', read: path, size: rootStat.size });
}
else {
entries.push({ namePath: base, kind: 'dir' });
if (deps.recurse) {
const [below, found, closed] = await subtree(path, base, base, deps);
for (const item of below)
entries.push(item);
crossings = found;
for (const item of unreadableProblems(closed))
problems.push(item);
}
}
}
if (deps.dereference && links !== null) {
const expanded = [];
for (const entry of entries) {
if (entry.kind !== 'link') {
expanded.push(entry);
continue;
}
const [followed, why, unreachable, closed] = await follow(entry.namePath, path, deps);
if (unreachable !== '') {
problems.push({ path: entry.namePath, reason: unreachable, fatal: true });
continue;
}
for (const item of followed)
expanded.push(item);
for (const item of why.map((reason) => ({ path: entry.namePath, reason })))
problems.push(item);
for (const item of unreadableProblems(closed))
problems.push(item);
}
entries = expanded;
}
return { entries, crossings, problems, missing: false };
}
//# sourceMappingURL=walk.js.map