UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

432 lines • 19.8 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 { 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