@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
209 lines • 8.96 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 { normDir, rstripSlash } from "../utils/slash.js";
import { FileStat, FileType } from "../types.js";
import { hiddenUnder, pathVisible, visibleLinks } from "../utils/hidden.js";
import { getAdmission, sessionVisibility } from "../context/session_context.js";
import { eloop } from "../errors/fs.js";
import { CycleError } from "../utils/path.js";
import { compareCodePoints } from "../utils/sort.js";
/**
* The child segments of `parent` that some allowed path runs through.
*
* The one session filter both namespace enumerations share, because both
* owe a segment to a deeper thing rather than to an entry of its own: a
* mount prefix and a link path each synthesize every directory above it,
* and the segment is visible exactly when at least one of the things that
* synthesized it is.
*
* The full path is what gets tested, never the segment. A hide is
* subtree-closed on all three of its planes (an exact entry contains its
* subtree, a component pattern matches any segment, an anchored one is
* tested against every ancestor), so testing the deeper path is strictly
* stronger: a segment can only be withheld by this, never granted.
* Testing the segment instead is what leaked the name of a namespace-only
* ancestor whose only mount or link the session hides, a directory that
* answers ENOENT to every verb applied to it.
*/
export function visibleChildSegments(vis, paths, parent) {
const norm = normDir(parent);
const out = new Set();
for (const path of paths) {
if (!path.startsWith(norm))
continue;
const name = path.slice(norm.length).split('/', 1)[0] ?? '';
if (name === '' || out.has(name) || !pathVisible(vis, path))
continue;
out.add(name);
}
return [...out].sort(compareCodePoints);
}
/**
* Immediate child segments of mounts strictly under `parent`.
*
* Session-filtered by `visibleChildSegments`: a child name appears only
* when some mount whose prefix runs through it is visible to the current
* session, so a scoped session never learns an ungranted mount's name
* from a listing. Hidden names (leading dot) are included; presentation
* filtering is the consumer's job, exactly as for backend entries.
*/
export function childMountNames(vis, prefixes, parent) {
const norm = normDir(parent);
const below = [];
for (const prefix of prefixes) {
const p = normDir(prefix);
if (p === norm || !p.startsWith(norm))
continue;
below.push(rstripSlash(p));
}
return visibleChildSegments(vis, below, parent);
}
/**
* Immediate child segments owed to links at or below `parent`.
*
* Derived from every link path, not just direct children, exactly as
* mount prefixes are: `ln` allows a link below a directory chain no
* backend serves, and without its ancestors synthesized the link lists
* at its own parent yet is unreachable from a walk above it.
* Session-filtered by `visibleChildSegments`, the same predicate
* `childMountNames` applies to a mount prefix: the link path is tested,
* not the segment, so a namespace-only ancestor whose only link the
* session hides is not named either. A link that leads where the session
* cannot see is hidden with its target.
*/
function linkNames(vis, links, parent) {
if (links === null)
return [];
const targets = links.symlinkTargets();
const below = normDir(parent);
const candidates = [...targets.keys()].filter((path) => path.startsWith(below));
const visible = visibleLinks(vis, candidates, targets);
const shown = candidates.filter((_, index) => visible[index]);
return visibleChildSegments(vis, shown, parent);
}
/**
* Every child segment the namespace owes `parent`: mounts + links.
*
* The one union both consumers derive from: the dispatcher merges these
* names into its readdir and the `childMounts` fact offers them to
* listing commands, so the shell and the ops surface cannot disagree
* about what a directory holds.
*/
export function namespaceNames(vis, prefixes, links, parent) {
return [
...new Set([...childMountNames(vis, prefixes, parent), ...linkNames(vis, links, parent)]),
].sort(compareCodePoints);
}
/**
* Merge namespace structure into a backend readdir listing.
*
* Child mounts and symlinks are namespace state no backend can see, so
* a listing that stops at one backend misses both. Merged names are
* appended as virtual paths (the shape RAM-style backends already
* emit); deduplication is by final path segment because backends
* disagree on entry shape (bare names, trailing-slash names, full
* paths).
*/
export function mergeReaddir(vis, entries, prefixes, links, parent) {
const present = new Set(entries.map((e) => stripEntry(e)));
const base = rstripSlash(parent);
const merged = [...entries];
for (const name of namespaceNames(vis, prefixes, links, parent)) {
if (present.has(name))
continue;
present.add(name);
merged.push(`${base}/${name}`);
}
return merged;
}
function stripEntry(entry) {
const trimmed = rstripSlash(entry);
const slash = trimmed.lastIndexOf('/');
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
}
/**
* A listing for a directory that exists only as namespace structure.
*
* `/data/x` exists when a mount sits at `/data/x/y` or a link lives
* directly under it, even though the `/data` backend holds nothing at
* `/x`. Null when the namespace knows nothing there either, so a caller
* re-throws the backend's miss.
*/
export function namespaceListing(vis, prefixes, links, parent) {
if (namespaceNames(vis, prefixes, links, parent).length === 0) {
return null;
}
return mergeReaddir(vis, [], prefixes, links, parent);
}
/**
* A directory stat for a path that exists only as namespace structure.
*
* The listing and the stat must agree: a directory `readdir` can serve
* (because a mount or a link sits below it) must stat as a directory,
* or `os.walk` and `Path.is_dir` break on it.
*/
export function namespaceStat(vis, prefixes, links, path) {
if (namespaceListing(vis, prefixes, links, path) === null)
return null;
const name = stripEntry(path);
return new FileStat({ name: name === '' ? '/' : name, type: FileType.DIRECTORY });
}
/**
* Whether a hide or a path rule judges anything a command's operands
* reach, so a native walk that classifies the raw tree gives way to the
* checked one.
*
* Per operand, not per session: a hidden `.env` under `/repo` must not
* force `find` on `/s3` off its native op. The hides are the running
* session's and the rules the running command's gate; `prefix` is the
* mount root, judged in place of a glob operand. Mirrors Python's
* paths_scoped.
*/
export function pathsScoped(paths, prefix = '') {
const vis = sessionVisibility();
const gate = getAdmission();
return paths.some((path) => {
const virtual = path.pattern !== null ? prefix || '/' : path.virtual;
return hiddenUnder(vis, virtual) || (gate?.scopes(virtual) ?? false);
});
}
/**
* stat(2) over a table whose stat is lstat(2): a link answers for what it
* leads to.
*
* What a walker that follows links (`find -L`, `du -L`, `grep -R`) asks of
* each entry: a link to a directory is a directory to walk and a link to a
* file is that file, under the link's own name. A dangling link stays the
* link, which is what GNU reports when the target cannot be statted, and a
* chain that loops throws ELOOP. `path` is the entry as `stat` takes it;
* without `links` the walk follows none and `stat` answers alone. Mirrors
* Python's followed_stat.
*/
export async function followedStat(stat, links, path) {
const row = await stat(path);
if (links == null || row.type !== FileType.SYMLINK)
return row;
const virtual = typeof path === 'string' ? path : path.virtual;
try {
links.resolve(virtual);
}
catch (err) {
if (err instanceof CycleError)
throw eloop(virtual);
throw err;
}
const target = await links.targetStat(virtual);
return target === null ? row : target.with({ name: row.name });
}
//# sourceMappingURL=namespace_view.js.map