@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
432 lines • 19.8 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 { readdirOp } from "../../../../commands/builtin/generic/crossmount/utils.js";
import { dispatchedReadTree } from "../../../../commands/builtin/generic_bind/files.js";
import { groupName, ownerName } from "../../../../commands/builtin/utils/identity.js";
import { dispatchStat, dotRefusal } from "../../../../commands/builtin/utils/paths.js";
import { beforeFullWalk, treeWalk } from "../../../../commands/builtin/utils/tree.js";
import { UsageError } from "../../../../commands/errors.js";
import { callerSignal } from "../../../../context/session_context.js";
import { fsStrerror, isEacces, isEnoent, isEnotdir, isRefusal, walkRefusal, } from "../../../../errors/fs.js";
import { posixPhrase } from "../../../../errors/posix.js";
import { fsErrorLine } from "../../../../errors/render.js";
import { PolicyDenied } from "../../../../policy/index.js";
import { encodeText } from "../../../../shell/bytes.js";
import { FileType, PathSpec } from "../../../../types.js";
import { systemDate } from "../../../../utils/clock.js";
import { ancestors, CycleError } from "../../../../utils/path.js";
import { shellQuoteAlways } from "../../../../utils/quote.js";
import { rstripSlash } from "../../../../utils/slash.js";
import { expandOperands, result } from "../shared.js";
export function parseOwner(text) {
const sep = text.indexOf(':');
const owner = sep >= 0 ? text.slice(0, sep) : text;
const group = sep >= 0 ? text.slice(sep + 1) : '';
const uid = owner.length > 0 ? (/^\d+$/.test(owner) ? parseInt(owner, 10) : owner) : null;
const gid = sep >= 0 && group.length > 0 ? (/^\d+$/.test(group) ? parseInt(group, 10) : group) : null;
return [uid, gid];
}
// Parse a chgrp GROUP argument. Numeric ids become numbers; names are kept
// as strings (mirage has no group database; ownership is stored, not
// enforced). Null when the text is empty.
export function parseGroup(text) {
if (text.length === 0)
return null;
return /^\d+$/.test(text) ? parseInt(text, 10) : text;
}
// Resolve touch -t/-d into an ISO timestamp. `t` is a POSIX
// `[[CC]YY]MMDDhhmm[.ss]` stamp; `d` is a date string (ISO 8601). Returns
// null when neither flag is given; throws Error when the stamp is invalid.
export function parseTouchStamp(t, d) {
if (t !== null) {
let raw = t;
let seconds = 0;
if (raw.includes('.')) {
const dot = raw.indexOf('.');
const secText = raw.slice(dot + 1);
raw = raw.slice(0, dot);
if (secText.length !== 2 || !/^\d+$/.test(secText))
throw new Error(t);
seconds = parseInt(secText, 10);
}
if (!/^\d+$/.test(raw))
throw new Error(t);
if (raw.length === 8) {
raw = String(systemDate().getUTCFullYear()).padStart(4, '0') + raw;
}
else if (raw.length === 10) {
const century = parseInt(raw.slice(0, 2), 10) < 69 ? '20' : '19';
raw = century + raw;
}
if (raw.length !== 12)
throw new Error(t);
const dt = new Date(Date.UTC(parseInt(raw.slice(0, 4), 10), parseInt(raw.slice(4, 6), 10) - 1, parseInt(raw.slice(6, 8), 10), parseInt(raw.slice(8, 10), 10), parseInt(raw.slice(10, 12), 10), seconds));
if (Number.isNaN(dt.getTime()))
throw new Error(t);
if (dt.getUTCMonth() !== parseInt(raw.slice(4, 6), 10) - 1 ||
dt.getUTCDate() !== parseInt(raw.slice(6, 8), 10) ||
dt.getUTCHours() !== parseInt(raw.slice(8, 10), 10) ||
dt.getUTCMinutes() !== parseInt(raw.slice(10, 12), 10) ||
seconds > 59) {
throw new Error(t);
}
return isoformat(dt);
}
if (d !== null) {
let normalized = d.replace('Z', '+00:00').replace(' ', 'T');
if (!normalized.includes('T'))
normalized += 'T00:00:00';
const hasZone = /[+-]\d{2}:\d{2}$/.test(normalized);
const dt = new Date(hasZone ? normalized : normalized + '+00:00');
if (Number.isNaN(dt.getTime()))
throw new Error(d);
return isoformat(dt);
}
return null;
}
// Python's `datetime.isoformat()` for a UTC instant, the spelling the Python
// touch stores: the fraction is written only when there is one, and a Date's
// milliseconds make its last three microsecond digits zero.
function isoformat(dt) {
const ms = dt.getUTCMilliseconds();
const fraction = ms === 0 ? '' : `.${String(ms).padStart(3, '0')}000`;
return `${dt.toISOString().slice(0, 19)}${fraction}+00:00`;
}
export function nowIso() {
return isoformat(systemDate());
}
// A refused attribute write. A read-only mount is GNU's per-operand line,
// `<cmd>: <action> '<name>': Read-only file system`, the voice every other
// write refusal uses; a policy's deny at the dispatcher is the line a
// refused operand gets, `<cmd>: <name>: Permission denied` (touch's
// `cannot touch '<name>'`). Either names the file the way the line does,
// never the target a link resolved to: the dispatcher's error carries the
// resolved path. `action` is GNU's phrase for the write (`cannot touch`,
// `changing permissions of`). Mirrors Python's `permission_error`.
export function permissionError(cmd, action, name, err) {
if (err instanceof PolicyDenied)
return fsErrorLine(cmd, name, err);
return `${cmd}: ${action} '${name}': ${fsStrerror(err) ?? posixPhrase('EROFS')}\n`;
}
// The line for a walked directory whose listing was refused: fts's
// unreadable directory, which chmod, chown and chgrp report after the
// directory's own change and do not descend. Mirrors Python's
// `unread_error`.
export function unreadError(cmd, name, err) {
const strerror = fsStrerror(err) ?? posixPhrase('EACCES');
return `${cmd}: cannot read directory ${shellQuoteAlways(name)}: ${strerror}\n`;
}
// Route one attribute write through the dispatcher. The dispatcher applies what
// the backend can hold natively and stores the residual in the namespace
// overlay (dropping overlay fields the backend applied, so a stale
// overlay never shadows the fresh backend value); a VFS with no
// setattr op overlays everything. Kept as a seam so every metadata
// builtin shares one call shape.
export async function setattrVia(dispatch, path, fields) {
await dispatch('setattr', path, [], fields);
}
// Setattr a link node itself (the -h family): dispatched with `nofollow`
// so the dispatcher writes the link entry's own attrs instead of the target's;
// a link has no backend inode, so the dispatcher stores them in the overlay.
export async function setattrLink(dispatch, path, fields) {
await dispatch('setattr', path, [], { ...fields, nofollow: true });
}
/**
* Follow symlinks for one operand, collecting the ELOOP error. `action`
* words the step that failed (`cannot access`, `cannot touch`, `setting
* times of`). Mirrors Python's follow_operand.
*/
export function followOperand(links, cmd, action, target, errors) {
let virtual;
try {
virtual = links.follow(target.virtual);
}
catch (err) {
if (err instanceof CycleError) {
errors.push(`${cmd}: ${action} '${target.rawPath}': Too many levels of symbolic links\n`);
return null;
}
throw err;
}
return PathSpec.fromStrPath(virtual);
}
// A subtree as [path, stat] pairs, parents before children. Each entry's
// stat is captured during the walk because chmod's symbolic clauses (u+x)
// build on the entry's own current mode. Symlinks are skipped by name:
// the dispatcher's readdir reports them (they are namespace structure), GNU
// chmod -R changes neither a traversed link nor its referent, and the
// skip must come before the stat because stat follows a link and would
// descend through a directory link.
/**
* Follow symlinks and stat one operand, collecting GNU's errors: the dots
* the operand was typed with walk first, then the links, then the stat.
* `links` is the session's link table (`Namespace.sessionLinks`). Mirrors
* Python's resolve_operand.
*/
export async function resolveOperand(links, dispatch, cmd, target, errors) {
const refusal = target.walkError !== null
? walkRefusal(target)
: await dotRefusal(dispatchStat(dispatch), target, (v) => links.follow(v));
if (refusal !== null) {
errors.push(`${cmd}: cannot access '${target.rawPath}': ${fsStrerror(refusal) ?? posixPhrase('ENOENT')}\n`);
return null;
}
const resolved = followOperand(links, cmd, 'cannot access', target, errors);
if (resolved === null)
return null;
try {
const [result] = await dispatch('stat', resolved);
return [resolved, result];
}
catch (err) {
const strerror = isEnoent(err) || isEnotdir(err) ? fsStrerror(err) : null;
if (strerror !== null) {
errors.push(`${cmd}: cannot access '${target.rawPath}': ${strerror}\n`);
return null;
}
throw err;
}
}
// Ask the mount of `root`, and every mount below it, before -R changes
// anything, as for rm -r: a change cut short would leave a tree that looks
// done. The walk crosses into each mount below, so each one may refuse. A
// folder the session is refused is not asked: the walk does not enter it
// either, and reports it itself. Mirrors Python's before_recursive_change.
export async function beforeRecursiveChange(mounts, dispatch, root, cmd) {
for (const virtual of [root.virtual, ...mounts.visibleDescendants(root.virtual)]) {
try {
await beforeFullWalk(dispatch, PathSpec.fromStrPath(virtual), cmd);
}
catch (err) {
if (!isEacces(err))
throw err;
console.debug(`walk guard skipped at ${virtual}: ${String(err)}`);
}
}
}
// A subtree as [path, stat, unread] triples, in fts's pre-order, each stat
// read off the mount's `readTree` where one answers for the subtree. `links`
// is the session's link table (`Namespace.sessionLinks`). `unread` is the
// refusal a directory's listing met (a rule that denies the directory lets
// its stat through): fts's unreadable directory, which the caller reports
// after the directory's own change, and whose subtree is not walked.
// Mirrors Python's walk_stats.
export async function walkStats(links, dispatch, root, rootStat) {
const [readdir, statOf] = treeWalk(dispatchedReadTree(dispatch, links.mounts(false), null, true), readdirOp(dispatch), async (path) => (await dispatch('stat', path))[0], true, callerSignal());
const entries = [];
// An explicit stack, so a deep tree costs no recursion: a directory's
// children go on in reverse and come off in listing order.
const stack = [[root, rootStat]];
for (let top = stack.pop(); top !== undefined; top = stack.pop()) {
const [path, stat] = top;
if (stat.type !== FileType.DIRECTORY) {
entries.push([path, stat, null]);
continue;
}
let children;
try {
children = await readdir(path);
}
catch (err) {
if (!isRefusal(err))
throw err;
entries.push([path, stat, err]);
continue;
}
entries.push([path, stat, null]);
const found = [];
for (const listed of children) {
// A folder-backed readdir spells a directory child with its slash.
const childVirtual = rstripSlash(listed);
if (links.isLink(childVirtual))
continue;
const child = PathSpec.fromStrPath(childVirtual);
found.push([child, await statOf(child)]);
}
for (const entry of found.reverse())
stack.push(entry);
}
return entries;
}
// A subtree split into backend entries and namespace link nodes. chown and
// chgrp change a traversed symlink itself rather than its referent (POSIX
// gives -R an implicit -P), and a link is namespace state that no readdir
// can report, so the link nodes are folded back in from the session's link
// table (`Namespace.sessionLinks`), but for those under a directory whose
// listing was refused, which the walk does not descend.
export async function walkOwned(links, dispatch, root, rootStat) {
const walked = await walkStats(links, dispatch, root, rootStat);
const unread = new Set(walked.filter(([, , err]) => err !== null).map(([p]) => p.virtual));
// The root as well, which `ancestors` leaves out: a refused listing of `/`
// keeps every link below it unwalked.
const owned = links
.linkStatsBelow(root.virtual)
.map(([path]) => path)
.filter((path) => !['/', ...ancestors(path)].some((dir) => unread.has(dir)));
return { walked, owned };
}
// Which files chmod, chown and chgrp report: 'verbose' (every one),
// 'changes' (the changed ones) or null. The last of -c and -v wins.
export function verbosity(fl) {
return fl.typedOrder('changes', 'verbose').at(-1) ?? null;
}
// An entry of a walked operand as GNU names it: the operand as typed, then
// the entry's path below it.
export function walkedName(typed, root, path) {
let below = path.virtual.slice(root.virtual.replace(/\/+$/, '').length);
if (typed.endsWith('/'))
below = below.replace(/^\/+/, '');
return typed + below;
}
// Whether chown or chgrp changes a link's referent, or GNU's refusal. The
// last of -h and --dereference wins; -R implies -h, and refuses an explicit
// --dereference, which needs the -H or -L walk mirage does not offer.
export function followsLinks(cmd, fl) {
const last = fl.typedOrder('no_dereference', 'dereference').at(-1);
if (!fl.asBool('recursive'))
return [last !== 'no_dereference', null];
if (last === 'dereference') {
const stderr = `${cmd}: -R --dereference requires either -H or -L\n`;
return [false, result(cmd, { exitCode: 1, stderr })];
}
return [false, null];
}
// `USER:GROUP`, or the one of them given, as GNU's -v names it.
export function ownerSpec(user, group) {
if (user === null)
return group;
return group === null ? user : `${user}:${group}`;
}
// chown and chgrp's report for one file, as GNU 9.7 prints it.
// `status` is 'changed', 'retained' or 'failed'; `old` is null when the
// file could not be read.
export function ownerLine(name, status, old, user, group) {
const now = ownerSpec(user, group) ?? '';
const was = old === null ? null : ownerSpec(user !== null ? old[0] : null, group !== null ? old[1] : null);
const what = user !== null ? 'ownership' : 'group';
const shown = shellQuoteAlways(name);
if (status === 'changed')
return `changed ${what} of ${shown} from ${String(was)} to ${now}\n`;
if (status === 'retained')
return `${what} of ${shown} retained as ${now}\n`;
if (was === null)
return `failed to change ${what} of ${shown} to ${now}\n`;
return `failed to change ${what} of ${shown} from ${was} to ${now}\n`;
}
/**
* Set the owner and group of every operand, the way chown and chgrp do: -R
* walks under an implicit -P, -h changes a link itself, -v and -c report,
* -f drops the per-file errors. Mirrors Python's change_owner.
*/
export async function changeOwner(namespace, dispatch, session, cmd, fl, operands, uid, gid) {
const [follow, refused] = followsLinks(cmd, fl);
if (refused !== null)
return refused;
const report = verbosity(fl);
const identity = { profile: session.profile ?? null };
const user = uid === null ? null : String(uid);
const group = gid === null ? null : String(gid);
const fields = {
...(uid !== null ? { uid } : {}),
...(gid !== null ? { gid } : {}),
};
const action = cmd === 'chown' ? 'changing ownership of' : 'changing group of';
const errors = [];
const out = [];
const describe = (name, stat, failed) => {
let old = null;
let same = false;
if (stat !== null) {
old = [ownerName(stat.uid, identity), groupName(stat.gid, identity)];
same = (uid === null || uid === stat.uid) && (gid === null || gid === stat.gid);
}
const status = failed ? 'failed' : same ? 'retained' : 'changed';
if (report === 'verbose' || (report === 'changes' && status === 'changed')) {
out.push(ownerLine(name, status, old, user, group));
}
};
const own = async (path, name, link) => {
try {
if (link)
await setattrLink(dispatch, path, fields);
else
await setattrVia(dispatch, path, fields);
return true;
}
catch (err) {
if (!isRefusal(err))
throw err;
errors.push(permissionError(cmd, action, name, err));
return false;
}
};
const ownLink = async (link, name) => {
let stat = null;
try {
;
[stat] = (await dispatch('stat', link, [], { nofollow: true }));
}
catch (err) {
if (!isEnoent(err) && !isEnotdir(err))
throw err;
}
describe(name, stat, !(await own(link, name, true)));
};
const links = namespace.sessionLinks(session);
for (const target of await expandOperands(namespace, dispatch, operands)) {
const typed = target.rawPath;
if (!follow && links.isLink(target.virtual)) {
await ownLink(target, typed);
continue;
}
const found = await resolveOperand(links, dispatch, cmd, target, errors);
if (found === null) {
describe(typed, null, true);
continue;
}
const [resolved, stat] = found;
if (fl.asBool('recursive')) {
try {
await beforeRecursiveChange(links.mounts(false), dispatch, resolved, cmd);
}
catch (err) {
if (!(err instanceof UsageError))
throw err;
errors.push(`${err.message}\n`);
continue;
}
}
const { walked, owned } = fl.asBool('recursive')
? await walkOwned(links, dispatch, resolved, stat)
: { walked: [[resolved, stat, null]], owned: [] };
for (const [path, pathStat, unread] of walked) {
const name = walkedName(typed, resolved, path);
describe(name, pathStat, !(await own(path, name, false)));
if (unread !== null)
errors.push(unreadError(cmd, name, unread));
}
for (const link of owned) {
const linkSpec = PathSpec.fromStrPath(link);
await ownLink(linkSpec, walkedName(typed, resolved, linkSpec));
}
}
const quiet = fl.asBool('silent') || fl.asBool('quiet');
const text = out.join('');
return result(cmd, {
out: text === '' ? null : encodeText(text),
exitCode: errors.length > 0 ? 1 : 0,
...(quiet ? {} : { stderr: errors.join('') }),
});
}
//# sourceMappingURL=metadata.js.map