@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
633 lines • 28.4 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 { underPath } from "../../../utils/key_prefix.js";
import { IOResult } from "../../../io/types.js";
import { FileType, Follow, LineFacts, } from "../../../types.js";
import { isFifo } from "../../../utils/stat_view.js";
import { UsageError } from "../../errors.js";
import { usageHint } from "../../spec/usage.js";
import { beforeFullWalk } from "../utils/tree.js";
import { backupControl, siblingPath } from "../utils/backup.js";
import { DEFAULT_BACKUP_SUFFIX } from "../utils/constants.js";
import { STAT_REFUSALS, backendKeyDefault, copyTargets, pathExists, typedName, } from "../utils/copy.js";
import { errorVirtualPath, innerSuffix, isFsError, isLandedMove, isStaleWrite, strerrorOf, virtualOf, withInner, } from "../../../errors/fs.js";
import { rstripSlash } from "../../../utils/slash.js";
import { descendantPath } from "../utils/paths.js";
import { linkStat, renameLink, backupDisplaces, backupRaw, copyEntries, walk, destKind, justCreatedError, sameFileError, sourceKind, makeBackup, overwriteGate, overwriteTypeError, prompter, slashRefusesFile, splitOperands, stderrOf, suffixFlag, targetDirError, targetFlags, typeMismatchError, updateGates, updateMode, } from "./cp.js";
import { posixPhrase } from "../../../errors/posix.js";
import { MoveError } from "../../../errors/types.js";
const ENC = new TextEncoder();
// Bound on the --exchange staging-name probe.
const HOLDING_ATTEMPTS = 100;
export function mvFlags(init = {}) {
return {
noClobber: init.noClobber ?? false,
interactive: init.interactive ?? false,
verbose: init.verbose ?? false,
update: init.update ?? null,
backup: init.backup ?? null,
suffix: init.suffix ?? DEFAULT_BACKUP_SUFFIX,
targetDir: init.targetDir ?? null,
noTargetDir: init.noTargetDir ?? false,
exchange: init.exchange ?? false,
noCopy: init.noCopy ?? false,
};
}
function isPrimitiveMove(strategy) {
return 'read' in strategy;
}
// Parse the mv flag bag once into a frozen struct. The last of -f, -i and
// -n decides: -i asks before each overwrite, -n skips, -f replaces.
// --strip-trailing-slashes is a no-op because PathSpec already normalizes
// trailing slashes.
export function parseFlags(fl) {
const update = updateMode('mv', fl);
const suffix = suffixFlag(fl);
const control = backupControl('mv', backupRaw(fl), suffix);
const answer = fl.typedOrder('force', 'interactive', 'no_clobber').at(-1);
const noClobber = answer === 'no_clobber';
const exchange = fl.asBool('exchange');
if (control !== null && control !== 'none' && (exchange || noClobber || update === 'none-fail')) {
throw new UsageError(`mv: cannot combine --backup with --exchange, -n, or --update=none-fail\n${usageHint('mv')}`, 1);
}
const [targetDir, noTargetDir] = targetFlags('mv', fl);
return mvFlags({
noClobber,
interactive: answer === 'interactive',
verbose: fl.asBool('verbose'),
update,
backup: control,
suffix: suffix ?? DEFAULT_BACKUP_SUFFIX,
targetDir,
noTargetDir,
exchange,
noCopy: fl.asBool('no_copy'),
});
}
// Confirm a failed removal actually left something behind. On dirless
// object stores a directory vanishes with its last child, so a failed
// rmdir of a path that no longer exists (or that no longer lists any
// children — an existing empty directory is impossible there) is a
// completed removal, not an error. The listing check covers index
// backends whose per-entry stat can lag a just-unlinked child within a
// command.
async function entryGone(strategy, stat, spec, isDir) {
if (!(await pathExists(stat, spec)))
return true;
if (!isDir)
return false;
let children;
try {
children = await strategy.readdir(spec);
}
catch (err) {
if (!isFsError(err))
throw err;
return true;
}
return children.length === 0;
}
// GNU's line for a target mv cannot replace. On one filesystem the rename
// refuses the overwrite itself. Across filesystems GNU removes the target
// before it copies, so the failed removal is what it reports (coreutils 9.7).
// Mirrors Python's _replace_error.
function replaceError(src, target, reason, across) {
if (across) {
return `mv: inter-device move failed: '${src.rawPath}' to '${target.rawPath}'; unable to remove target: ${reason}`;
}
return `mv: cannot overwrite '${target.rawPath}': ${reason}`;
}
// GNU's line for a source mv could not move onto its target. Mirrors
// Python's _move_error.
function moveError(src, target, reason) {
return `mv: cannot move '${src.rawPath}' to '${target.rawPath}': ${reason}`;
}
// Remove one copied entry; why it stayed, null once it is gone. Mirrors
// Python's _remove_entry.
async function removeEntry(strategy, stat, spec, isDir) {
try {
if (isDir)
await strategy.rmdir(spec);
else
await strategy.unlink(spec);
}
catch (err) {
if (!isFsError(err))
throw err;
if (!(await entryGone(strategy, stat, spec, isDir)))
return strerrorOf(err);
}
return null;
}
// Remove copied source entries children first, GNU rm style. A failed
// removal is reported per entry ('mv: cannot remove ...') and the remaining
// entries are still attempted; directories with a failed descendant are
// skipped silently like GNU, which never reports the not-empty ancestors of
// a file it could not remove. A mount root is busy: its files go, the root
// stays (coreutils 9.7). Returns whether the source is fully gone.
async function removeEntries(strategy, stat, src, entries, errors, lines, mountRoot) {
const failed = [];
for (let i = entries.length - 1; i >= 0; i -= 1) {
const node = entries[i];
if (node === undefined)
continue;
const isDir = node.kind === FileType.DIRECTORY;
const base = rstripSlash(node.path);
if (isDir && failed.some((f) => f.startsWith(base + '/'))) {
failed.push(base);
continue;
}
const spec = descendantPath(src, node.path);
const reason = isDir && mountRoot?.(spec.virtual) === true
? posixPhrase('EBUSY')
: await removeEntry(strategy, stat, spec, isDir);
if (reason !== null) {
errors.push(`mv: cannot remove '${spec.rawPath}': ${reason}`);
failed.push(base);
continue;
}
lines?.push(`removed ${isDir ? 'directory ' : ''}'${spec.rawPath}'`);
}
return failed.length === 0;
}
// An unused sibling of `target` to stage a swap through. The name is probed
// instead of assumed: renames overwrite on most backends, so a fixed
// `.~xchg~` would silently destroy a real file of that name. null means no
// free slot was found.
async function holdingPath(stat, target) {
for (let attempt = 0; attempt < HOLDING_ATTEMPTS; attempt += 1) {
const tag = attempt === 0 ? '.~xchg~' : `.~xchg${String(attempt)}~`;
const candidate = siblingPath(target, tag);
if (!(await pathExists(stat, candidate)))
return candidate;
}
return null;
}
// Put a partially completed swap back the way it was. Returns true when the
// operands are back in their original places.
async function undoExchange(strategy, src, target, holding, staged, swapped) {
if (!staged)
return true;
try {
if (swapped)
await strategy.rename(src, target);
await strategy.rename(holding, src);
}
catch (err) {
if (!isFsError(err))
throw err;
return false;
}
return true;
}
// Swap two entries through a staging name (--exchange). Both sides must
// exist. Deliberate divergence: GNU issues one atomic
// renameat2(RENAME_EXCHANGE), which no backend exposes, so the swap is
// staged through an unused sibling of the target and is *not* atomic. The
// staging name is probed for a free slot so an existing `.~xchg~` is never
// clobbered, and a failure part-way rolls the operands back. Where GNU's
// renameat2 probe degrades a missing side to 'Unknown error -1', the honest
// errno text is reported instead. A cross-mount exchange fails like GNU on a
// cross-device rename.
async function exchangePair(strategy, stat, src, target, errors, lines) {
const refused = `mv: cannot exchange '${src.rawPath}' and '${target.rawPath}'`;
if (isPrimitiveMove(strategy)) {
errors.push(`${refused}: ${posixPhrase('EXDEV')}`);
return;
}
if (typedName(src) === '.' || typedName(src) === '..') {
errors.push(`${refused}: ${posixPhrase('EBUSY')}`);
return;
}
if (!(await pathExists(stat, src)) || !(await pathExists(stat, target))) {
errors.push(`${refused}: ${posixPhrase('ENOENT')}`);
return;
}
const holding = await holdingPath(stat, target);
if (holding === null) {
errors.push(`${refused}: ${posixPhrase('EEXIST')}`);
return;
}
let staged = false;
let swapped = false;
try {
await strategy.rename(src, holding);
staged = true;
await strategy.rename(target, src);
swapped = true;
await strategy.rename(holding, target);
}
catch (err) {
if (!isFsError(err))
throw err;
const restored = await undoExchange(strategy, src, target, holding, staged, swapped);
errors.push(`${refused}: ${strerrorOf(err)}`);
if (!restored) {
errors.push(`mv: '${src.rawPath}' left at '${holding.rawPath}' after a failed exchange`);
}
return;
}
if (lines !== undefined)
lines.push(`exchanged '${src.rawPath}' <-> '${target.rawPath}'`);
}
// Move sources to a destination, fanning out into a directory. NativeMove
// uses an atomic backend rename. PrimitiveMove handles cross-mount moves by
// copying the tree (parents first, via walk plus mkdir/write) and then
// removing the source children first. Failures follow GNU mv on a
// cross-device move: a copy failure keeps the whole source and skips
// removal, a removal failure (e.g. a source mount with no unlink) reports
// 'cannot remove' and leaves the copied destination in place; either way
// the remaining sources still move. -n/--update/--backup gate whole source
// operands (rename semantics), never individual entries of a tree.
export async function mvGeneric(paths, stat, strategy, flags, backendKey, readdir,
// Judges one (source, target) pair before the move touches anything,
// the backup included, throwing to refuse it; the adapter wires the
// hidden-reveal check here so a refused move mutates nothing (no
// half-copy, no destination renamed aside by -b). Consulted only for
// a directory source, since a file carries nothing below it to
// reveal.
guard, copies,
// Where -i reads its answers.
stdin,
// Whether a path is a mount root, which a move can neither replace nor
// rename aside, nor remove from a source moved across mounts; undefined
// outside a workspace.
mountRoot,
// The command's dispatcher, which lets a source's mount refuse a tree
// copied across mounts; undefined outside a workspace.
dispatch) {
if (copies !== undefined)
stat = (path) => linkStat(copies, path);
const keyOf = backendKey ?? backendKeyDefault;
const [sources, dst] = splitOperands('mv', paths, flags.targetDir, flags.noTargetDir);
let dstIsDir;
let dstExists;
let dstErr = null;
if (flags.targetDir !== null) {
const err = await targetDirError('mv', stat, dst);
if (err !== null) {
return [null, new IOResult({ stderr: ENC.encode(`${err}\n`), exitCode: 1 })];
}
dstIsDir = true;
dstExists = true;
}
else if (flags.noTargetDir) {
dstIsDir = false;
dstExists = true;
}
else {
const probe = await destKind(stat, dst);
dstExists = probe.exists;
dstIsDir = probe.isDir;
dstErr = probe.condition;
}
let versionReaddir = readdir;
if (versionReaddir === undefined && isPrimitiveMove(strategy)) {
versionReaddir = strategy.readdir;
}
const errors = [];
const accepted = [];
const policy = {
cmdName: 'mv',
noClobber: flags.noClobber,
update: flags.update,
backup: flags.backup,
suffix: flags.suffix,
ask: flags.interactive ? prompter('mv', stdin ?? null, errors, accepted) : null,
};
const lines = [];
const created = new Set();
for (const [src, target] of copyTargets(sources, dst, dstIsDir, dstExists, dstErr)) {
const { kind: srcKind, condition: srcErr } = await sourceKind(stat, src);
const srcIsDir = srcKind === FileType.DIRECTORY;
if (srcErr !== null) {
errors.push(`mv: cannot stat '${src.rawPath}': ${posixPhrase(srcErr)}`);
continue;
}
if (target.walkError !== null && target.rawPath === '') {
// GNU stats an empty destination as the directory it is typed in, as
// if it were `.`: a file cannot overwrite it, and a directory renamed
// onto it is busy (coreutils 9.7).
errors.push(srcIsDir
? moveError(src, target, posixPhrase('EBUSY'))
: typeMismatchError('mv', src, target, false));
continue;
}
if (keyOf(src) === keyOf(target)) {
errors.push(sameFileError('mv', src, target));
continue;
}
if (flags.exchange) {
await exchangePair(strategy, stat, src, target, errors, flags.verbose ? lines : undefined);
continue;
}
// A source typed as `.` or `..` names no entry a rename can take, so
// GNU's rename answers EBUSY once the gates let it run, before any
// subdirectory verdict (coreutils 9.7). Across mounts GNU copies it and
// then refuses to remove it; mirage refuses first. A mount root as a
// source is busy too: the policy refuses one typed, and this one is
// reached through a link it cannot see.
const busy = typedName(src) === '.' || typedName(src) === '..' || mountRoot?.(src.virtual) === true;
if (!busy && keyOf(target).startsWith(keyOf(src) + '/')) {
errors.push(`mv: cannot move '${src.rawPath}' to a subdirectory of itself, '${target.rawPath}'`);
continue;
}
const probe = !flags.noTargetDir && target.virtual === dst.virtual
? { exists: dstExists, isDir: dstIsDir, condition: dstErr }
: await destKind(stat, target);
const { exists: targetExists, isDir: targetIsDir, condition: targetErr } = probe;
// mv's own order: the destination's stat refuses before the rename
// does. A chain that is merely absent is left to the backend rename
// below, which answers ENOENT in the same words (and on a dirless
// store may well succeed), unless a slash asked for a directory a
// file source can never be.
if (targetErr !== null && STAT_REFUSALS.has(targetErr)) {
errors.push(`mv: cannot stat '${target.rawPath}': ${posixPhrase(targetErr)}`);
continue;
}
if (slashRefusesFile(target, targetExists, srcIsDir)) {
errors.push(moveError(src, target, posixPhrase(targetErr ?? 'ENOTDIR')));
continue;
}
// GNU lets -n, --update and -i decide before it weighs a type mismatch,
// and -u never holds a directory back (coreutils 9.7).
const gate = srcIsDir && policy.update === 'older' ? { ...policy, update: null } : policy;
if (!(await overwriteGate(gate, stat, src, target, errors)))
continue;
// A backup renames the target aside, whatever its kind.
const mismatch = !backupDisplaces(flags.backup)
? overwriteTypeError('mv', src, srcIsDir, target, targetExists, targetIsDir)
: null;
if (mismatch !== null) {
errors.push(mismatch);
continue;
}
if (created.has(keyOf(target)) &&
!(flags.noClobber || updateGates(flags.update) || flags.backup === 'numbered')) {
errors.push(justCreatedError('mv', src, target));
continue;
}
if (busy) {
errors.push(moveError(src, target, posixPhrase('EBUSY')));
continue;
}
if (mountRoot?.(target.virtual) === true) {
// A mount root stands on another filesystem than the directory holding
// it, so the rename crosses devices, and the root can be neither renamed
// aside nor removed (coreutils 9.7).
const busyPhrase = posixPhrase('EBUSY');
if (backupDisplaces(flags.backup)) {
errors.push(`mv: cannot backup '${target.rawPath}': ${busyPhrase}`);
}
else if (flags.noCopy) {
errors.push(moveError(src, target, posixPhrase('EXDEV')));
}
else {
errors.push(replaceError(src, target, busyPhrase, true));
}
continue;
}
const across = isPrimitiveMove(strategy);
// GNU refuses to replace a non-empty directory whether the target was
// named outright (-T) or mapped under an existing destination
// directory, so this cannot be gated on noTargetDir. It runs after the
// clobber gate because -n and --update=none skip such a target
// silently at exit 0, and a backup renames the target aside first, so
// -b installs over it instead. With --no-copy a move across mounts meets
// the other device first (all GNU 9.7).
if (srcIsDir &&
targetIsDir &&
versionReaddir !== undefined &&
!backupDisplaces(flags.backup) &&
!(across && flags.noCopy)) {
let children;
try {
children = await versionReaddir(target);
}
catch (err) {
if (!isFsError(err))
throw err;
// Reading it as "empty" would clobber a directory whose contents
// could not be verified.
errors.push(replaceError(src, target, strerrorOf(err), across));
continue;
}
if (children.length > 0) {
errors.push(replaceError(src, target, posixPhrase('ENOTEMPTY'), across));
continue;
}
}
// The reveal guard answers before the backup so a refused move
// cannot leave the destination renamed aside; only a directory
// source has anything below it to re-anchor, so a file passes.
if (guard !== undefined && srcIsDir) {
try {
guard(src, target);
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(moveError(src, target, strerrorOf(err)));
continue;
}
}
const backupStrategy = copies !== undefined
? { rename: (a, b) => renameLink(copies, a, b) }
: strategy;
if (across && flags.noCopy) {
// GNU renames the target aside before its rename meets the other
// device, and leaves it there (coreutils 9.7).
const made = await makeBackup(policy, backupStrategy, stat, versionReaddir, target, errors, copies);
if (made.ok)
errors.push(moveError(src, target, posixPhrase('EXDEV')));
continue;
}
const sourceLink = copies !== undefined && copies.links.statAt(src.virtual) !== null;
// Refuse an undeletable source before a backup moves the target aside.
if (isPrimitiveMove(strategy) && !sourceLink) {
try {
strategy.checkUnlink?.(src);
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(moveError(src, target, strerrorOf(err)));
continue;
}
}
// Across mounts the tree is copied file by file, so the source's mount is
// asked before a backup or a removal touches the destination.
if (isPrimitiveMove(strategy) && srcIsDir && !sourceLink) {
await beforeFullWalk(dispatch, src, 'mv');
}
// A cross-mount move removes the target first, and a pipe cannot be
// removed: GNU stops there, keeping the source (`mv f >(cat)`).
if (across &&
targetExists &&
!targetIsDir &&
!sourceLink &&
(await targetIsFifo(stat, target))) {
errors.push(replaceError(src, target, posixPhrase('EPERM'), across));
continue;
}
const made = await makeBackup(policy, backupStrategy, stat, versionReaddir, target, errors, copies);
if (!made.ok)
continue;
if (copies !== undefined && sourceLink) {
try {
await renameLink(copies, src, target);
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(moveError(src, target, strerrorOf(err)));
continue;
}
}
else if (isPrimitiveMove(strategy)) {
if (targetErr === 'ENOENT') {
// GNU's rename meets the absent parent before it falls back to a
// copy across devices.
errors.push(moveError(src, target, posixPhrase(targetErr)));
continue;
}
if (targetIsDir && made.backup === null) {
// GNU removes the target before it copies across devices, so an
// empty directory there is made anew (-v: `created directory`).
try {
await strategy.rmdir(target);
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(replaceError(src, target, strerrorOf(err), across));
continue;
}
}
// Across mounts GNU copies and then removes, and -v says so entry by
// entry instead of `renamed` (coreutils 9.7).
const verbose = flags.verbose ? lines : undefined;
const entries = await walk(strategy.readdir, stat, src);
const copiedAll = await copyEntries('mv', strategy, stat, src, target, entries, errors, {
copies,
lines: verbose,
rootBackup: made.backup,
});
// GNU keeps the whole source tree when any copy failed; the
// destination keeps the entries that landed.
if (!copiedAll)
continue;
const removedAll = await removeEntries(strategy, stat, src, entries, errors, verbose, mountRoot);
// GNU leaves the copied destination in place and reports the source
// entries it could not remove.
if (!removedAll)
continue;
if (!srcIsDir)
created.add(keyOf(target));
continue;
}
else {
try {
await strategy.rename(src, target);
}
catch (err) {
// A walk that moved what it listed found the source holding a child
// written since: as in GNU's move across devices, the source is
// what could not be removed.
if (err.code === 'ENOTEMPTY' &&
errorVirtualPath(err) === src.virtual) {
errors.push(`mv: cannot remove '${src.rawPath}': ${strerrorOf(err)}`);
continue;
}
if (err instanceof MoveError) {
errors.push(`mv: cannot remove '${src.rawPath}': ${strerrorOf(err.error)}`);
errors.push(`mv: rollback of '${target.rawPath}' incomplete: ${err.recoveryReason}; destination may remain`);
if (!srcIsDir && !err.recovered)
created.add(keyOf(target));
continue;
}
if (!isFsError(err))
throw err;
// A stale key inside a walk is named; other refusals, the operand.
const stale = isStaleWrite(err);
const inner = stale ? innerSuffix(src, err) || innerSuffix(target, err) : '';
const from = withInner(src.rawPath, inner);
if (isLandedMove(err)) {
// Report the source refusal even when compensation reverted the copy.
errors.push(`mv: cannot remove '${from}': ${strerrorOf(err)}`);
if (!srcIsDir && err.recovered !== true)
created.add(keyOf(target));
if (err.recovered === false) {
const reason = err.recoveryError ?? err.recoveryReason ?? 'cleanup incomplete';
errors.push(`mv: rollback of '${target.rawPath}' incomplete: ${reason}; destination may remain`);
}
continue;
}
// A backend rename that refuses (e.g. a destination whose parent
// chain is not all directories) is one failed operand, not an
// aborted command: GNU reports it and keeps going with the
// remaining sources.
const to = withInner(target.rawPath, inner);
// A refused write names the end that changed; its path is spelt as typed or resolved.
const lost = errorVirtualPath(err);
const atTarget = underPath(lost, target.virtual) || underPath(lost, virtualOf(target));
const changed = !stale ? '' : `'${atTarget ? to : from}' `;
errors.push(`mv: cannot move '${from}' to '${to}': ${changed}${strerrorOf(err)}`);
continue;
}
}
if (!srcIsDir)
created.add(keyOf(target));
if (flags.verbose) {
let line = `renamed '${src.rawPath}' -> '${target.rawPath}'`;
if (made.backup !== null)
line += ` (backup: '${made.backup.rawPath}')`;
lines.push(line);
}
}
const output = lines.length > 0 ? ENC.encode(lines.join('\n') + '\n') : null;
return [
output,
new IOResult({
stderr: stderrOf(errors),
exitCode: errors.length > accepted.length ? 1 : 0,
}),
];
}
/** Whether a move's existing target is a pipe (a process substitution's
* descriptor). Mirrors Python's _target_is_fifo. */
async function targetIsFifo(stat, target) {
try {
return isFifo(await stat(target));
}
catch (err) {
if (!isFsError(err))
throw err;
console.debug(`mv: target ${target.virtual} gave no stat: ${String(err)}`);
return false;
}
}
/**
* An mv line moves each source with everything under it, never through a
* link, into the directory `-t` names if it names one. Mirrors Python's
* `facts`.
*/
export function facts(fl, _operands) {
return new LineFacts({
follows: Follow.NONE,
subtree: true,
moveTarget: fl.asStr('target_directory') ?? null,
});
}
//# sourceMappingURL=mv.js.map