UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

420 lines • 20 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 { destKind } from "../../../../commands/builtin/generic/cp.js"; import { dispatchStat, walkSpelling } from "../../../../commands/builtin/utils/paths.js"; import { FlagView, parseCommand, SPECS } from "../../../../commands/spec/index.js"; import { parseToKwargs } from "../../../../commands/spec/parser.js"; import { fsStrerror, isDotWalkError, isEnoent, isFsError, isRefusal, } from "../../../../errors/fs.js"; import { posixPhrase } from "../../../../errors/posix.js"; import { FileType, Follow, PathSpec } from "../../../../types.js"; import { CycleError, gnuBasename, posixNormpath } from "../../../../utils/path.js"; import { rstripSlash } from "../../../../utils/slash.js"; import { lineFacts, SLASH_KEEPS_LAST } from "../../../lookup/constants.js"; import { statOrNull } from "../../../mount/namespace/probe.js"; import { fail } from "../shared.js"; // Opt in by assigning a debug sink, as Python's module logger is off until // configured. export const logger = {}; export function posixRelative(target, startDir) { const t = target.split('/').filter(Boolean); const s = startDir.split('/').filter(Boolean); let i = 0; while (i < t.length && i < s.length && t[i] === s[i]) i += 1; const parts = [...s.slice(i).map(() => '..'), ...t.slice(i)]; return parts.length > 0 ? parts.join('/') : '.'; } // Resolve every component of a path but the last one. POSIX resolves a // path one component at a time, and only the last one is exempt for an // lstat-style command: `stat dlink/f2` reports f2 because dlink was // resolved on the way to it, while `stat dlink` reports the link. A // no-follow command therefore still needs its operand's prefix // resolved. The walk is the namespace's (`Namespace.followParent`, which // the dispatcher runs for every surface), read through the session's link // table (`Namespace.sessionLinks`); the operand comes back without a // trailing slash, which the slash-keeping commands read off `rawPath` // instead. Throws CycleError on ELOOP. function followParent(links, virtual) { const trimmed = rstripSlash(virtual); return trimmed === '' ? virtual : links.followParent(trimmed); } // Rewrite path operands through the symlink table (open(2) semantics). // The directory prefix always resolves; `followLast` decides the final // component, which is the whole difference between open(2) and lstat(2). // A trailing slash overrides it per operand, because POSIX reads // `dlink/` as `dlink/.` and there is no `.` to reach without resolving // the link first (GNU: `stat dlink` is a symbolic link, `stat dlink/` is // a directory). `slashFollows` turns that override off, which only tar // wants: it strips the slash before it stats. // A rewritten spec keeps the user-typed form in `rawPath` so error messages // still name the operand as typed; the mount re-stamps `vfsPath` at // dispatch. A path a link loop stands in resolves to nothing, so it stays // as typed with `walkError` set, and the op that reaches it answers ELOOP: // GNU reports the one operand in the command's own words and goes on to // the next. A path the line's session cannot see stays as typed too, so the // op that reaches it answers ENOENT rather than reading through a hidden // link: `links` is the session's link table (`Namespace.sessionLinks`). export function followPaths(links, items, followLast = true, slashFollows = true) { const out = []; for (const item of items) { if (!(item instanceof PathSpec)) { out.push(item); continue; } const last = followLast || (slashFollows && item.rawPath.endsWith('/')); let followed; try { const spelled = walkSpelling(item, (path) => links.follow(path)); followed = last ? links.follow(spelled) : followParent(links, spelled); } catch (err) { if (!(err instanceof CycleError)) throw err; out.push(new PathSpec({ virtual: item.virtual, directory: item.directory, vfsPath: item.vfsPath, pattern: item.pattern, resolved: item.resolved, rawPath: item.rawPath, dotted: item.dotted, walkError: 'ELOOP', })); continue; } // A relative target climbs from the link's own directory, which is a real // one, so its `..` collapses the way resolveLink collapses it; left in, // the path no longer matched the word that spelled it and the operand lost // its typed name (`wc -c sub/al` printed `/data/sub/../a`). let virtual = posixNormpath(followed); if (followed.endsWith('/') && virtual !== '/') virtual += '/'; if (virtual === item.virtual) { out.push(item); continue; } out.push(new PathSpec({ virtual, directory: virtual.slice(0, virtual.lastIndexOf('/') + 1) || '/', vfsPath: '', pattern: item.pattern, resolved: item.resolved, rawPath: item.rawPath, dotted: item.dotted, walkError: item.walkError, })); } return out; } // Whether the command layer will act on this line as written. // // A link entry lives in the namespace, so `stripLinkOperands` removes it // before the command runs, and the command layer can neither see that nor // undo it. GNU validates the whole line first and removes nothing when it // refuses: `rm --bogus dlink` reports the option and `unlink dlink other` // reports the extra operand, both with every link still in place. So the // strip runs only for a line that layer accepts, and a refused one falls // through to it unchanged to be reported there. Option errors are the // parser's, which reports rather than raises them; unlink's one-operand // grammar is its builder's, and reporting it needs the operands to arrive // intact. export function acceptsLine(name, args, items, cwd) { const spec = SPECS[name]; if (spec === undefined) return true; const parsed = parseCommand(spec, [...args], cwd, name); if (parsed.invalidOptions.length > 0 || parsed.ambiguousOptions.length > 0) { return false; } if (name === 'unlink') { return items.filter((i) => i instanceof PathSpec).length <= 1; } return true; } // Unlink and drop `rm`/`unlink` operands that are symlinks. GNU rm removes // the link itself and never follows it; a dangling link removes fine. // // The removal is a dispatch op, never a direct table write: the dispatcher is // where session grants, the turf's mode, admission policies and the op // ledger fire, and writing the table from here let a session delete a // link its grant reads and a policy protecting one never fired (the same // hole the FUSE unlink had). A refused operand does not stop the rest, // which is also rm's rule for a backend operand; `-f` silences only the // absent (a hidden link answers ENOENT, the no-name-leak rule). // // The refusal is voiced the way the same refusal on a backend file is // voiced, so one grant does not describe itself two ways: GNU's // per-operand line, a read-only region's EROFS included. // // An operand typed with a trailing slash is deliberately kept: the slash // asked for a directory, and GNU refuses rather than removing the link // (`rm dlink/` is "Is a directory", `unlink dlink/` is "Not a // directory"). Removing it here would delete exactly what the slash was // protecting, so the command reports it instead. Returns the surviving // parts, the number of link operands consumed (removed, refused or // force-silenced), and the refusal lines. Mirrors Python's // strip_link_operands. export async function stripLinkOperands(name, dispatch, links, items, args, cwd) { let force = false; if (name === 'rm') { const spec = SPECS.rm; if (spec !== undefined) { // Keyed by the dashed spelling the line used, not the dest. force = parseCommand(spec, [...args], cwd, 'rm').flags['-f'] === true; } } const verb = name === 'rm' ? 'remove' : 'unlink'; let handled = 0; const errors = []; const kept = []; for (const item of items) { if (item instanceof PathSpec && !item.rawPath.endsWith('/') && links.isLink(item.virtual)) { handled += 1; try { await dispatch('unlink', item); } catch (err) { const suffix = fsStrerror(err); if (suffix === null) throw err; if (isEnoent(err) && force) continue; errors.push(`${name}: cannot ${verb} '${item.rawPath}': ${suffix}\n`); } continue; } kept.push(item); } return [kept, handled, errors]; } // GNU's refusal for an `mv` source that is a link typed with a slash. // // rename(2) never follows, so the slash is not resolved away: POSIX reads // `dlink/` as `dlink/.`, which asks for a directory the call will not // resolve, and GNU refuses with everything left in place -- where a bare // `dlink` renames the link entry. Which of the four wordings applies // follows mv's own order, source stat before destination type before the // rename itself, and all four are pinned against GNU coreutils 9.7. async function slashedLinkRefusal(links, dispatch, src, dst, dstStat) { let followed; try { followed = links.follow(src.virtual); } catch (err) { if (!(err instanceof CycleError)) throw err; return fail('mv', `mv: cannot stat '${src.rawPath}': ${posixPhrase('ELOOP')}\n`); } const target = await statOrNull(dispatch, PathSpec.fromStrPath(followed)); if (target === null) { return fail('mv', `mv: cannot stat '${src.rawPath}': No such file or directory\n`); } if (target.type !== FileType.DIRECTORY) { return fail('mv', `mv: cannot stat '${src.rawPath}': Not a directory\n`); } if (dstStat !== null && dstStat.type !== FileType.DIRECTORY) { return fail('mv', `mv: cannot overwrite non-directory '${dst.rawPath}' with directory '${src.rawPath}'\n`); } let landing = dst.rawPath; if (dstStat !== null) { landing = rstripSlash(landing) + '/' + gnuBasename(src.virtual); } return fail('mv', `mv: cannot move '${src.rawPath}' to '${landing}': Not a directory\n`); } /** * Resolve each command-line link that leads to a directory: GNU ls's default * for command-line operands (measured on coreutils 9.7). `ls dlink` lists the * directory, while `ls flink` and a dangling `ls dang` report the link itself, * and so does a loop, whose target cannot be reached. * Where a link leads takes a stat through the dispatcher to know, since the target * may live on any mount, so this runs after `followPaths` has resolved every * operand's prefix. Mirrors Python's follow_directory_links. */ export async function followDirectoryLinks(links, dispatch, items) { const out = []; for (const item of items) { if (!(item instanceof PathSpec) || item.walkError !== null || !links.isLink(item.virtual)) { out.push(item); continue; } const followed = followPaths(links, [item])[0]; let target = null; if (followed instanceof PathSpec && followed.walkError === null) { try { target = await statOrNull(dispatch, followed); } catch (err) { // A loop leads nowhere: GNU lstats it then and lists the link. if (!isDotWalkError(err) || err.code !== 'ELOOP') { if (!isFsError(err)) throw err; // A stat refused other than by absence is GNU's to report for that // operand, and ls stats the target itself. console.debug(`ls: ${item.virtual} leads to a refusal: ${String(err)}`); out.push(followed); continue; } } } out.push(target !== null && target.type === FileType.DIRECTORY && followed !== undefined ? followed : item); } return out; } /** * The pathname resolution a line gives its operands, as one call. * * What the kernel does to a path before the syscall: every component but the * last resolves for every command, and the last for a command that follows * (open(2) rather than lstat(2)) or an operand typed with a trailing slash; * ls resolves a command-line link only once a stat has shown that it leads to * a directory. The router runs it over the typed operands, and a command's glob * over the names a pattern matched, which bash would have put on the line as * words, so a link a glob matched resolves as the same name typed would. * Mirrors Python's operand_follow. */ export function operandFollow(links, dispatch, name, words, cwd) { const follows = lineFacts(name, words, cwd).follows; const followLast = follows === Follow.LAST; const slashFollows = !SLASH_KEEPS_LAST.has(name); return async (items) => { if (links.symlinkTargets().size === 0) return [...items]; const out = followPaths(links, [...items], followLast, slashFollows); return follows === Follow.DIRECTORY ? followDirectoryLinks(links, dispatch, out) : out; }; } /** Resolve the destination directory without transferring any source. */ export async function prepareMv(links, dispatch, items, args, cwd) { const paths = items.filter((p) => p instanceof PathSpec); const spec = SPECS.mv; if (spec === undefined) return { items, early: null }; const fl = new FlagView(parseToKwargs(parseCommand(spec, [...args], cwd, 'mv')), spec); const target = fl.raw('target_directory'); if (target !== undefined || paths.length > 2) { return prepareMany(links, items, paths, target); } const src = paths[0]; const dst = paths[1]; if (paths.length !== 2 || src === undefined || dst === undefined) { return { items, early: null }; } return preparePair(links, dispatch, items, src, dst, fl); } function landingKey(path) { return rstripSlash(path) || '/'; } /** * An `mv` of sources into one destination directory: `mv a b dst` and * `mv -t dst a ...`. GNU stats the destination through its link before * anything moves: a link to a directory takes the sources, a link to anything * else is not a directory, a dangling one is missing and a loop is ELOOP, each * worded by the generic mv from the destination handed to it here (coreutils * 9.7). Every source then lands at the directory plus its basename: a link * source through the namespace, which no backend mv can see, and every other * one with its node entries re-anchored once the backend confirms the move. * Mirrors Python's _prepare_many. */ function prepareMany(links, items, paths, target) { let dst; if (target !== undefined) { const spelled = target instanceof PathSpec ? target.virtual : typeof target === 'string' ? target : null; dst = spelled === null ? undefined : paths.find((p) => landingKey(p.virtual) === landingKey(spelled)); } else { dst = paths[paths.length - 1]; } if (dst?.walkError !== null) { return { items, early: null }; } const followed = followPaths(links, [dst])[0]; if (!(followed instanceof PathSpec)) return { items, early: null }; const rewritten = items.map((item) => (item === dst ? followed : item)); return { items: rewritten, early: null }; } // A two-operand `mv`: one source and the destination it replaces or lands // inside. Mirrors Python's _prepare_pair. async function preparePair(links, dispatch, items, src, dst, fl) { if (src.walkError !== null || dst.walkError !== null) { // The walk refused the operand, so there is no entry to move or land // on; the generic mv reports it through its own stat, which cannot see // a link source. A link is a non-directory, and GNU stats an empty // destination as a directory (see mvGeneric). if (dst.rawPath === '' && src.walkError === null && links.isLink(src.virtual)) { const early = fail('mv', `mv: cannot overwrite directory '' with non-directory '${src.rawPath}'\n`); return { items, early }; } if (dst.walkError === 'ELOOP' && src.walkError === null && links.isLink(src.virtual)) { const early = fail('mv', `mv: cannot stat '${dst.rawPath}': ${posixPhrase('ELOOP')}\n`); return { items, early }; } return { items, early: null }; } // Where the move lands: inside a directory destination (followed, so // node-meta keys line up with the followed paths stat merges on), else // the destination itself, replaced like rename(2). A destination a link // loop stands in stats ELOOP, which mv reads as not a directory: the // rename replaces the link itself (GNU 9.7). let followed; try { followed = links.follow(dst.virtual); } catch (err) { if (!(err instanceof CycleError)) throw err; followed = null; } let stat; try { stat = followed === null ? null : await statOrNull(dispatch, PathSpec.fromStrPath(followed)); } catch (err) { if (!isRefusal(err)) throw err; // A refused destination: the generic mv names it through its stat. logger.debug?.(`mv: destination ${dst.virtual} refused: ${String(err)}`); return { items, early: null }; } const intoDir = !fl.asBool('no_target_directory') && stat !== null && stat.type === FileType.DIRECTORY; if (links.isLink(src.virtual)) { if (src.rawPath.endsWith('/')) { const early = await slashedLinkRefusal(links, dispatch, src, dst, stat); return { items, early }; } if (!intoDir && dst.rawPath.endsWith('/')) { // rename(2) never follows the source, so a link is not a directory // whatever it points at, and a slashed destination asks for one: // GNU 9.7 refuses `mv dlnk missing/` at the rename and `mv dlnk reg/` // at the destination's stat, the same two wordings a regular source // gets from the generic, whose chain walk also keeps an absent // parent's ENOENT (`mv dlnk nodir/name/`) ahead of the slash. const { condition } = await destKind(dispatchStat(dispatch), dst); const early = condition === 'ENOTDIR' ? fail('mv', `mv: cannot stat '${dst.rawPath}': Not a directory\n`) : fail('mv', `mv: cannot move '${src.rawPath}' to '${dst.rawPath}': ${posixPhrase(condition ?? 'ENOTDIR')}\n`); return { items, early }; } } const rewritten = intoDir && links.isLink(dst.virtual) ? items.map((item) => (item === dst ? (followPaths(links, [dst])[0] ?? dst) : item)) : items; return { items: rewritten, early: null }; } //# sourceMappingURL=links.js.map