@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
1,476 lines • 67.5 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 { closeQuietly, primeStream } from "../../../io/stream.js";
import { respelled } from "../../../utils/key_prefix.js";
import { AsyncLineIterator } from "../../../io/async_line_iterator.js";
import { IOResult } from "../../../io/types.js";
import { FileType, LINK_TARGET_KEY, LineFacts, PathSpec, } from "../../../types.js";
import { UsageError } from "../../errors.js";
import { beforeFullWalk } from "../utils/tree.js";
import { wordRefusalError, extraOperandError, usageHint } from "../../spec/usage.js";
import { matchWord } from "../../spec/word_choice.js";
import { modifiedTs } from "../../../core/generic/find.js";
import { backupControl, backupTarget } from "../utils/backup.js";
import { DEFAULT_BACKUP_SUFFIX } from "../utils/constants.js";
import { STAT_REFUSALS, backendKeyDefault, copyTargets, isDirectory, pathExists, typedName, } from "../utils/copy.js";
import { posixPhrase } from "../../../errors/posix.js";
import { enoent, fsStrerror, isDotWalkError, isEacces, isRefusal, isEnotdir, isFsError, isMissingPath, } from "../../../errors/fs.js";
import { nameLocation, typedLink } from "../utils/links.js";
import { followedStat } from "../../../view/namespace_view.js";
import { absentDestError, descendantPath, entryKind, nearestAncestor, spelledFrom, } from "../utils/paths.js";
import { rstripSlash, stripSlash } from "../../../utils/slash.js";
import { compareCodePoints } from "../../../utils/sort.js";
import { CycleError, parent, resolvePath } from "../../../utils/path.js";
import { shellQuoteAlways } from "../../../utils/quote.js";
import { classify } from "../../../errors/classify.js";
const ENC = new TextEncoder();
const UPDATE_MODES = ['all', 'none', 'none-fail', 'older'];
const SPECIAL_FILE_NOUNS = new Map([
[FileType.FIFO, 'fifo'],
[FileType.CHAR_DEVICE, 'special file'],
[FileType.BLOCK_DEVICE, 'special file'],
[FileType.SOCKET, 'special file'],
]);
// Each option of cp's link policy, and what it asks for; the last typed wins
// (coreutils 9.7: `-L -P` copies the link, `-P -L` what it names).
const DEREF_OPTIONS = {
dereference: 'always',
no_dereference: 'never',
H: 'command_line',
d: 'never',
archive: 'never',
};
export function cpFlags(init = {}) {
return {
recursive: init.recursive ?? false,
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,
dereference: init.dereference ?? 'always',
};
}
/**
* GNU's -i: ask on stderr before replacing a target and read one line of
* stdin as the answer. Only a line starting with `y` or `Y` is yes (rpmatch
* in the C locale); the end of input is no. A yes is recorded in `accepted`,
* since it is no failure. Mirrors Python's prompter.
*/
export function prompter(cmdName, stdin, errors, accepted) {
const replies = stdin !== null ? new AsyncLineIterator(stdin) : null;
return async (target) => {
errors.push(`${cmdName}: overwrite ${shellQuoteAlways(target.rawPath)}? `);
const reply = replies !== null ? await replies.readline() : null;
const first = reply?.[0];
if (first !== 0x79 && first !== 0x59)
return false;
accepted.push(target.virtual);
return true;
};
}
// The collected messages as stderr: one per line, except that a question
// leaves the cursor after it, as a terminal prompt does. Mirrors Python's
// stderr_of.
export function stderrOf(errors) {
if (errors.length === 0)
return null;
return ENC.encode(errors.map((line) => (line.endsWith('? ') ? line : `${line}\n`)).join(''));
}
function isPrimitiveCopy(strategy) {
return 'read' in strategy;
}
// Whether an --update mode can skip or fail an individual entry. 'all'
// copies unconditionally, so it needs no per-entry decision and must not
// cost a target probe or forfeit a whole-tree dirCopy.
export function updateGates(mode) {
return mode !== null && mode !== 'all';
}
// Whether a backup control actually moves an existing target aside. 'none'
// is a no-op control, so it needs no per-entry decision.
export function backupDisplaces(control) {
return control !== null && control !== 'none';
}
// Resolve -u/--update[=UPDATE] to a GNU update mode.
export function updateMode(cmdName, fl) {
const value = fl.raw('update');
if (value === undefined || value === false)
return null;
if (value === true)
return 'older';
const shown = typeof value === 'string' ? value : '';
const match = matchWord(shown, UPDATE_MODES);
if (match.matched)
return match.word;
throw wordRefusalError(cmdName, '--update', shown, UPDATE_MODES, 1, match.kind);
}
// The --suffix value, an empty one reading as absent: GNU 9.7
// `cp --backup --suffix= f g` writes the default `g~`, not a backup whose
// name is the original's. Python's twin is cp.suffix_flag.
export function suffixFlag(fl) {
const value = fl.asStr('suffix');
return value === undefined || value === '' ? null : value;
}
// The raw -b/--backup value, absent shapes reading as undefined. The parser
// lands both spellings on the canonical `backup` dest, so the key already
// carries GNU's last-occurrence-wins value.
export function backupRaw(fl) {
const value = fl.raw('backup');
if (typeof value === 'string' || typeof value === 'boolean')
return value;
return undefined;
}
// -t arrives as the PathSpec of the word that spelled it. Mirrors Python.
export function targetFlags(cmdName, fl) {
const raw = fl.raw('target_directory');
const targetDir = raw instanceof PathSpec ? raw : null;
const noTarget = fl.asBool('no_target_directory');
if (targetDir !== null && noTarget) {
throw new UsageError(`${cmdName}: cannot combine --target-directory (-t) and --no-target-directory (-T)`, 1);
}
return [targetDir, noTarget];
}
// Parse the cp flag bag once into a frozen struct. -f/-i are accepted
// no-ops (non-interactive control plane: overwrite always proceeds unless
// -n/--update say otherwise), and --strip-trailing-slashes is a no-op
// because PathSpec already normalizes trailing slashes.
export function parseFlags(fl) {
const update = updateMode('cp', fl);
const suffix = suffixFlag(fl);
const control = backupControl('cp', backupRaw(fl), suffix);
// -i and -n set one answer, so the later of the two wins.
const asking = fl.typedOrder('interactive', 'no_clobber').at(-1);
const noClobber = asking === 'no_clobber';
if (control !== null && control !== 'none' && (noClobber || update === 'none-fail')) {
throw new UsageError(`cp: --backup is mutually exclusive with -n or --update=none-fail\n${usageHint('cp')}`, 1);
}
const [targetDir, noTargetDir] = targetFlags('cp', fl);
const recursive = fl.asBool('r') || fl.asBool('recursive') || fl.asBool('archive');
const last = fl.typedOrder(...Object.keys(DEREF_OPTIONS)).at(-1);
// With no link option a recursive copy copies links as links and any other
// copy follows them (GNU cp 9.7 with none of -H, -L, -P, -d).
const dereference = last !== undefined ? (DEREF_OPTIONS[last] ?? 'always') : recursive ? 'never' : 'always';
return cpFlags({
recursive,
noClobber,
interactive: asking === 'interactive',
verbose: fl.asBool('verbose'),
update,
backup: control,
suffix: suffix ?? DEFAULT_BACKUP_SUFFIX,
targetDir,
noTargetDir,
dereference,
});
}
// What stands at a path, asked through the dispatcher; null where nothing does,
// which is where a new link goes. Mirrors Python's _entry_at.
async function entryAt(dispatch, spec) {
try {
const [there] = await dispatch('stat', spec);
return there !== null && typeof there === 'object' && 'type' in there
? there
: null;
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err) || err.code === 'ELOOP')
return null;
throw err;
}
}
/** Stat the entry itself for overwrite and backup decisions. */
export async function linkStat(copies, path) {
return copies.links.statAt(path.virtual) ?? (await copies.relayStat(path));
}
export async function renameLink(copies, src, target) {
await copies.dispatch('rename', src, [target]);
}
/**
* stat(2) of a destination, over a table whose stat is lstat(2). cp opens its
* destination the way open(2) does, through a link at the name: a link to a
* directory is that directory, one to a file is the file it writes through, and
* one that leads nowhere is no destination at all (cp then refuses to write
* through it). Mirrors Python's dest_stat.
*/
export function destStat(stat, copies) {
return async (path) => {
const row = await followedStat(stat, copies?.links, path);
if (row.type === FileType.SYMLINK)
throw enoent(path);
return row;
};
}
/**
* The destination as the link standing at its name, null where none does. The
* router follows a destination to what its link leads to, which is what a plain
* copy writes, through the link. GNU's backup renames the destination itself, a
* link included, and the copy then lands at the name, so a backup is made of
* this and the copy goes here: through the mount's own copy, which keeps the
* source's mode, or through the dispatcher where the name sits on another mount
* (`owns`). Mirrors Python's link_name.
*/
export function linkName(copies, target) {
const where = nameLocation(copies.links, target, copies.cwd);
if (where === null || copies.links.statAt(where) === null)
return null;
if (where === target.virtual)
return target;
return new PathSpec({
virtual: where,
directory: where.slice(0, where.lastIndexOf('/') + 1) || '/',
vfsPath: stripSlash(where),
rawPath: target.rawPath,
});
}
/** Copy a symlink through the shared overwrite and backup policy. */
export async function makeLink(copies, src, target, text, policy, errors, lines) {
const stat = (path) => linkStat(copies, path);
const targetLink = copies.links.statAt(target.virtual);
const there = targetLink ?? (await entryAt(copies.dispatch, target));
if (there?.type === FileType.DIRECTORY) {
errors.push(`${policy.cmdName}: cannot overwrite directory '${target.rawPath}' with non-directory`);
return false;
}
if (!(await overwriteGate(policy, stat, src, target, errors)))
return false;
const made = await makeBackup(policy, targetLink === null ? copies.relay : { rename: (a, b) => renameLink(copies, a, b) }, stat, copies.relay.readdir, target, errors, copies);
if (!made.ok)
return false;
lines?.push(transferLine(src, target, made.backup, policy.cmdName));
try {
if (await pathExists(stat, target))
await copies.dispatch('unlink', target);
await copies.dispatch('symlink', target, [], { target: text });
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(`${policy.cmdName}: cannot create symbolic link '${target.rawPath}': ${String(fsStrerror(err))}`);
return false;
}
return true;
}
/**
* Recreate the links below a copied directory, which its copy could not see.
* Without -L each lands as a link with its target verbatim, dangling and
* looping ones included. Under -L each is what it leads to: a file's bytes, a
* directory's whole tree (the links below it included), and `cannot stat` for
* one that leads nowhere or loops (coreutils 9.7). A link that leads back into
* a tree being copied is refused as GNU names it, `cannot copy cyclic symbolic
* link`, rather than copied until the name is too long, which is where GNU
* stops. Mirrors Python's copy_tree_links.
*/
export async function copyTreeLinks(copies, deref, src, target, errors, lines, policy, seen = [], blocked = []) {
const base = rstripSlash(src.virtual) || '/';
const dstBase = rstripSlash(target.virtual);
const shownSrc = rstripSlash(src.rawPath) || src.rawPath;
const shownDst = rstripSlash(target.rawPath) || target.rawPath;
const below = [...copies.links.subtree(base)].sort((a, b) => compareCodePoints(a[0], b[0]));
for (const [virtual, row] of below) {
// A source directory whose tree the copy left out keeps its links out too.
if (blocked.some((root) => within(virtual, root)))
continue;
const rel = virtual.slice(rstripSlash(base).length + 1);
const landing = `${dstBase}/${rel}`;
const shown = `${shownSrc}/${rel}`;
if (deref !== 'always') {
const raw = row.extra[LINK_TARGET_KEY];
const text = typeof raw === 'string' ? raw : '';
await makeLink(copies, respelled(PathSpec.fromStrPath(virtual), shown), respelled(PathSpec.fromStrPath(landing), `${shownDst}/${rel}`), text, policy, errors, lines);
continue;
}
let resolved;
try {
resolved = copies.links.resolve(virtual);
}
catch (err) {
if (!(err instanceof CycleError))
throw err;
errors.push(`cp: cannot stat '${shown}': ${posixPhrase('ELOOP')}`);
continue;
}
const leads = await copies.links.targetStat(virtual);
if (leads === null) {
errors.push(`cp: cannot stat '${shown}': ${posixPhrase('ENOENT')}`);
continue;
}
const placed = respelled(PathSpec.fromStrPath(landing), `${shownDst}/${rel}`);
if (leads.type !== FileType.DIRECTORY) {
const entry = respelled(PathSpec.fromStrPath(resolved), shown);
await copyEntries('cp', copies.relay, copies.relayStat, entry, placed, [{ path: entry.virtual, kind: leads.type }], errors, { policy, lines, copies });
continue;
}
const inside = rstripSlash(resolved) || '/';
if ([...seen, base].some((d) => d === inside || d.startsWith(`${rstripSlash(inside)}/`))) {
errors.push(`cp: cannot copy cyclic symbolic link '${shown}'`);
continue;
}
const followed = respelled(PathSpec.fromStrPath(inside), shown);
const entries = await walk(copies.relay.readdir, copies.relayStat, followed, 'cp', errors, copies.links);
const roots = [];
await copyEntries('cp', copies.relay, copies.relayStat, followed, placed, entries, errors, {
policy,
...(lines !== undefined ? { lines } : {}),
copies,
blocked: roots,
});
await copyTreeLinks(copies, deref, followed, placed, errors, lines, policy, [...seen, base], roots);
}
}
// Split operands into sources and destination, GNU arity errors. With -t
// every operand is a source and the target directory is the destination.
// -T requires exactly two operands.
export function splitOperands(cmdName, paths, targetDir, noTargetDir) {
const hint = usageHint(cmdName);
const first = paths[0];
if (first === undefined) {
throw new UsageError(`${cmdName}: missing file operand\n${hint}`, 1);
}
if (targetDir !== null)
return [[...paths], targetDir];
if (paths.length === 1) {
throw new UsageError(`${cmdName}: missing destination file operand after '${first.rawPath}'\n${hint}`, 1);
}
if (noTargetDir && paths.length > 2) {
throw extraOperandError(cmdName, paths[2]?.rawPath ?? '');
}
return [paths.slice(0, -1), paths[paths.length - 1] ?? first];
}
// The error line when a -t operand is missing or not a directory.
export async function targetDirError(cmdName, stat, target) {
let condition;
try {
const info = await stat(target);
if (info.type === FileType.DIRECTORY)
return null;
condition = 'ENOTDIR';
}
catch (err) {
if (isEnotdir(err))
condition = 'ENOTDIR';
else if (err.code === 'ELOOP')
condition = 'ELOOP';
else if (isRefusal(err))
condition = 'EACCES';
else if (isMissingPath(err))
condition = 'ENOENT';
else
throw err;
}
return `${cmdName}: target directory '${target.rawPath}': ${posixPhrase(condition)}`;
}
// Probe a destination for {exists, isDir, condition}. cp and mv are not
// `mkdir -p`: neither creates the destination's parent, so a missing or
// non-directory component is a per-operand failure, and GNU surfaces the two
// at different phases. A non-directory fails the destination stat itself:
// `reg/x` at any depth, and `reg/` typed with a slash over a plain file, are
// both "cannot stat 'DST': Not a directory". A merely absent parent fails the
// create or the rename ("cannot create regular file" for cp, "cannot move"
// for mv), so the condition comes back bare and each caller words it in its
// own voice. null means the destination exists or its parent is a usable
// directory.
//
// The backends answer ENOENT for a path under a plain file just as they do
// for a genuinely absent one (only a slashed operand makes the stat itself
// say ENOTDIR), so the chain is walked upward until something exists
// (absentDestError); the common case (the parent is there) costs a
// single stat.
export async function destKind(stat, target) {
let info = null;
try {
info = await stat(target);
}
catch (err) {
const code = err.code;
if (code === 'ENOTDIR')
return { exists: false, isDir: false, condition: 'ENOTDIR' };
if (code === 'ELOOP')
return { exists: false, isDir: false, condition: 'ELOOP' };
// Its `..` passes a name that is not there: the chain of the path it
// simplifies to says nothing about this one.
if (isDotWalkError(err))
return { exists: false, isDir: false, condition: 'ENOENT' };
if (isRefusal(err))
return { exists: false, isDir: false, condition: 'EACCES' };
if (!isMissingPath(err))
throw err;
}
if (info !== null)
return { exists: true, isDir: info.type === FileType.DIRECTORY, condition: null };
try {
return { exists: false, isDir: false, condition: await absentDestError(stat, target) };
}
catch (err) {
if (!isRefusal(err))
throw err;
return { exists: false, isDir: false, condition: 'EACCES' };
}
}
// Whether a slash-terminated destination refuses a non-directory. POSIX
// resolves `missing/` as `missing/.`, so the name may only ever be a
// directory: rename(2) and open(2) refuse to put a file there with ENOTDIR
// where a bare `missing` would take it. GNU 9.7 words it at the create
// ("mv: cannot move 'f' to 'missing/': Not a directory", "cp: cannot create
// regular file 'missing/': Not a directory"); a directory source passes,
// since the slash asked for exactly what it is. An existing destination
// never reaches this: a directory receives the move inside it, and a
// non-directory has already failed the stat.
export function slashRefusesFile(target, targetExists, srcIsDir) {
return !targetExists && target.rawPath.endsWith('/') && !srcIsDir;
}
// Probe a source operand, keeping the errno the kernel reports: `cp /plain/child /dst`
// is `cannot stat 'X': Not a directory`, not "No such file or directory". The
// backends cannot supply that distinction, because stat answers ENOENT for a
// path under a plain file just as it does for a genuinely absent one (only
// readdir splits the two). So the chain is walked the way destKind walks
// a destination's: the first component that does exist decides, and a plain
// file there means ENOTDIR. Walking happens only on the failure path.
export async function sourceKind(stat, path) {
let info = null;
try {
info = await stat(path);
}
catch (err) {
const code = err.code;
if (code === 'ENOTDIR')
return { kind: null, condition: 'ENOTDIR' };
if (code === 'ELOOP')
return { kind: null, condition: 'ELOOP' };
if (isRefusal(err))
return { kind: null, condition: 'EACCES' };
if (!isMissingPath(err))
throw err;
}
if (info !== null)
return { kind: info.type, condition: null };
let isDir;
try {
;
[, isDir] = await nearestAncestor(stat, path);
}
catch (err) {
if (!isRefusal(err))
throw err;
return { kind: null, condition: 'EACCES' };
}
return { kind: null, condition: isDir ? 'ENOENT' : 'ENOTDIR' };
}
// GNU's line for a special file that a recursive copy cannot make, null for a
// kind copied as bytes. Under -R GNU recreates a FIFO or a device instead of
// reading it. No mount can store one, so the create fails as mknod would,
// with ENOTSUP, and the source is never read: a live pipe keeps its bytes.
// Mirrors Python's `_special_file_error`.
function specialFileError(cmdName, kind, dst) {
const noun = kind === null ? undefined : SPECIAL_FILE_NOUNS.get(kind);
if (noun === undefined)
return null;
return `${cmdName}: cannot create ${noun} '${dst.rawPath}': ${posixPhrase('ENOTSUP')}`;
}
// GNU dir/non-dir overwrite mismatch line, or null when compatible.
export function overwriteTypeError(cmdName, src, srcIsDir, target, targetExists, targetIsDir) {
if (!targetExists || srcIsDir === targetIsDir)
return null;
return typeMismatchError(cmdName, src, target, srcIsDir);
}
// GNU's line for a directory and a non-directory meeting at one name.
export function typeMismatchError(cmdName, src, target, srcIsDir) {
if (srcIsDir) {
return `${cmdName}: cannot overwrite non-directory '${target.rawPath}' with directory '${src.rawPath}'`;
}
return `${cmdName}: cannot overwrite directory '${target.rawPath}' with non-directory '${src.rawPath}'`;
}
// GNU's line for a source that is its own destination.
export function sameFileError(cmdName, src, target) {
return `${cmdName}: '${src.rawPath}' and '${target.rawPath}' are the same file`;
}
// GNU's line for a destination this line already wrote.
export function justCreatedError(cmdName, src, target) {
return `${cmdName}: will not overwrite just-created '${target.rawPath}' with '${src.rawPath}'`;
}
// Whether a directory stands where a file is about to be copied. A probe the
// backend refuses answers false, so the create that follows reports the
// refusal in its own words, as it did before the probe. Mirrors Python's
// _holds_directory.
async function holdsDirectory(stat, path) {
try {
return await isDirectory(stat, path);
}
catch (err) {
if (!isFsError(err))
throw err;
console.debug(`cp: destination probe of ${path.virtual} failed: ${String(err)}`);
return false;
}
}
// GNU's line for a file a directory stands in the way of, met once the
// overwrite gates let the file through (-n and -u skip it, -i asks first).
// Under a backup cp tries the create, which the directory refuses; otherwise
// it refuses the overwrite itself (coreutils 9.7). Mirrors Python's
// _onto_directory.
function ontoDirectory(cmdName, src, target, backup) {
if (backupDisplaces(backup)) {
return `${cmdName}: cannot create regular file '${target.rawPath}': ${posixPhrase('EISDIR')}`;
}
return typeMismatchError(cmdName, src, target, false);
}
// Free a non-directory's name for a directory copied onto it. GNU refuses the
// overwrite unless a backup moves the entry aside, and then renames it to its
// backup name. A source typed as `.` or `..` is never backed up, so the entry
// is removed instead, which -v reports. No gate or -i question applies to a
// directory source (coreutils 9.7). Outside a workspace nothing can rename
// the entry, so the overwrite is refused as it is without a backup. A link at
// the name is the entry moved or removed, not what it leads to, and the tree
// then lands at its name (linkName). Returns the name freed for the
// directory, null when it stays taken. Mirrors Python's _clear_for_directory.
async function clearForDirectory(policy, copies, stat, src, dest, errors, lines) {
if (copies === undefined || !backupDisplaces(policy.backup)) {
errors.push(typeMismatchError(policy.cmdName, src, dest, true));
return null;
}
const target = linkName(copies, dest) ?? dest;
const typed = typedName(src);
if (typed === '.' || typed === '..') {
try {
await copies.dispatch('unlink', target);
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(`${policy.cmdName}: cannot remove '${target.rawPath}': ${String(fsStrerror(err))}`);
return null;
}
lines?.push(`removed '${target.rawPath}'`);
return target;
}
const made = await makeBackup(policy, { rename: (a, b) => renameLink(copies, a, b) }, stat, undefined, target, errors, copies);
return made.ok ? target : null;
}
// Decide whether an existing target may be replaced. -n and --update=none
// skip silently; --update=none-fail records GNU's `not replacing` error;
// --update=older replaces only when the source is strictly newer. A source
// or target with no usable mtime always replaces (freshness cannot be
// proven). A copy that writes through a link at the target's name compares
// the source with what the link leads to; one whose backup moves the link
// aside compares with the link itself (GNU cp 9.7). `copies` is given for a
// copy that writes through such a link, and left out for one that renames
// onto it or copies a link as a link.
export async function overwriteGate(policy, stat, src, target, errors, copies) {
// No gating flag: skip the target probe entirely so API-backed mounts
// pay no extra stat per entry.
const ask = policy.ask ?? null;
if (!policy.noClobber && !updateGates(policy.update) && ask === null)
return true;
let targetInfo;
try {
targetInfo = await stat(target);
}
catch (err) {
// A probe failure here is not permission to clobber: returning true on an
// auth error or timeout would silently defeat -n / --update=none.
if (!isMissingPath(err) && !isEnotdir(err))
throw err;
return true;
}
if (policy.noClobber || policy.update === 'none')
return false;
if (policy.update === 'none-fail') {
errors.push(`${policy.cmdName}: not replacing '${target.rawPath}'`);
return false;
}
if (policy.update === 'older') {
let srcInfo;
try {
srcInfo = await stat(src);
}
catch (err) {
if (!isMissingPath(err) && !isEnotdir(err))
throw err;
return true;
}
if (copies !== undefined && policy.backup === null) {
try {
targetInfo = await destStat(stat, copies)(target);
}
catch (err) {
if (isMissingPath(err) || isEnotdir(err))
return true;
if (!isFsError(err))
throw err;
// GNU names a link whose target its stat may not reach and goes on
// with the next entry (coreutils 9.7).
errors.push(`${policy.cmdName}: cannot stat '${target.rawPath}': ${fsStrerror(err) ?? String(err)}`);
return false;
}
}
const srcTs = modifiedTs(srcInfo.modified);
const targetTs = modifiedTs(targetInfo.modified);
if (srcTs !== null && targetTs !== null && srcTs <= targetTs)
return false;
}
// -i asks last, once the target survived the update checks.
return ask !== null ? ask(target) : true;
}
// Materialize the backup: mv renames the target away, cp copies it. A
// directory target needs a tree transfer, not a byte copy: the primitive
// (cross-mount) strategies walk it entry by entry and a native copy defers to
// dirCopy, while a native rename already carries a whole subtree. Returns
// true when the backup landed in full.
async function duplicateForBackup(strategy, stat, target, backup, errors, cmdName) {
if ('rename' in strategy) {
await strategy.rename(target, backup);
return true;
}
const targetIsDir = await isDirectory(stat, target);
if ('read' in strategy) {
if (!targetIsDir) {
const data = await primeStream(strategy.read(target));
try {
await strategy.write(backup, data);
}
finally {
await closeQuietly(data);
}
return true;
}
const entries = await walk(strategy.readdir, stat, target);
const copiedAll = await copyEntries(cmdName, strategy, stat, target, backup, entries, errors);
return copiedAll;
}
if (!targetIsDir) {
await strategy.copy(target, backup);
return true;
}
if (strategy.dirCopy === undefined) {
errors.push(`${cmdName}: cannot backup '${target.rawPath}': ${posixPhrase('ENOTSUP')}`);
return false;
}
await strategy.dirCopy(target, backup);
return true;
}
async function restoreBackupLink(copies, backup, link, cmdName, errors) {
try {
if (await pathExists((path) => linkStat(copies, path), backup)) {
await copies.dispatch('unlink', backup);
}
const raw = link.extra[LINK_TARGET_KEY];
await copies.dispatch('symlink', backup, [], { target: typeof raw === 'string' ? raw : '' });
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(`${cmdName}: cannot restore backup '${backup.rawPath}': ${String(fsStrerror(err))}`);
}
}
// Back up an existing target before it is overwritten. Returns the backup
// path (null when no backup was needed) and whether the transfer may
// proceed.
export async function makeBackup(policy, strategy, stat, readdir, target, errors, copies) {
if (policy.backup === null)
return { backup: null, ok: true };
if (!(await pathExists(stat, target)))
return { backup: null, ok: true };
let backup;
try {
// A failed version scan must not degrade to `.~1~`/the simple suffix:
// that would overwrite existing backup history.
backup = await backupTarget(copies?.relay.readdir ?? readdir, target, policy.backup, policy.suffix);
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(`${policy.cmdName}: cannot backup '${target.rawPath}': ${String(fsStrerror(err))}`);
return { backup: null, ok: false };
}
if (backup === null)
return { backup: null, ok: true };
const backupLink = copies !== undefined && !('rename' in strategy) ? copies.links.statAt(backup.virtual) : null;
let removedLink = false;
let made = false;
try {
if (copies !== undefined && backupLink !== null) {
await copies.dispatch('unlink', backup);
removedLink = true;
}
made = await duplicateForBackup(strategy, stat, target, backup, errors, policy.cmdName);
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(`${policy.cmdName}: cannot backup '${target.rawPath}': ${String(fsStrerror(err))}`);
return { backup: null, ok: false };
}
finally {
if (removedLink && !made && copies !== undefined && backupLink !== null) {
await restoreBackupLink(copies, backup, backupLink, policy.cmdName, errors);
}
}
if (!made)
return { backup: null, ok: false };
return { backup, ok: true };
}
// The verbose line for one copied entry, with GNU's backup note. GNU prints
// it before the create, so a create that fails still shows it; mv words a
// copy across mounts `copied`. Mirrors Python's transfer_line.
function transferLine(src, target, backup, cmdName = 'cp') {
let line = `'${src.rawPath}' -> '${target.rawPath}'`;
if (backup !== null)
line += ` (backup: '${backup.rawPath}')`;
return cmdName === 'mv' ? `copied ${line}` : line;
}
/**
* GNU's verdict on a source this cp already began to copy, as the stderr line
* and whether it is only a warning, or null to copy it. Copied again to the
* same place, it is a warning and is skipped. A directory meeting a second
* place would be a hard link to a directory, which cp refuses unless -L or -H
* followed it there; a file is copied again (coreutils 9.7). A file under a
* backup that moves the first copy aside goes on to the just-created rule.
* Mirrors Python's _repeated_source.
*/
function repeatedSource(earlier, src, target, srcIsDir, flags, keyOf) {
if (earlier === undefined)
return null;
if (keyOf(earlier) === keyOf(target)) {
if (!srcIsDir && backupDisplaces(flags.backup))
return null;
const noun = srcIsDir ? 'directory' : 'file';
return [`cp: warning: source ${noun} '${src.rawPath}' specified more than once`, true];
}
if (srcIsDir && flags.dereference === 'never') {
return [
`cp: will not create hard link '${target.rawPath}' to directory '${earlier.rawPath}'`,
false,
];
}
return null;
}
// GNU -v lines for a tree about to be copied natively, parents first. GNU
// `cp -rv` reports every file and every directory it creates, including the
// source root itself; a directory already at the destination is merged into
// without a line. Read before the copy, so the destination still shows which
// directories exist. Deliberate divergence: GNU's sibling order follows
// readdir, which no backend can reproduce, so entries are sorted
// lexicographically instead. That keeps every parent ahead of its children
// (GNU's only load-bearing ordering guarantee) and is stable across backends.
async function treeLines(strategy, stat, src, target, srcBase, dstBase) {
const dirs = new Set([srcBase, ...(await strategy.find(src, { type: 'd' }))]);
const files = await strategy.find(src, { type: 'f' });
const unique = [...new Set([...dirs, ...files])].sort(compareCodePoints);
const lines = [];
for (const entryMount of unique) {
const entry = spelledFrom(PathSpec.fromStrPath(entryMount), src);
const entryDst = spelledFrom(PathSpec.fromStrPath(dstBase + entryMount.slice(srcBase.length)), target);
if (dirs.has(entryMount) && (await isDirectory(stat, entryDst)))
continue;
lines.push(`'${entry.rawPath}' -> '${entryDst.rawPath}'`);
}
return lines;
}
// Copy one entry natively, or by bytes where a link leads elsewhere. The
// dispatcher follows a link standing at the destination name, so a native
// copy whose link leads onto another mount answers EXDEV; that entry goes
// through the relay's read and write instead, which follow the link wherever
// it lands. Every other entry keeps the backend's copy (a Drive document
// stays a document). Mirrors Python's _copy_entry.
async function copyEntry(strategy, copies, src, dst) {
try {
await strategy.copy(src, dst);
}
catch (err) {
if (copies === undefined ||
(err.code !== 'EXDEV' &&
err.declined !== true))
throw err;
const data = await primeStream(copies.relay.read(src));
try {
await copies.relay.write(dst, data);
}
finally {
await closeQuietly(data);
}
}
}
// Whether `path` is `root` or below it. Mirrors Python's within.
export function within(path, root) {
const base = rstripSlash(root);
return rstripSlash(path) === base || path.startsWith(`${base}/`);
}
// Recreate a source tree's directories under the destination root, for the
// per-entry path. A directory that cannot be made, or whose name a
// non-directory holds that no backup moves aside, is GNU's line and its tree
// is left out; its siblings still copy, as GNU's walk goes on (coreutils
// 9.7). Returns the source directories whose trees the file pass leaves out,
// and the destination directories this made, whose entries cannot be taken
// yet (null where the backend has no directories). Mirrors Python's
// _mirror_dirs.
async function mirrorDirs(strategy, stat, src, target, srcBase, dstBase, errors, policy, copies, intoItself, lines,
// Collects the source directories the walk may not open, which are still
// made at the destination; absent lets the refusal propagate.
unreadable) {
if (strategy.mkdir === undefined)
return { blocked: [], made: null };
// A destination inside the source leaves its own subtree out, as the
// file pass does.
const listed = await strategy.find(src, {
type: 'd',
...(unreadable === undefined ? {} : { unreadable }),
});
const mounts = [srcBase, ...listed].filter((found) => !(intoItself && within(found, dstBase)));
// Shortest first so a parent is created before its children, then by name:
// sorting on length alone leaves equal-length siblings in whatever order
// the Set happened to hold, which is insertion order here and hash order in
// Python. Same key on both sides, same output.
const unique = [...new Set(mounts)].sort((a, b) => a.length - b.length || compareCodePoints(a, b));
const blocked = [];
const made = new Set();
for (const entryMount of unique) {
if (blocked.some((root) => within(entryMount, root)))
continue;
const dstMount = dstBase + entryMount.slice(srcBase.length);
const entryDst = spelledFrom(PathSpec.fromStrPath(dstMount), target);
const entry = spelledFrom(PathSpec.fromStrPath(entryMount), src);
try {
// A directory meets a link at its name as the link itself, where a file
// writes through it (GNU's lstat for a directory source, coreutils 9.7).
const { exists, isDir } = await entryKind(stat, entryDst);
if (isDir)
continue;
if (!exists ||
(await clearForDirectory(policy, copies, stat, entry, entryDst, errors, lines)) !== null) {
await strategy.mkdir(entryDst);
made.add(dstMount);
if (lines !== undefined)
lines.push(`'${entry.rawPath}' -> '${entryDst.rawPath}'`);
continue;
}
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(`cp: cannot create directory '${entryDst.rawPath}': ${String(fsStrerror(err))}`);
}
blocked.push(entryMount);
}
return { blocked, made };
}
// List a tree as {path, kind} pairs, parents before children. The kind is
// captured while the tree is intact so a caller that deletes as it goes (mv)
// never re-stats a path whose virtual parent has since vanished, and a copy
// knows a FIFO from a file without opening it. Mirrors the Python cp `walk`;
// used only by the primitive (no native copy) path. A folder a backend lists
// with a trailing slash (box, dropbox, gdrive) is walked without it. A
// directory the session may not open, or an entry it may not stat (a rule
// refused it below the operand), is GNU's `cannot access` / `cannot stat`
// line when `errors` is given and the walk goes on without its contents;
// with no channel the refusal propagates rather than leave a silent gap.
export async function walk(readdir, stat, root, cmdName = 'cp', errors, links) {
const info = await stat(root);
if (info.type !== FileType.DIRECTORY)
return [{ path: root.virtual, kind: info.type }];
const entries = [
{ path: root.virtual, kind: FileType.DIRECTORY },
];
const queue = [root];
while (queue.length > 0) {
const directory = queue.shift();
if (directory === undefined)
break;
let children;
try {
children = await readdir(directory);
}
catch (err) {
if (errors === undefined || !isEacces(err))
throw err;
errors.push(`${cmdName}: cannot access '${directory.rawPath}': ${String(fsStrerror(err))}`);
continue;
}
for (const listed of children) {
const child = rstripSlash(listed);
const childSpec = descendantPath(root, child);
if (links?.statAt(childSpec.virtual) != null)
continue;
let childInfo;
try {
childInfo = await stat(childSpec);
}
catch (err) {
if (errors === undefined || !isEacces(err))
throw err;
errors.push(`${cmdName}: cannot stat '${childSpec.rawPath}': ${String(fsStrerror(err))}`);
continue;
}
entries.push({ path: child, kind: childInfo.type });
if (childInfo.type === FileType.DIRECTORY)
queue.push(childSpec);
}
}
return entries;
}
// Copy a walked source tree entry by entry with GNU per-entry errors: the
// shared primitive-transfer loop of cp and mv. A directory that cannot be
// made, or whose name a non-directory holds that no backup moves aside,
// leaves its own tree out; a failed read or write is reported; either way the
// remaining entries still copy, like GNU cp/mv on a cross-device transfer.
// Every error line carries fsStrerror, so a backend missing the needed op
// reports `Operation not supported` instead of aborting the command. A FIFO
// or device is GNU's failed recreate (specialFileError), never read. `policy`
// applies -n/--update/--backup per file entry, like GNU during a recursive
// merge (null overwrites unconditionally); `lines` is an optional per-entry
// sink, a file's line written before its create; `rootBackup` is the backup
// the caller made of the target, which the source's own line notes. Returns
// whether every entry landed.
export async function copyEntries(cmdName, strategy, stat, src, target, entries, errors, opts = {}) {
const srcBase = rstripSlash(src.virtual);
const dstBase = rstripSlash(target.virtual);
let copiedAll = true;
const blocked = opts.blocked ?? [];
const made = new Set();
const entryPolicy = opts.policy ?? {
cmdName,
noClobber: false,
update: null,
backup: null,
suffix: DEFAULT_BACKUP_SUFFIX,
};
for (const { path: entry, kind } of entries) {
if (blocked.some((root) => within(entry, root)))
continue;
const entrySpec = descendantPath(src, entry);
const entryDstSpec = descendantPath(target, dstBase + entry.slice(srcBase.length));
if (kind === FileType.DIRECTORY) {
try {
const { exists, isDir } = await entryKind(stat, entryDstSpec);
if (isDir)
continue;
const free = !exists ||
(await clearForDirectory(entryPolicy, opts.copies, stat, entrySpec, entryDstSpec, errors, opts.lines)) !== null;
if (free) {
await strategy.mkdir(entryDstSpec);
made.add(entryDstSpec.virtual);
opts.lines?.push(cmdName === 'mv'
? `created directory '${entryDstSpec.rawPath}'`
: `'${entrySpec.rawPath}' -> '${entryDstSpec.rawPath}'`);
continue;
}
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(`${cmdName}: cannot create directory '${entryDstSpec.rawPath}': ${String(fsStrerror(err))}`);
}
copiedAll = false;
blocked.push(entry);
continue;
}
const link = opts.copies?.links.statAt(entry);
if (opts.copies !== undefined && link != null) {
const errorCount = errors.length;
const raw = link.extra[LINK_TARGET_KEY];
await makeLink(opts.copies, entrySpec, entryDstSpec, typeof raw === 'string' ? raw : '', entryPolicy, errors, opts.lines);
if (errors.length > errorCount)
copiedAll = false;
continue;
}
if (opts.policy !== undefined &&
!(await overwriteGate(opts.policy, stat, entrySpec, entryDstSpec, errors, 'unlink' in strategy ? undefined : opts.copies)))
continue;
if (entry !== src.virtual &&
!made.has(parent(entryDstSpec.virtual)) &&
(await holdsDirectory(stat, entryDstSpec))) {
errors.push(ontoDirectory(cmdName, entrySpec, entryDstSpec, opts.policy?.backup ?? null));
copiedAll = false;
continue;
}
const refusal = specialFileError(cmdName, kind, entryDstSpec);
if (refusal !== null) {
opts.lines?.push(transferLine(entrySpec, entryDstSpec, null, cmdName));
errors.push(refusal);
copiedAll = false;
continue;
}
let backup = entry === src.virtual ? (opts.rootBackup ?? null) : null;
if (opts.policy !== undefined) {
const made = await makeBackup(opts.policy, strategy, stat, strategy.readdir, entryDstSpec, errors, opts.copies);
if (!made.ok) {
copiedAll = false;
continue;
}
backup = made.backup;
}
opts.lines?.push(transferLine(entrySpec, entryDstSpec, backup, cmdName));
if (!(await transferFile(strategy, entrySpec, entryDstSpec, cmdName, errors))) {
copiedAll = false;
}
}
return copiedAll;
}
/**
* GNU's line for a file copy that failed: the source's open when the error
* names the source, else the destination's create. Mirrors Python's
* copy_failed.
*/
function copyFailed(cmdName, src, dst, err) {
if (err.virtualPath === src.virtual) {
return `${cmdName}: cannot open '${src.rawPath}' for reading: ${String(fsStrerror(err))}`;
}
return `${cmdName}: cannot create regular file '${dst.rawPath}': ${String(fsStrerror(err))}`;
}
/**
* Copy one file between the strategy's mounts, reporting a failure in GNU's
* words; false when it failed. A store-to-store copy goes first where the
* strategy has one: the destination's store copies from the source's without
* the bytes passing through mirage. EXDEV from it says the stores cannot, as
* ENOTSUP does from a store with no copy of its own, and a declined walk says
* a hide or rule wants the copy made by parts; either way
* the bytes move through a read and a write, the file materialized in
* between. Mirrors Python's transfer_file.
*/
export async function transferFile(strategy, src, dst, cmdName, errors) {
if (strategy.copy !== undefined) {
try {
await strategy.copy(src, dst);
return true;
}
catch (err) {
const cannot = classify(err) === 'EXDEV' || classify(err) === 'ENOTSUP';
if (err.declined !== true && !cannot) {
if (!isFsError(err))
throw err;
errors.push(copyFailed(cmdName, src, dst, err));
return false;
}
}
}
let data;
const reading = { failed: false };
try {
data = await primeStream(strategy.read(src), () => {
reading.failed = true;
});
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(`${cmdName}: cannot open '${src.rawPath}' for reading: ${String(fsStrerror(err))}`);
return false;
}
try {
await strategy.write(dst, data);
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(reading.failed
? `${cmdName}: error reading '${src.rawPath}': ${String(fsStrerror(err))}`
: `${cmdName}: cannot create regular file '${dst.rawPath}': ${String(fsStrerror(err))}`);
return false;
}
finally {
await closeQuietly(data);
}
return true;
}
// Copy sources to a destination, fanning out into a directory. NativeCopy
// uses backend copy/find operations for an efficient same-store copy.
// PrimitiveCopy handles cross-mount copies by walking via readdir/stat and
// applying mkdir or write(read(...)) to each entry. --update/--backup
// force the per-entry native loop (a whole-tree dirCopy cannot honor
// per-file decisions).
export async function cpGeneric(paths, stat, strategy, flags, backendKey, readdir,
// The link standing at the name a destination was typed as, its own row,
// null where none stands (the router has followed the operand by the time
// cp runs); undefined outside a workspace. Mirrors Python's link_at.
linkAt,
// The namespace's links and the dispatcher that makes them, so a link is copied
// as a link where the policy says to; undefined outside a workspace, where
// no link can stand.
copies,
// Where -i reads its answers.
stdin,
// The command's dispatcher, which lets a source's mount refuse a tree
// copied file by file; undefined outside a workspace.
dispatch) {
const keyOf = backendKey ?? backendKeyDefault;
const [sources, dst] = splitOperands('cp', paths, flags.targetDir, flags.noTargetDir);
const toDest = destStat(stat, copies);
let dstIsDir;
let dstExists;
let dstErr = null;
if (flags.targetDir !== null) {
const err = await targetDirError('cp', toDest, 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(toDest, dst);
dstExists = probe.exists;
dstIsDir = probe.isDir;
dstErr = probe.condition;
}
let versionReaddir = readdir;
if (versionReaddir === undefined && isPrimitiveCopy(strategy)) {
versionReaddir = strategy.readdir;
}
const errors = [];
const accepted = [];
const policy = {
cmdName: 'cp',
noClobber: flags.noClobber,
update: flags.update,
backup: flags.backup,
suffix: flags.suffix,
ask: flags.interactive ? prompter('cp', stdin ?? null, errors, accepted) : null,
};
const perEntryNative = flags.noClobber ||
flags.interactive ||
updateGates(flags.update) ||
backupDisplaces(flags.backup);
const lines = [];
let warned = 0;
// Where each source's first copy went, by source and by link name.
const firstCopy = new Map();
const firstLink = new Map();
const created = new Set();
const guardsCreated = !(flags.noClobber ||
updateGates(flags.update) ||
flags.backup === 'numbered');
for (const [src, dest] of copyTargets(sources, dst, dstIsDir, dstExists, dstErr, '.')) {
let target = dest;
const link = copies !== undefined && flags.dereference === 'never'
? typedLink(copies.links, src, copies.cwd)
: null;
if (copies !== undefined && link !== null) {
// The router followed the operand, but the policy copies the link
// itself, whatever it leads to (coreutils 9.7). Onto a destination that
// is no directory the link replaces the name as typed, never what a link
// standing there leads to.
const named = resolvePath(src.rawPath || src.virtual, copies.cwd);
const landing = target !== dst ? target.virtual : resolvePath(dst.rawPath || dst.virtual, copies.cwd);
if (named === landing) {
errors.push(sameFileError('cp', src, target));
continue;
}
const repeat = repeatedSource(firstLink.get(named), src, target, false, flags, keyOf);
if (repeat !== null) {
errors.push(repeat[0]);
if (repeat[1])
warned += 1;
continue;
}
if (!firstLink.has(named))
firstLink.set(named, target);
if (guardsCreated && created.has(keyOf(target))) {
if (policy.ask && !(await policy.ask(target)))
continue;
errors.push(justCreatedError('cp', src, target));
continue;
}
const raw = link.extra[LINK_TARGET_KEY];
const text = typeof raw === 'string' ? raw : '';
const made = await makeLink(copies, respelled(PathSpec.fromStrPath(named), src.rawPath), respelled(PathSpec.fromStrPath(landing), target.rawPath), text, policy, errors, flags.verbose ? lines : undefined);
if (made)
created.add(keyOf(target));
continue;
}
const { kind: srcKind, condition: srcErr } = await sourceKind(stat, src);
if (srcErr !== null) {
errors.push(`cp: cannot stat '${src.rawPath}': ${posixPhrase(srcErr)}`);
continue;
}
const srcIsDir = srcKind === FileType.DIRECTORY;
if (flags.noTargetDir && !srcIsDir && target.walkError !== null && target.rawPath === '') {
// Under -T, GNU stats an empty destination as the directory it is
// typed in, which a file cannot overwrite (coreutils 9.7). A
// directory source it merges into the working directory; mirage
// refuses that at the create, since reading the empty name as the
// working directory is what `walkError` is for.
errors.push(typeMismatchError('cp', src, target, false));
continue;
}
if (keyOf(src) === keyOf(target)) {
errors.push(sameFileError('cp', src, target));
continue;
}
// GNU copies a directory into its own subtree too: everything but the
// new copy itself, before it says it could not (cp -r d d).
const intoItself = flags.recursive && keyOf(target).startsWith(keyOf(src) + '/');
if (!flags.recursive && srcIsDir) {
errors.push(`cp: -r not specified; omitting directory '${src.rawPath}'`);
continue;
}
const probe = !flags.noTargetDir && target.virtual === dst.virtual
? { exists: dstExists, isDir: dstIsDir, condition: dstErr }
: await destKind(toDest, target);
const { exists: targetExists, isDir: targetIsDir } = probe;
let targetErr = probe.condition;
if (targetErr !== null && STAT_REFUSALS.has(targetErr)) {
errors.push(`cp: cannot stat '${target.rawPath}': ${posixPhrase(targetErr)}`);
continue;
}
// The create fails on the absent parent before the slash matters, so a
// chain verdict keeps its ENOENT (`cp f deep/missing/`).
if (slashRefusesFile(target, targetExists, srcIsDir))
targetErr ??= 'ENOTDIR';
if (targetErr !== null) {
const noun = srcIsDir ? 'directory' : 'regular file';
// GNU announces a file before the create this refuses; a directory's
// line waits for its mkdir.
if (flags.verbose && !srcIsDir)
lines.push(transferLine(src, target, null));
errors.push(`cp: cannot create ${noun} '${target.rawPath}': ${posixPhrase(targetErr)}`);
continue;
}
// A directory onto a non-directory is refused before any gate, unless a
// backup moves the entry aside (below); a file a directory stands in the
// way of waits for the gates (further below).
const mismatch = srcIsDir && (copies === undefined || !backupDisplaces(flags.backup))
? overwriteTypeError('cp', src, srcIsDir, target, targetExists, targetIsDir)
: null;
if (mismatch !== null) {
errors.push(mismatch);
continue;
}
if (!targetExists && linkAt !== undefined && linkAt(target) !== null) {
// A dangling link: the stat followed it to nothing, but the name is
// taken. GNU will not create the file it points at (POSIX would), and
// the link is a non-directory to a tree.
if (srcIsDir) {
errors.push(typeMismatchError('cp', src, target, true));
continue;
}
if (flags.verbose)
lines.push(transferLine(src, target, null));
errors.push(`cp: not writing through dangling symlink '${target.rawPath}'`);
continue;
}
const repeat = repeatedSource(firstCopy.get(keyOf(src)), src, target, srcIsDir, flags, keyOf);
if (repeat !== null) {
errors.push(repeat[0]);
if (repeat[1])
warned += 1;
continue;
}
if (!firstCopy.has(keyOf(src)))
firstCopy.set(keyOf(src), target);
if (intoItself) {
errors.push(`cp: cannot copy a directory, '${src.rawPath}', into itself, '${target.rawPath}'`);
}
if (flags.recursive && srcIsDir) {
if (guardsCreated && created.has(keyOf(target))) {
// As a file would be, but a directory is asked nothing.
errors.push(justCreatedError('cp', src, target));
continue;
}
// A whole-tree copy reads nothing here; any other copy goes file by
// file, so the source's mount is asked before the destination is
// touched.
const wholeTree = !isPrimitiveCopy(strategy) &&
strategy.dirCopy !== undefined &&
!perEntryNative &&
!intoItself &&
!targetExists;
if (!wholeTree)
await beforeFullWalk(dispatch, src, 'cp');
if (targetExists && !targetIsDir) {
const freed = await clearForDirectory(policy, copies, stat, src, target, errors, flags.verbose ? lines : undefined);
if (freed === null)
continue;
target = freed;
}
const srcBase = rstripSlash(src.virtual);
const dstBase = rstripSlash(target.virtual);
if (isPrimitiveCopy(strategy)) {
const walked = await walk(strategy.readdir, stat, src, 'cp', errors, copies?.links);
const entries = intoItself
? walked.filter((entry) => !within(entry.path, target.virtual))
: walked;
const leftOut = [];
await copyEntries('cp', strategy, stat, src, target, entries, errors, {
policy,
lines: flags.verbose ? lines : undefined,
copies,
blocked: leftOut,
});
if (copies !== undefined) {
await copyTreeLinks(copies, flags.dereference, src, target, errors, flags.verbose ? lines : undefined, policy, [], leftOut);
}
continue;
}
// A whole-tree copy cannot honor a merge's per-entry rules (a Drive copy
// adds a second entry of the same name, a Box one refuses the tree), so
// only a fresh destination takes it.
if (wholeTree && strategy.dirCopy !== undefined) {
const tree = flags.verbose
? await treeLines(strategy, stat, src, target, srcBase, dstBase)
: [];
let copied = true;
try {
await strategy.dirCopy(src, target);
}
catch (err) {
if (classify(err) !== 'ENOTSUP') {
const reason = fsStrerror(err);
if (reason === null)
throw err;
errors.push(err.virtualPath === src.virtual
? `cp: cannot open directory '${src.rawPath}': ${reason}`
: `cp: cannot create directory '${target.rawPath}': ${reason}`);
continue;
}
copied = false;
}
if (copied) {
for (const item of tree)
lines.push(item);
if (copies !== undefined) {
await copyTreeLinks(copies, flags.dereference, src, target, errors, flags.verbose ? lines : undefined, policy);
}
continue;
}
}
// Per-entry policy forfeits dirCopy, as does a tree copy the
// dispatcher declines or the backend cannot make in one call, so the
// tree's directories are recreated here: a files-only pass would drop
// every directory that holds no files (GNU keeps them).
if (wholeTree)
await beforeFullWalk(dispatch, src, 'cp');
// A directory the walk may not open is GNU's line; it is made at the
// destination and the rest of the tree still copies.
const unreadable = [];
const mirrored = await mirrorDirs(strategy, stat, src, target, srcBase, dstBase, errors, policy, copies, intoItself, flags.verbose ? lines : undefined, unreadable);
const files = await strategy.find(src, { type: 'f', unreadable });
for (const root of new Set(unreadable)) {
errors.push(`cp: cannot access '${spelledFrom(PathSpec.fromStrPath(root), src).rawPath}': ${posixPhrase('EACCES')}`);
}
for (const entryMount of files) {
if (intoItself && within(entryMount, dstBase))
continue;
if (mirrored.blocked.some((root) => within(entryMount, root)))
continue;
const dstMount = dstBase + entryMount.slice(srcBase.length);
const entry = spelledFrom(PathSpec.fromStrPath(entryMount), src);
const entryDst = spelledFrom(PathSpec.fromStrPath(dstMount), target);
if (!(await overwriteGate(policy, stat, entry, entryDst, errors, copies)))
continue;
if (mirrored.made !== null &&
!mirrored.made.has(parent(dstMount)) &&
(await holdsDirectory(stat, entryDst))) {
errors.push(ontoDirectory('cp', entry, entryDst, flags.backup));
continue;
}
const made = await makeBackup(policy, strategy, stat, versionReaddir, entryDst, errors, copies);
if (!made.ok)
continue;
if (flags.verbose)
lines.push(transferLine(entry, entryDst, made.backup));
try {
await copyEntry(strategy, copies, entry, entryDst);
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(copyFailed('cp', entry, entryDst, err));
continue;
}
}
if (copies !== undefined) {
await copyTreeLinks(copies, flags.dereference, src, target, errors, flags.verbose ? lines : undefined, policy, [], mirrored.blocked.map((root) => PathSpec.fromStrPath(root).virtual));
}
continue;
}
if (guardsCreated && created.has(keyOf(target))) {
// -i asks first: GNU only meets the just-created rule once the answer
// says to replace.
if (policy.ask && !(await policy.ask(target)))
continue;
errors.push(justCreatedError('cp', src, target));
continue;
}
if (!(await overwriteGate(policy, stat, src, target, errors, copies)))
continue;
if (targetIsDir) {
errors.push(ontoDirectory('cp', src, target, flags.backup));
continue;
}
const refusal = flags.recursive ? specialFileError('cp', srcKind, target) : null;
if (refusal !== null) {
if (flags.verbose)
lines.push(transferLine(src, target, null));
errors.push(refusal);
continue;
}
let copier = strategy;
let mover = strategy;
if (copies !== undefined && policy.backup !== null) {
const linked = linkName(copies, target);
if (linked !== null) {
target = linked;
mover = { rename: (a, b) => renameLink(copies, a, b) };
if (copies.owns !== undefined && !copies.owns(target.virtual))
copier = copies.relay;
}
}
const made = await makeBackup(policy, mover, stat, versionReaddir, target, errors, copies);
if (!made.ok)
continue;
if (flags.verbose)
lines.push(transferLine(src, target, made.backup));
if (isPrimitiveCopy(copier)) {
if (!(await transferFile(copier, src, target, 'cp', errors)))
continue;
}
else {
try {
await copyEntry(copier, copies, src, target);
}
catch (err) {
if (!isFsError(err))
throw err;
errors.push(copyFailed('cp', src, target, err));
continue;
}
}
created.add(keyOf(target));
}
const output = lines.length > 0 ? ENC.encode(lines.join('\n') + '\n') : null;
return [
output,
new IOResult({
stderr: stderrOf(errors),
exitCode: errors.length > warned + accepted.length ? 1 : 0,
}),
];
}
/**
* A cp line reads below its sources when it copies recursively (`-r`, `-R`,
* `-a`), and copies into the directory `-t` names if it names one. Mirrors
* Python's `facts`.
*/
export function facts(fl, _operands) {
return new LineFacts({
readsSubtree: ['r', 'recursive', 'archive'].some((dest) => fl.asBool(dest)),
moveTarget: fl.asStr('target_directory') ?? null,
});
}
//# sourceMappingURL=cp.js.map