@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
527 lines • 23.4 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 { specOf } from "../../spec/builtins.js";
import { FlagView } from "../../spec/flag_view.js";
import { Follow, LineFacts, PathSpec } from "../../../types.js";
import { IOResult } from "../../../io/types.js";
import { pathsScoped } from "../../../view/namespace_view.js";
import { ZERO_LENGTH_NAME } from "../../../errors/render.js";
import { fsStrerror, isDotWalkError, isMissingPath } from "../../../errors/fs.js";
import { mountPrefixOf } from "../../../utils/key_prefix.js";
import { respellRaw } from "../../../utils/path.js";
import { lstripSlash, rstripSlash, stripSlash } from "../../../utils/slash.js";
import { formatRecords } from "../utils/output.js";
import { scaledSize } from "../utils/formatting.js";
import { compareCodePoints } from "../../../utils/sort.js";
import { posixPhrase } from "../../../errors/posix.js";
import { mountsOf } from "../utils/links.js";
import { vfsStatPath } from "../utils/paths.js";
import { StreamRefusal } from "../../../rs/errors.js";
import { refused, scan } from "../../../rs/scan.js";
function line(size, human, label) {
return `${scaledSize(size, null, human)}\t${label}`;
}
/**
* du's options, `--max-depth` and its conflicts with `-s` and `-a` read by
* mirage-rs before any operand is resolved: the flags, or du's refusal.
* Mirrors Python's `parse_flags`.
*/
export function parseFlags(ctx) {
const fl = new FlagView(ctx.flags, specOf('du'));
let values;
try {
values = scan('du', fl.texts('max_depth', 's', 'a'));
}
catch (err) {
if (!(err instanceof StreamRefusal))
throw err;
return refused(err);
}
const { max_depth: depth, warning } = values;
if ((depth !== null && typeof depth !== 'number') ||
(warning !== null && typeof warning !== 'string')) {
throw new Error(`du: mirage-rs read --max-depth as ${JSON.stringify(values)}`);
}
return {
s: fl.asBool('s'),
a: fl.asBool('a'),
h: fl.asBool('h'),
c: fl.asBool('c'),
S: fl.asBool('separate_dirs'),
maxDepth: depth,
...(warning === null ? {} : { warning }),
};
}
/** The operand GNU `du` assumes when the line names none. Mirrors Python's
* cwd_spec. */
function cwdSpec(cwd) {
if (cwd instanceof PathSpec)
return cwd;
const dir = cwd || '/';
return new PathSpec({ vfsPath: stripSlash(dir), virtual: dir, directory: dir, resolved: false });
}
/**
* Whether an operand holds anything, for the unstattable case.
*/
async function duHasContent(computeEntries, path) {
try {
const [entries] = await computeEntries(path);
// Safe to ask after the dispatcher already said no: `computeEntries`
// walks through the command's files, whose every listing the dispatcher
// filters by the session's hides, so a walled-off subtree answers no
// entries and its parent stays unconfirmed.
return entries.length > 0;
}
catch {
// This runs only after stat already failed, to tell an implicit
// directory from an absent path. Backends raise their own error types
// here (Graph 404, SFTP no-such-file), and every one of them means the
// same thing: nothing to measure. Surfacing it would replace GNU's
// "cannot access" line with a driver error.
return false;
}
}
/**
* Whether one operand is there at all, before anything is measured.
*
* A point lookup alone cannot decide, so this asks both channels a
* backend can answer on, the way `find` classifies its start point: on a
* prefix store a directory is not an object, it is the set of keys under
* it, so `stat` misses what a listing would show (redis never
* materialises an entry for its own mount root). Absence takes both
* coming back empty.
*
* Where a dispatcher is wired it replaces the bound backend's stat,
* because that stat sees one accessor and knows nothing of hides:
* trusting it answered `0 <path>` for a hidden directory and confirmed
* to the agent what the profile was not meant to show it. The content probe
* behind it counts only what the session may see, so it cannot re-open
* what the first channel closed.
*/
async function duOperandExists(path, stattable, hasContent, statPath) {
if (statPath === null && stattable)
return true;
if (statPath !== null && (await statPath(path)) !== null)
return true;
return hasContent !== undefined && (await hasContent(path));
}
/**
* Split the operands into the ones du can read and the ones it cannot.
*
* GNU names every operand it fails to stat, keeps going with the rest, and
* exits 1. With no operand at all it measures the working directory.
*
* A failed stat is not proof of absence, and du runs bound to one backend, so
* its own stat cannot see a mount nested below a path, which makes it a real
* directory from another VFS. `statPath` is the channel that knows, because it
* resolves through the dispatcher rather than one accessor,
* and it is the same probe `find` classifies its start point with. SessionState
* filtering rides along with it: a mount the session may not see contributes
* no directory here, so absence stays the answer for it.
*
* `hasContent` is the second channel behind that, for a backend that never
* materialises a directory entry for its own mount root (redis is one) while
* the subtree below it is full. It counts only what the session may see, so
* it cannot re-open what the first channel closed.
*/
async function duOperands(paths, cwd, resolveGlob, stat, hasContent, statPath = null) {
const targets = paths.length > 0 ? paths : [cwdSpec(cwd)];
const resolved = await resolveGlob(targets);
const present = [];
const missing = [];
// An unmatched glob reaches GNU as the literal pattern, which it then
// reports as unreadable.
if (resolved.length === 0) {
for (const item of targets.map((p) => [p.rawPath, posixPhrase('ENOENT')]))
missing.push(item);
}
for (const path of resolved) {
// A missing path is the diagnostic, not an error to propagate. Any
// other backend failure (403, a driver fault) is real and must surface
// rather than being renamed "No such file or directory".
let stattable = true;
try {
await stat(path);
}
catch (err) {
// An operand typed with a trailing slash that did not name a
// directory. Unreadable like a missing one, but GNU reports the
// errno it got, so the two cannot share a wording.
if (err.code === 'ENOTDIR') {
missing.push([path.rawPath, posixPhrase('ENOTDIR')]);
continue;
}
// The operand did not resolve, so no channel asked about the path
// it simplifies to can find it there.
if (isDotWalkError(err)) {
missing.push([path.rawPath, fsStrerror(err) ?? posixPhrase('ENOENT')]);
continue;
}
if (!isMissingPath(err))
throw err;
stattable = false;
}
let exists;
try {
exists = await duOperandExists(path, stattable, hasContent, statPath);
}
catch (err) {
// The dispatcher refuses to stat it: GNU names the errno it got
// (`du: cannot access 'P': Permission denied`).
if (err.code !== 'EACCES')
throw err;
missing.push([path.rawPath, fsStrerror(err) ?? posixPhrase('EACCES')]);
continue;
}
if (!exists) {
missing.push([path.rawPath, posixPhrase('ENOENT')]);
continue;
}
present.push(path);
}
return { present, missing };
}
function norm(path) {
return rstripSlash(path) || '/';
}
function parentOf(path) {
const cut = norm(path).lastIndexOf('/');
return cut > 0 ? path.slice(0, cut) : '/';
}
function depthOf(entryPath, basePath) {
const base = rstripSlash(basePath);
const rel = rstripSlash(entryPath).slice(base.length);
if (!rel)
return 0;
return (stripSlash(rel).match(/\//g) ?? []).length + 1;
}
/**
* Lift mount-relative walk entries onto absolute virtual paths.
*
* Backends walk their own key space and report mount-relative paths, so two
* mounts that both hold `notes.txt` would otherwise render the same line. The
* mount prefix is recovered from the operand the same way `find` and `grep -r`
* recover it.
*/
export function toVirtual(entries, path) {
const prefix = mountPrefixOf(path.virtual, path.vfsPath);
if (!prefix)
return [...entries];
return entries.map(([entry, size]) => [`${prefix}/${lstripSlash(entry)}`, size]);
}
/**
* Sum of leaves whose parent is the operand (GNU `-S` total).
*/
export function separateTotal(entries, root) {
const rootKey = norm(root);
let total = 0;
for (const [leaf, size] of entries) {
if (parentOf(norm(leaf)) === rootKey)
total += size;
}
return total;
}
/**
* Derive GNU's per-directory lines from a flat list of leaf files.
*
* Backends report only files, but GNU `du` prints a line per directory
* carrying its recursive total (and, under `-a`, a line per file too). Every
* directory between the operand and a leaf therefore accumulates that leaf's
* size, and the result is emitted post-order: children before their parent,
* siblings sorted by name. GNU walks in readdir order, which is unspecified,
* so sorting is a deterministic choice within the same shape.
*
* With `-S`/`--separate-dirs` a directory only counts files that sit
* directly in it: a leaf still forces every ancestor directory to appear
* (possibly at size 0), but only the immediate parent gets its bytes.
* The operand's own line is not included; the caller renders it with the
* operand as typed.
*/
export function rollup(entries, root,
// `dirs`: paths that are directories even though no leaf may point at
// them: the directories a walk met (an empty one, or one it could not
// open) and the roots of descendant mounts, which still get GNU's `0` row.
ctx) {
const rootKey = norm(root);
const prefix = rootKey.endsWith('/') ? rootKey : `${rootKey}/`;
const separateDirs = ctx.separateDirs === true;
const sizes = new Map();
const files = new Map();
for (const [leaf, size] of entries) {
const node = norm(leaf);
if (node === rootKey || !node.startsWith(prefix))
continue;
files.set(node, size);
let parent = parentOf(node);
let immediate = true;
while (parent !== rootKey && parent.startsWith(prefix)) {
if (separateDirs && !immediate) {
// -S: only the directory a file sits in counts its bytes. The
// ancestors still print, at 0 when they hold nothing but
// directories.
if (!sizes.has(parent))
sizes.set(parent, 0);
}
else {
sizes.set(parent, (sizes.get(parent) ?? 0) + size);
}
immediate = false;
parent = parentOf(parent);
}
}
// Set only when absent: a hinted directory that does hold leaves
// already carries their total.
for (const hinted of ctx.dirs ?? []) {
let node = norm(hinted);
while (node !== rootKey && node.startsWith(prefix)) {
if (!sizes.has(node))
sizes.set(node, 0);
node = parentOf(node);
}
}
// Keyed backends (S3, GridFS) carry a zero-byte marker object for a
// directory, which arrives here as a leaf. Under -a it must not replace
// that directory's computed total, so sums win on a clash.
const nodes = ctx.all ? new Map(files) : new Map();
for (const [node, size] of sizes)
nodes.set(node, size);
const kids = new Map();
for (const node of nodes.keys()) {
const parent = parentOf(node);
const group = kids.get(parent);
if (group === undefined)
kids.set(parent, [node]);
else
group.push(node);
}
for (const group of kids.values())
group.sort(compareCodePoints);
const order = [];
const stack = [[rootKey, false]];
while (stack.length > 0) {
const frame = stack.pop();
if (frame === undefined)
break;
const [node, expanded] = frame;
if (expanded) {
const deep = ctx.maxDepth !== null && depthOf(node, rootKey) > ctx.maxDepth;
if (node !== rootKey && !deep)
order.push([node, nodes.get(node) ?? 0]);
continue;
}
stack.push([node, true]);
const group = kids.get(node) ?? [];
for (let i = group.length - 1; i >= 0; i--)
stack.push([group[i] ?? '', false]);
}
return order;
}
/**
* Drop what falls under a descendant mount's root.
*
* The parent backend's keys under a nested mount are shadowed: no read can
* reach them, so no size may count them. GNU agrees (coreutils 9.7,
* `du --apparent-size -B1` over a tmpfs mounted inside the operand): a file
* covered by a mount appears nowhere and is in no total. What GNU
* additionally folds into the parent's rows, the mounted filesystem's own
* content, arrives here as the descendant's separately appended block
* instead, so the parent's own report is GNU's `du -x`.
*/
function dropShadowed(records, roots) {
return records.filter(({ path }) => {
const node = norm(path.virtual);
return !roots.some((root) => node === root || node.startsWith(root + '/'));
});
}
/** Each file a measurement counted, as (virtual path, bytes). */
export function leavesOf(records) {
return records.flatMap((r) => r.size === undefined ? [] : [[r.path.virtual, r.size]]);
}
async function duOne(path, run, computeSize, computeEntries, flags, mounts, directories) {
const label = path.rawPath;
const roots = mounts?.descendants(path.virtual) ?? [];
if (flags.s && !flags.S && roots.length === 0 && !pathsScoped([path])) {
// The one-total fast path trusts the backend's own sum, which a
// session hiding paths cannot: hidden leaves would be counted into
// a total their names never justify, so that session takes the
// entries walk below instead.
const total = await computeSize(path);
return [[line(total, flags.h, label)], total, null];
}
const [raw, rawTotal] = await computeEntries(path);
let total = rawTotal;
const rootKey = norm(path.virtual);
const under = rootKey.replace(/\/$/, '') + '/';
const met = dropShadowed((directories?.() ?? [])
.filter((d) => norm(d).startsWith(under))
.map((d) => ({ path: PathSpec.fromStrPath(d), run })), roots);
const walked = (directories?.() ?? []).some((d) => norm(d) === rootKey);
if (raw.length === 0 && met.length === 0 && !walked) {
// A backend that can only produce a size degrades to one total; it
// cannot enumerate, so shadowed keys cannot be excluded either. A
// walk that opened the operand already said all it can: an empty
// directory, or one a rule refused, which a second walk would
// report twice.
const fallback = await computeSize(path);
return [[line(fallback, flags.h, label)], fallback, fallback === 0 ? [{ path, run }] : null];
}
const counted = toVirtual(raw, path).map(([leaf, size]) => ({
path: PathSpec.fromStrPath(leaf),
run,
size,
}));
// Shadowed leaves drop: the total is the sum of what survived, never the
// backend's own number.
const kept = dropShadowed(counted, roots);
const entries = leavesOf(kept);
if (kept.length !== counted.length || roots.length > 0) {
total = entries.reduce((acc, [, size]) => acc + size, 0);
}
const dirs = met.map((r) => r.path.virtual);
const records = [{ path, run }, ...kept, ...met];
// A file operand walks to itself. GNU prints it once, with or without -a,
// never as a leaf line plus a roll-up line. GNU scopes -S to directories, so
// a file operand keeps its own size in both its row and the grand total.
const first = entries[0];
if (entries.length === 1 && first !== undefined && norm(first[0]) === rootKey) {
return [[line(first[1], flags.h, label)], total, records];
}
// -S changes what the operand's own row counts, not what the operand
// contributes to -c: GNU's grand total stays recursive (coreutils 9.7,
// `du -bSc dir` prints `3 dir` then `6 total`).
const own = flags.S ? separateTotal(entries, path.virtual) : total;
if (flags.s) {
return [[line(own, flags.h, label)], total, records];
}
const rows = rollup(entries, path.virtual, {
all: flags.a,
maxDepth: flags.maxDepth,
dirs,
separateDirs: flags.S,
});
const shown = respellRaw(rows.map(([p]) => p), path.virtual, label);
const lines = rows.map(([, size], i) => line(size, flags.h, shown[i] ?? ''));
lines.push(line(own, flags.h, label));
return [lines, total, records];
}
/**
* Render `du` output for a list of operands.
*
* `computeEntries` reports mount-relative (path, size) pairs plus the total.
* `missing` names the operands that could not be read: GNU reports each and
* exits 1 but still prints the rest. `stopped` is read after the walks for
* where one ran past `walkLimit`, each line without the command's name.
* `mounts` marks the descendant
* boundaries: leaves under one are shadowed and dropped from every row and
* total (see `dropShadowed`). `unreadable` is read after the walks for the
* directories a walk could not open (a rule refused them below the operand):
* GNU names each one, counts what it could, and exits 1; the line is spelled
* as the operand was typed, and follows the unreadable-operand lines since
* those are known before any walk. `directories` is read after each operand's
* walk for every directory it met, so one no file points at (empty, or
* refused) still gets GNU's row. `inaccessible` is read after the walks for
* each link `-L` could not follow, with the strerror GNU adds for a chain
* that loops (it names a dangling one alone); exits 1.
*/
export async function du(paths, flags, computeSize, computeEntries, missing = [], stopped, mounts = null, unreadable, directories, inaccessible,
// Asked before each operand's walk whether an earlier one already counted
// it, which GNU then skips without a row.
repeated) {
const lines = [];
let grand = 0;
let records = [];
for (const [run, root] of paths.entries()) {
if (repeated?.(root) === true)
continue;
const [block, total, measured] = await duOne(root, run, computeSize, computeEntries, flags, mounts, directories);
for (const item of block)
lines.push(item);
grand += total;
if (measured === null)
records = null;
else if (records !== null)
for (const record of measured)
records.push(record);
}
// GNU still prints the grand total when every operand failed ("0 total"), so
// this stays outside the loop guard.
if (flags.c)
lines.push(line(grand, flags.h, 'total'));
const notes = flags.warning === undefined ? [] : [flags.warning];
notes.push(...missing.map(([raw, detail]) => raw === '' ? `du: ${ZERO_LENGTH_NAME}` : `du: cannot access '${raw}': ${detail}`));
let exitCode = missing.length > 0 ? 1 : 0;
for (const virtual of unreadable?.() ?? []) {
notes.push(`du: cannot read directory '${respellUnder(virtual, paths)}': ${posixPhrase('EACCES')}`);
exitCode = 1;
}
for (const [virtual, detail] of inaccessible?.() ?? []) {
const shown = respellUnder(virtual, paths);
notes.push(`du: cannot access '${shown}'` + (detail === null ? '' : `: ${detail}`));
exitCode = 1;
}
for (const line of stopped?.() ?? []) {
notes.push(`du: ${line}`);
exitCode = 1;
}
const stderr = notes.length > 0 ? new TextEncoder().encode(`${notes.join('\n')}\n`) : new Uint8Array(0);
return { stdout: formatRecords(lines), stderr, exitCode, records };
}
/**
* Run one whole `du` invocation, from raw flags to rendered bytes.
*
* Every caller needs the same three steps in the same order: validate the
* flags before touching I/O, split the operands into readable and unreadable,
* then render. Keeping them here means a backend wrapper is wiring only, and
* the three steps cannot drift apart per backend.
*
* The walks meet links as entries of their own; under -L they follow them
* instead, which the operand's own was already, at dispatch. Deliberate
* divergence: a link counts as its target string's length, the size `ls -l`
* prints, where GNU counts the disk blocks it holds (0 for a target short
* enough to live in the inode); mirage counts bytes throughout.
*/
export async function duGeneric(paths, ctx, resolveGlob, stat, computeSize, computeEntries, stopped, unreadable, directories, inaccessible, repeated) {
const flags = parseFlags(ctx);
if (flags instanceof IOResult)
return [new Uint8Array(), flags];
const { present, missing } = await duOperands(paths, ctx.cwd, resolveGlob, stat, (p) => duHasContent(computeEntries, p), vfsStatPath(ctx.vfs) ?? null);
const out = await du(present, flags, computeSize, computeEntries, missing, stopped, mountsOf(ctx) ?? null, unreadable, directories, inaccessible, repeated);
return [
out.stdout,
new IOResult({ stderr: out.stderr, exitCode: out.exitCode, records: out.records }),
];
}
// Spell a walked path as the operand it lies under was typed.
function respellUnder(virtual, paths) {
for (const path of paths) {
const base = rstripSlash(path.virtual) || '/';
if (virtual === base || virtual.startsWith(`${rstripSlash(base)}/`)) {
return respellRaw([virtual], path.virtual, path.rawPath)[0] ?? virtual;
}
}
return virtual;
}
/**
* A du line reads below every path it names, walks into the mounts below
* unless `-x`, and follows a link operand when the last of `-L` and `-P` is
* `-L`. Mirrors Python's `facts`.
*/
export function facts(fl, _operands) {
return new LineFacts({
follows: fl.typedOrder('L', 'P').at(-1) === 'L' ? Follow.LAST : Follow.NONE,
walks: !fl.asBool('one_file_system'),
readsSubtree: true,
});
}
//# sourceMappingURL=du.js.map