UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

257 lines • 11.2 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 { FileType, LINK_TARGET_KEY, PathSpec, } from "../../../../types.js"; import { mountKey } from "../../../../utils/key_prefix.js"; import { isRefusal } from "../../../../errors/fs.js"; import { classify } from "../../../../errors/classify.js"; import { CycleError } from "../../../../utils/path.js"; import { rstripSlash, stripSlash } from "../../../../utils/slash.js"; import { compareCodePoints } from "../../../../utils/sort.js"; import { posixPhrase } from "../../../../errors/posix.js"; // A mount boundary is a filesystem boundary, so both archivers stop at // one and say so in GNU tar's --one-file-system wording. Descending // would archive by accident exactly what the mount-root refusal forbids // on purpose. export const OTHER_FILESYSTEM = 'file is on a different filesystem; not dumped'; function linkTarget(stat) { const target = stat.extra[LINK_TARGET_KEY]; return typeof target === 'string' ? target : ''; } // A PathSpec for a walked descendant of one operand. The walk reports // absolute virtual paths; reading their bytes needs the backend key too, // which is the virtual path with this mount's prefix removed. function childSpec(virtual, root) { const cut = rstripSlash(root.virtual).length - stripSlash(root.vfsPath).length; const prefix = rstripSlash(root.virtual.slice(0, cut)); const slash = virtual.lastIndexOf('/'); return new PathSpec({ virtual, directory: slash >= 0 ? virtual.slice(0, slash + 1) : '/', vfsPath: mountKey(virtual, prefix), rawPath: virtual, }); } // Whether two virtual paths are served by the same mount. Outside a // workspace (no MountView) there is only one mount. function sameMount(mounts, one, other) { if (mounts === null) return true; return mounts.rootOf(one) === mounts.rootOf(other); } // Every entry under one directory, named under `nameBase`. // // Three sources have to be merged because no single one can see them // all: the backend walk (files and directories), the namespace (its // symlinks, which a walk for either kind passes over), and the mount table // (a nested mount, whose keys live in another VFS entirely). // // `base` and `nameBase` differ only when a link is being followed: the // walk runs over the target while the members keep the link's own name. async function subtree(root, base, nameBase, deps) { const walked = base !== root.virtual ? childSpec(base, root) : root; const found = new Map(); const dirs = await deps.walk(walked, 'd'); for (const virtual of dirs.paths) { if (rstripSlash(virtual) !== base) found.set(rstripSlash(virtual), ['dir', '']); } const files = await deps.walk(walked, 'f'); for (const virtual of files.paths) { found.set(rstripSlash(virtual), ['file', '']); } // Named under nameBase like the entries, once each (both listings // meet the same refusal). const unreadable = [...new Set([...(dirs.unreadable ?? []), ...(files.unreadable ?? [])])].map((u) => rstripSlash(nameBase) + rstripSlash(u).slice(rstripSlash(base).length)); const links = deps.links ?? null; if (links !== null) { for (const [virtual, stat] of links.subtree(base)) { found.set(rstripSlash(virtual), ['link', linkTarget(stat)]); } } const mounts = deps.mounts ?? null; // Two lists, because pruning and naming are different questions. A // mount the session cannot see still shadows the parent backend's // keys, so every descendant prunes; only the ones it may be told // about become a member and a "different filesystem" warning, since // both hand back the name a hide exists to withhold. const crossings = mounts !== null ? mounts.descendants(base) : []; const visibleCrossings = mounts !== null ? mounts.visibleDescendants(base) : []; for (const crossing of visibleCrossings) { // The mountpoint itself is still an entry, exactly as GNU's // --one-file-system keeps the directory and drops its contents. found.set(rstripSlash(crossing), ['dir', '']); } const below = crossings.map((c) => `${rstripSlash(c)}/`); const entries = []; for (const [virtual, [kind, target]] of found) { if (below.some((c) => virtual.startsWith(c))) continue; const named = rstripSlash(nameBase) + virtual.slice(rstripSlash(base).length); entries.push({ namePath: named, kind, target, read: kind === 'file' ? childSpec(virtual, root) : null, }); } entries.sort((a, b) => compareCodePoints(a.namePath, b.namePath)); return [entries, visibleCrossings.map((c) => rstripSlash(c)), unreadable]; } // What dereferencing puts in the archive in place of one symlink. // // The member keeps the link's own name and takes the target's content, // which is what dereferencing means. Two links resolving to the same // file are not a loop and both are archived; a real loop is whatever // `resolve` refuses to resolve, since the namespace already walks the // chain under a hop limit and raises ELOOP at the end of it. The third // return value is why the link was unreachable, empty when it was not; // the fourth names the directories below the target the walk could not // open. async function follow(virtual, root, deps) { const links = deps.links ?? null; if (links === null) return [[], [], '', []]; let target; try { target = links.resolve(virtual); } catch (e) { if (!(e instanceof CycleError)) throw e; return [[], [], posixPhrase('ELOOP'), []]; } if (!sameMount(deps.mounts ?? null, virtual, target)) return [[], [OTHER_FILESYSTEM], '', []]; const spec = childSpec(target, root); let targetStat; try { targetStat = await deps.stat(spec); } catch (err) { return [[], [], statProblem(err), []]; } if (targetStat.type !== FileType.DIRECTORY) { return [[{ namePath: virtual, kind: 'file', read: spec, size: targetStat.size }], [], '', []]; } if (!deps.recurse) return [[{ namePath: virtual, kind: 'dir' }], [], '', []]; const [entries, crossings, unreadable] = await subtree(root, target, virtual, deps); return [ [{ namePath: virtual, kind: 'dir' }, ...entries], crossings.map(() => OTHER_FILESYSTEM), '', unreadable, ]; } // The problems for directories a walk could not open. A rule refused each // one. tar prints the reason after "Cannot open: " and fails the run; // Info-ZIP stores the directory and is silent. // The phrase a member whose stat failed is reported with: a plain file in // its chain or a refusal keeps its errno, anything else reads as missing. // Mirrors Python's posix_phrase(classify(exc) or ENOENT). function statProblem(err) { const why = classify(err); return posixPhrase(why !== null && (why === 'ENOTDIR' || isRefusal(err)) ? why : 'ENOENT'); } function unreadableProblems(closed) { return closed.map((path) => ({ path, reason: posixPhrase('EACCES'), unreadable: true })); } /** * Everything one operand contributes to an archive. * * This is the whole of what tar and zip share: which paths go in, what each * one is, and which of them could not be reached. The two formats disagree * about the defaults, not the traversal, so both are parameters: tar stores a * symlink unless `-h` says to follow it and always descends, zip follows * unless `-y` says otherwise and only descends under `-r`. */ export async function scanOperand(path, deps) { const base = rstripSlash(path.virtual) || '/'; let entries = []; let crossings = []; const problems = []; const links = deps.links ?? null; const linkStat = links !== null ? links.statAt(path.virtual) : null; if (linkStat !== null && !deps.dereference) { entries.push({ namePath: base, kind: 'link', target: linkTarget(linkStat) }); } else if (linkStat !== null) { const [followed, why, unreachable, closed] = await follow(base, path, deps); if (unreachable !== '') { return { entries: [], crossings: [], problems: [{ path: base, reason: unreachable, fatal: true }], missing: true, }; } for (const item of followed) entries.push(item); for (const item of why.map((reason) => ({ path: base, reason }))) problems.push(item); for (const item of unreadableProblems(closed)) problems.push(item); } else { let rootStat; try { rootStat = await deps.stat(path); } catch (err) { return { entries: [], crossings: [], problems: [{ path: base, reason: statProblem(err), fatal: true }], missing: true, }; } if (rootStat.type !== FileType.DIRECTORY) { entries.push({ namePath: base, kind: 'file', read: path, size: rootStat.size }); } else { entries.push({ namePath: base, kind: 'dir' }); if (deps.recurse) { const [below, found, closed] = await subtree(path, base, base, deps); for (const item of below) entries.push(item); crossings = found; for (const item of unreadableProblems(closed)) problems.push(item); } } } if (deps.dereference && links !== null) { const expanded = []; for (const entry of entries) { if (entry.kind !== 'link') { expanded.push(entry); continue; } const [followed, why, unreachable, closed] = await follow(entry.namePath, path, deps); if (unreachable !== '') { problems.push({ path: entry.namePath, reason: unreachable, fatal: true }); continue; } for (const item of followed) expanded.push(item); for (const item of why.map((reason) => ({ path: entry.namePath, reason }))) problems.push(item); for (const item of unreadableProblems(closed)) problems.push(item); } entries = expanded; } return { entries, crossings, problems, missing: false }; } //# sourceMappingURL=walk.js.map