UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

527 lines • 23.4 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 { 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