@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
780 lines • 34.6 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 { backupControl, backupTarget } from "../../../../commands/builtin/utils/backup.js";
import { DEFAULT_BACKUP_SUFFIX } from "../../../../commands/builtin/utils/constants.js";
import { absentDestError, dispatchStat, dotRefusal, resolvePathStat, } from "../../../../commands/builtin/utils/paths.js";
import { UsageError } from "../../../../commands/errors.js";
import { specOf } from "../../../../commands/spec/builtins.js";
import { FlagView } from "../../../../commands/spec/flag_view.js";
import { parseCommand, parseToKwargs } from "../../../../commands/spec/parser.js";
import { ambiguousOptionError, missingValueError, unexpectedValueError, unknownOptionError, usageHint, } from "../../../../commands/spec/usage.js";
import { classify } from "../../../../errors/classify.js";
import { fsStrerror, isEexist, isEisdir, isEnoent, isEnotdir, isRefusal, } from "../../../../errors/fs.js";
import { posixPhrase } from "../../../../errors/posix.js";
import { materialize } from "../../../../io/types.js";
import { decodeText, encodeText } from "../../../../shell/bytes.js";
import { FileType, PathSpec, wordText } from "../../../../types.js";
import { CycleError, dottedSpelling, gnuBasename, gnuDirname, posixNormpath, } from "../../../../utils/path.js";
import { rstripSlash } from "../../../../utils/slash.js";
import { linkTargetStat, missCondition, pathReaddir } from "../../../mount/namespace/probe.js";
import { absPath, fail, result } from "../shared.js";
import { posixRelative } from "./links.js";
// Opt in by assigning a debug sink, as Python's module logger is off until
// configured.
export const logger = {};
const TARGET_DIR_LONG = '--target-directory';
const SUFFIX_LONG = '--suffix';
const VALUED_SHORTS = 'tS';
// Parse the ln flag bag once into a frozen struct. -L and -P are one
// switch and the later occurrence wins, as in GNU (-LP links the symlink
// itself, -PL its target); a hard link of a symlink is the -P default.
// Throws UsageError for a --backup control GNU does not know.
export function parseFlags(fl) {
const raw = fl.raw('backup');
const backupRaw = typeof raw === 'string' || typeof raw === 'boolean' ? raw : undefined;
const suffixRaw = fl.asStr('suffix');
const suffix = suffixRaw === undefined || suffixRaw === '' ? null : suffixRaw;
const deref = fl.typedOrder('logical', 'physical');
return Object.freeze({
symbolic: fl.asBool('symbolic'),
force: fl.asBool('force'),
noDereference: fl.asBool('no_dereference'),
verbose: fl.asBool('verbose'),
relative: fl.asBool('relative'),
logical: deref.length > 0 && deref[deref.length - 1] === 'logical',
directory: fl.asBool('directory') || fl.asBool('F'),
noTarget: fl.asBool('no_target_directory'),
backup: backupControl('ln', backupRaw, suffix),
suffix: suffix ?? DEFAULT_BACKUP_SUFFIX,
});
}
/** The GNU option error the parser reported, if any. */
export function optionRefusal(parsed) {
const ambiguousFirst = parsed.ambiguousOptions[0];
if (parsed.optionErrorKinds[0] === 'ambiguous' && ambiguousFirst !== undefined) {
const [msg, code] = ambiguousOptionError('ln', ...ambiguousFirst);
return [decodeText(msg), code];
}
const invalid = parsed.invalidOptions[0];
if (invalid !== undefined) {
const [msg, code] = parsed.optionErrorKinds[0] === 'unexpected_value'
? unexpectedValueError('ln', invalid)
: unknownOptionError('ln', invalid);
return [decodeText(msg), code];
}
if (ambiguousFirst !== undefined) {
const [msg, code] = ambiguousOptionError('ln', ...ambiguousFirst);
return [decodeText(msg), code];
}
const needsValue = parsed.needsValueOptions[0];
if (needsValue !== undefined) {
const [msg, code] = missingValueError('ln', needsValue);
return [decodeText(msg), code];
}
return null;
}
// The operands as classified, and -t's value as typed. The spec parse
// resolves every path against the cwd, which is what a link is made at,
// but a message spells what the user typed ('e/f.txt'), and only the
// classified words still carry that. The parse already validated the
// options, so this walk only has to know which of ln's spellings consume
// a word.
export function operandWords(args) {
const operands = [];
let targetTyped = null;
let parsing = true;
let i = 0;
while (i < args.length) {
const arg = args[i] ?? '';
const tok = wordText(arg);
if (parsing && tok === '--') {
parsing = false;
}
else if (parsing && tok.startsWith('--') && tok.length > 2) {
const eq = tok.indexOf('=');
const name = eq === -1 ? tok : tok.slice(0, eq);
if (TARGET_DIR_LONG.startsWith(name)) {
if (eq !== -1) {
targetTyped = tok.slice(eq + 1);
}
else if (i + 1 < args.length) {
i += 1;
targetTyped = wordText(args[i] ?? '');
}
}
else if (SUFFIX_LONG.startsWith(name) && eq === -1 && i + 1 < args.length) {
i += 1;
}
}
else if (parsing && tok.startsWith('-') && tok.length > 1) {
for (let j = 1; j < tok.length; j += 1) {
const letter = tok[j] ?? '';
if (!VALUED_SHORTS.includes(letter))
continue;
let value = tok.slice(j + 1);
if (value === '' && i + 1 < args.length) {
i += 1;
value = wordText(args[i] ?? '');
}
if (letter === 't')
targetTyped = value;
break;
}
}
else {
operands.push(arg);
}
i += 1;
}
return [operands, targetTyped];
}
async function pathStat(dispatch, virtual) {
return await resolvePathStat(dispatch, PathSpec.fromStrPath(virtual));
}
// What a destination operand names once links are resolved. GNU
// dereferences a destination that is a link to a directory and links
// inside that directory; -n keeps the link itself as the name. A real
// directory is the directory either way.
// Whether a path's own name is in its parent's listing: the dispatcher's proof
// of a directory, repeated here because a stat row settles nothing. An
// API tree synthesizes its directories (a postgres schema lists tables/
// and views/ for a schema nobody created, a grouping mount stats every
// path under a live collection as one), and linking *into* an invented
// directory would bury the name the user typed. Compared on the final
// segment, because backends disagree on entry shape.
async function listedByParent(dispatch, virtual) {
const stripped = rstripSlash(virtual);
const cut = stripped.lastIndexOf('/');
const name = stripped.slice(cut + 1);
if (name === '')
return false;
const parent = stripped.slice(0, cut) || '/';
let listing;
try {
listing = await pathReaddir(dispatch, parent);
}
catch (err) {
if (!isRefusal(err))
throw err;
// A parent it may not list says nothing against the stat's row.
logger.debug?.(`ln: listing ${parent} refused: ${String(err)}`);
return true;
}
return listing.some((entry) => {
const s = rstripSlash(entry);
return s.slice(s.lastIndexOf('/') + 1) === name;
});
}
// An operand as the path the kernel reaches, its final name kept. Command
// dispatch walks the links above the name of every operand it classifies;
// the relative words of ln, readlink and `[` arrive unclassified, so they are
// walked here, and every namespace read sees the name the dispatcher will. A hidden
// path stays as typed, and a loop is left for the dispatcher to report when the
// link is made. `links` is the session's link table (`Namespace.sessionLinks`).
// Mirrors Python's operand_abs.
export function operandAbs(links, arg, cwd) {
const virtual = absPath(arg, cwd);
if (arg instanceof PathSpec)
return virtual;
try {
return posixNormpath(links.followParent(dottedSpelling(arg, cwd) ?? virtual));
}
catch (err) {
if (err instanceof CycleError)
return virtual;
throw err;
}
}
// The condition the kernel walk answers for an operand before any op. An
// empty name resolves nowhere, and a link loop stops the walk in front of
// the final name; `followLast` asks for that name too, for an operand that
// has to be a directory. Null when the walk gets there, and for a hidden
// path, which the dispatcher answers like any other absent one. ln's relative
// words arrive unclassified, so the verdict cannot be read off a PathSpec.
// Mirrors Python's _walk_verdict.
function walkVerdict(links, word, cwd, followLast = false) {
if (wordText(word) === '')
return 'ENOENT';
const trimmed = rstripSlash(absPath(word, cwd)) || '/';
try {
if (followLast)
links.follow(trimmed);
else
links.followParent(trimmed);
}
catch (err) {
if (err instanceof CycleError)
return 'ELOOP';
throw err;
}
return null;
}
// ln's line for a link its name's parent chain would not take. GNU words
// the step that failed. Under -f or -b it lstats the name before anything
// else, and an error there other than absence is `failed to access`; the
// rest fail at link(2) or symlink(2), where a hard link names its target
// alongside and a symlink never does. Mirrors Python's _refused.
function refused(flags, typed, targetTyped, why) {
const backs = flags.backup !== null && flags.backup !== 'none';
const words = posixPhrase(why);
if ((flags.force || backs) && why !== 'ENOENT') {
return `ln: failed to access '${typed}': ${words}\n`;
}
const kind = flags.symbolic ? 'symbolic link' : 'hard link';
const arrow = flags.symbolic ? '' : ` => '${targetTyped}'`;
return `ln: failed to create ${kind} '${typed}'${arrow}: ${words}\n`;
}
async function dirAt(links, dispatch, virtual, noDereference) {
let resolved = virtual;
if (links.isLink(virtual)) {
if (noDereference)
return [virtual, null];
try {
resolved = links.follow(virtual);
}
catch (err) {
if (err instanceof CycleError)
return [virtual, null];
throw err;
}
}
const stat = await pathStat(dispatch, resolved);
if (stat !== null &&
stat.type === FileType.DIRECTORY &&
!links.isMount(resolved) &&
!(await listedByParent(dispatch, resolved))) {
return [resolved, null];
}
return [resolved, stat];
}
// The link a TARGET gets inside a directory: GNU names it after the
// target's basename, spelled under the directory as typed.
function into(source, dirAbs, dirTyped) {
const base = gnuBasename(rstripSlash(wordText(source)));
const joined = dirAbs === '/' ? `/${base}` : `${rstripSlash(dirAbs)}/${base}`;
// `.` and `..` name the directory or its parent, which already stands
// there: GNU's symlink(2) answers EEXIST (coreutils 9.7).
const linkAbs = base === '.' || base === '..' ? posixNormpath(joined) : joined;
return { source, linkAbs, linkTyped: `${rstripSlash(dirTyped)}/${base}` };
}
// Turn ln's operands into the links to make, GNU's four forms: `ln -t DIR
// TARGET...` and `ln TARGET... DIR` link every target into the directory;
// `ln TARGET` links into the cwd; `ln TARGET LINK` names the link, unless
// LINK is a directory and -T did not forbid descending into it. Returns
// the plans, or an empty list with the refusal to print.
export async function planLinks(links, dispatch, cwd, operands, targetDir, targetTyped, flags) {
const hint = `${usageHint('ln')}\n`;
if (targetDir !== null) {
const typed = targetTyped ?? targetDir;
const why = walkVerdict(links, typed, cwd, true);
if (why !== null)
return [[], `ln: failed to access '${typed}': ${posixPhrase(why)}\n`];
let resolved;
let stat;
try {
const unwalked = await dotRefusal(dispatchStat(dispatch), PathSpec.fromStrPath(typed, undefined, cwd), (v) => links.follow(v));
if (unwalked !== null) {
return [
[],
`ln: failed to access '${typed}': ${fsStrerror(unwalked) ?? posixPhrase('ENOENT')}\n`,
];
}
;
[resolved, stat] = await dirAt(links, dispatch, operandAbs(links, targetDir, cwd), flags.noDereference);
}
catch (err) {
if (!isRefusal(err))
throw err;
return [[], `ln: failed to access '${typed}': ${String(fsStrerror(err))}\n`];
}
if (stat === null) {
return [
[],
`ln: failed to access '${typed}': ${posixPhrase(await missCondition(dispatch, resolved))}\n`,
];
}
if (stat.type !== FileType.DIRECTORY)
return [[], `ln: target '${typed}' is not a directory\n`];
return [operands.map((op) => into(op, resolved, typed)), null];
}
const first = operands[0];
if (first === undefined)
return [[], null];
if (operands.length === 1) {
if (flags.noTarget) {
return [[], `ln: missing destination file operand after '${wordText(first)}'\n${hint}`];
}
return [[into(first, cwd, '.')], null];
}
if (flags.noTarget) {
const extra = operands[2];
if (extra !== undefined)
return [[], `ln: extra operand '${wordText(extra)}'\n${hint}`];
const link = operands[1];
if (link === undefined)
return [[], null];
return [
[
{
source: first,
linkAbs: operandAbs(links, link, cwd),
linkTyped: wordText(link),
},
],
null,
];
}
const last = operands[operands.length - 1] ?? first;
const lastAbs = operandAbs(links, last, cwd);
// The empty name reads as the working directory in `lastAbs`, and it is
// no directory to link into.
let resolved;
let stat;
try {
;
[resolved, stat] =
wordText(last) === ''
? [lastAbs, null]
: await dirAt(links, dispatch, lastAbs, flags.noDereference);
}
catch (err) {
if (!isRefusal(err))
throw err;
// GNU reads a name it cannot stat as no directory, so one TARGET links
// at it and meets the refusal there; a hard link's source is reached
// first.
if (operands.length > 2) {
return [[], `ln: target '${wordText(last)}': ${String(fsStrerror(err))}\n`];
}
if (flags.symbolic) {
return [[], refused(flags, wordText(last), wordText(first), classify(err) ?? 'EACCES')];
}
return [[{ source: first, linkAbs: lastAbs, linkTyped: wordText(last) }], null];
}
const isDir = stat !== null && stat.type === FileType.DIRECTORY;
if (operands.length === 2 && !isDir) {
return [[{ source: first, linkAbs: lastAbs, linkTyped: wordText(last) }], null];
}
if (!isDir) {
if (stat === null) {
// A link standing at the name that leads nowhere, dangling or
// looping, is ENOENT to GNU; a loop above it is ELOOP.
const why = wordText(last) === '' || links.isLink(lastAbs)
? 'ENOENT'
: await missCondition(dispatch, resolved);
return [[], `ln: target '${wordText(last)}': ${posixPhrase(why)}\n`];
}
return [[], `ln: target '${wordText(last)}': Not a directory\n`];
}
return [operands.slice(0, -1).map((op) => into(op, resolved, wordText(last))), null];
}
// The bytes a hard link copies, or the refusal in ln's words.
async function sourceBytes(links, dispatch, srcAbs, typed, linkTyped, flags) {
let resolved = srcAbs;
if (links.isLink(srcAbs)) {
try {
resolved = links.follow(srcAbs);
}
catch (err) {
if (err instanceof CycleError) {
return [null, `ln: failed to access '${typed}': Too many levels of symbolic links\n`];
}
throw err;
}
}
let stat;
try {
stat = await pathStat(dispatch, resolved);
}
catch (err) {
if (!isRefusal(err))
throw err;
return [null, `ln: failed to access '${typed}': ${String(fsStrerror(err))}\n`];
}
if (stat === null) {
return [
null,
`ln: failed to access '${typed}': ${posixPhrase(await missCondition(dispatch, resolved))}\n`,
];
}
if (stat.type === FileType.DIRECTORY) {
if (flags.directory) {
return [
null,
`ln: failed to create hard link '${linkTyped}' => '${typed}': Operation not permitted\n`,
];
}
return [null, `ln: ${typed}: hard link not allowed for directory\n`];
}
// A source whose stat passes but whose read fails (a policy deny, a
// backend that answers stat but not read) is refused the way GNU
// refuses a source it cannot reach at all, so the remaining operands
// still link.
try {
const [data] = await dispatch('read', PathSpec.fromStrPath(resolved));
return [data instanceof Uint8Array ? data : await materialize(data), null];
}
catch (err) {
const why = fsStrerror(err);
if (why === null)
throw err;
return [null, `ln: failed to access '${typed}': ${why}\n`];
}
}
// Make one link, appending GNU's line to `errors` or `out`. A symlink
// stores the target as typed (or relative under -r). A hard link of a
// symlink is the link itself, so it becomes a second symlink with the
// same target, unless -L asks for the target's bytes; a hard link of a
// file is a byte copy through the dispatcher. A stat of the name that is
// refused is GNU's lstat under -f or -b and the call that makes the link
// otherwise.
export async function makeLink(links, dispatch, cwd, plan, flags, errors, out) {
try {
await attemptLink(links, dispatch, cwd, plan, flags, errors, out);
}
catch (err) {
if (!isRefusal(err))
throw err;
errors.push(refused(flags, plan.linkTyped, wordText(plan.source), classify(err) ?? 'EACCES'));
}
}
// makeLink up to a refused stat of the name.
async function attemptLink(links, dispatch, cwd, plan, flags, errors, out) {
const kind = flags.symbolic ? 'symbolic link' : 'hard link';
const typed = plan.linkTyped;
const targetTyped = wordText(plan.source);
let linkTarget = null;
let data = null;
// A `.` or `..` resolves against the directory in front of it, which has
// to be there. A symlink's target is stored as typed and never walked; a
// hard link's is the file it names.
const walker = dispatchStat(dispatch);
// What the walk answers before any op, in GNU's order: symlink(2)
// refuses an empty target ahead of the name, and then GNU names the
// target alongside; a hard link's source is reached first; the name
// last, as the call that makes it would meet it.
if (flags.symbolic && targetTyped === '') {
errors.push(`ln: failed to create symbolic link '${typed}' -> '': ${posixPhrase('ENOENT')}\n`);
return;
}
if (!flags.symbolic) {
const why = walkVerdict(links, plan.source, cwd, flags.logical);
if (why !== null) {
errors.push(`ln: failed to access '${targetTyped}': ${posixPhrase(why)}\n`);
return;
}
}
const nameWhy = walkVerdict(links, typed, cwd);
if (nameWhy !== null) {
errors.push(refused(flags, typed, targetTyped, nameWhy));
return;
}
if (!flags.symbolic) {
let unwalked;
try {
unwalked = await dotRefusal(walker, PathSpec.fromStrPath(plan.source, undefined, cwd), (v) => links.follow(v));
}
catch (err) {
if (!isRefusal(err))
throw err;
unwalked = err;
}
if (unwalked !== null) {
errors.push(`ln: failed to access '${targetTyped}': ${fsStrerror(unwalked) ?? posixPhrase('ENOENT')}\n`);
return;
}
}
// -f and -b look at the name before making it, so a plain file behind its
// slash is ENOTDIR there and EEXIST only to symlink(2).
const replaces = flags.force || (flags.backup !== null && flags.backup !== 'none');
const unwalked = await dotRefusal(walker, PathSpec.fromStrPath(typed, undefined, cwd), (v) => links.follow(v), !replaces);
if (unwalked !== null) {
errors.push(refused(flags, typed, targetTyped, classify(unwalked) ?? 'ENOENT'));
return;
}
if (flags.symbolic) {
linkTarget = targetTyped;
if (flags.relative) {
// --relative: rewrite the target relative to the link's own
// directory so the link stays valid addressed from anywhere. GNU
// canonicalizes existing symlink components of both ends first, so
// an aliased directory resolves to its real path (the link survives
// the alias being moved/removed); a loop falls back to the lexical
// answer.
let linkDir = gnuDirname(plan.linkAbs);
let targetAbs = absPath(plan.source, cwd);
try {
targetAbs = links.follow(targetAbs);
linkDir = links.follow(linkDir);
}
catch (err) {
if (!(err instanceof CycleError))
throw err;
}
linkTarget = posixRelative(targetAbs, linkDir);
}
}
else {
const srcAbs = operandAbs(links, plan.source, cwd);
if (links.isLink(srcAbs) && !flags.logical) {
linkTarget = links.readlink(srcAbs);
}
else {
const [bytes, refusal] = await sourceBytes(links, dispatch, srcAbs, targetTyped, typed, flags);
if (refusal !== null) {
errors.push(refusal);
return;
}
data = bytes;
}
}
if (links.isMount(plan.linkAbs)) {
errors.push(`ln: failed to create ${kind} '${typed}': File exists\n`);
return;
}
const linkSpec = PathSpec.fromStrPath(plan.linkAbs);
let backupNote = '';
const control = flags.backup;
const backs = control !== null && control !== 'none';
if (typed.endsWith('/')) {
// A link name typed with a slash asks for a directory the link can
// never be, and GNU settles it before -f or -b touch anything there:
// those two lstat the name first, and `reg/` over a file (or a link
// to one) is ENOTDIR, `failed to access`, with the file kept and
// nothing renamed aside. Without them symlink(2) and link(2) answer
// `missing/` with ENOENT and anything standing behind the slash with
// EEXIST, so GNU creates nothing where the normalized name would have
// made a link called `missing`. A directory there took the link
// inside it in planLinks, so only a non-directory and the absent name
// are settled here, and a plain file without a flag falls to the
// dispatcher's "File exists" below.
const linked = links.isLink(plan.linkAbs);
const behind = linked
? await linkTargetStat(links, dispatch, plan.linkAbs)
: await pathStat(dispatch, plan.linkAbs);
if (behind !== null && behind.type !== FileType.DIRECTORY && (flags.force || backs)) {
errors.push(refused(flags, typed, targetTyped, 'ENOTDIR'));
return;
}
if (!linked && behind === null) {
errors.push(refused(flags, typed, targetTyped, await missCondition(dispatch, plan.linkAbs)));
return;
}
if (linked && behind?.type !== FileType.DIRECTORY) {
errors.push(`ln: failed to create ${kind} '${typed}': File exists\n`);
return;
}
}
// GNU's same-name check, before any backup or removal: -f would
// otherwise unlink the source it is about to link, leaving `ln -sf a a`
// a self-loop where a file was. GNU waives it when a backup keeps the
// original, so `ln -sfb a a` still goes through, and a name typed as `.`
// or `..` removes nothing, so -f meets the directory there.
const dotted = typed.endsWith('/.') || typed.endsWith('/..');
if (flags.force &&
!backs &&
!dotted &&
operandAbs(links, plan.source, cwd) === plan.linkAbs &&
(links.isLink(plan.linkAbs) || (await pathStat(dispatch, plan.linkAbs)) !== null)) {
errors.push(`ln: '${targetTyped}' and '${typed}' are the same file\n`);
return;
}
// The dispatcher refuses an occupied name for a symlink; a byte copy would
// overwrite one, and a backup has to see it first, so those two probe.
// A backup moves a file aside, never a directory: GNU refuses the
// directory (`ln -bT a d` is `cannot overwrite directory`) where it
// would otherwise rename the whole tree to `d~`. A symlink standing
// there is what -T names, and that one is backed up.
let occupied = false;
if (data !== null || backs) {
const found = links.isLink(plan.linkAbs) ? null : await pathStat(dispatch, plan.linkAbs);
if (backs && found !== null && found.type === FileType.DIRECTORY) {
errors.push(`ln: ${typed}: cannot overwrite directory\n`);
return;
}
occupied = found !== null || links.isLink(plan.linkAbs);
}
if (occupied && backs) {
const backup = await backupTarget(async (p) => await pathReaddir(dispatch, p.virtual), linkSpec, control, flags.suffix);
if (backup !== null) {
try {
await dispatch('rename', linkSpec, [backup]);
}
catch (err) {
errors.push(`ln: cannot backup '${typed}': ${fsStrerror(err) ?? String(err)}\n`);
return;
}
backupNote = `'${typed}${backup.virtual.slice(plan.linkAbs.length)}' ~ `;
occupied = false;
}
}
else if (flags.force) {
// GNU -f is "remove the destination, then link", which is why it
// replaces a regular file and not only a link. The dispatcher refuses an
// occupied name (symlink(2)'s EEXIST), so the removal is what makes
// the flag work rather than a formality; a destination that is not
// there is what -f is for, so its miss is the expected case and not
// an error. unlink(2) refuses a directory (EISDIR); a backend that would
// remove one is never asked.
if (!links.isLink(plan.linkAbs)) {
const found = await pathStat(dispatch, plan.linkAbs);
if (found?.type === FileType.DIRECTORY) {
errors.push(`ln: ${typed}: cannot overwrite directory\n`);
return;
}
}
try {
await dispatch('unlink', linkSpec);
}
catch (err) {
if (isEisdir(err)) {
errors.push(`ln: ${typed}: cannot overwrite directory\n`);
return;
}
if (err.code === 'ELOOP') {
errors.push(refused(flags, typed, targetTyped, 'ELOOP'));
return;
}
if (isRefusal(err)) {
// GNU's lstat passed; making the link over it is refused.
const arrow = flags.symbolic ? '' : ` => '${targetTyped}'`;
errors.push(`ln: failed to create ${kind} '${typed}'${arrow}: ${String(fsStrerror(err))}\n`);
return;
}
if (!isEnoent(err) && !isEnotdir(err))
throw err;
}
occupied = false;
}
if (occupied) {
errors.push(`ln: failed to create ${kind} '${typed}': File exists\n`);
return;
}
if (linkTarget === null) {
// A byte copy lands through the backend's write, which on a keyed
// store makes the key whatever stands above it. ln is not mkdir -p, so
// the parent chain of the name (absent by now) is judged first, as cp
// judges its destination's; the dispatcher judges a symlink's itself.
const why = await absentDestError(dispatchStat(dispatch), linkSpec);
if (why !== null) {
errors.push(refused(flags, typed, targetTyped, why));
return;
}
}
try {
if (linkTarget !== null) {
await dispatch('symlink', linkSpec, [], { target: linkTarget });
}
else {
await dispatch('write', linkSpec, [data ?? new Uint8Array()]);
}
}
catch (err) {
const code = err.code;
if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'ELOOP') {
// The dispatcher refuses a name its parent cannot hold (symlink(2)'s
// ENOENT, ENOTDIR and ELOOP), and a store's write refuses the same
// way.
errors.push(refused(flags, typed, targetTyped, code));
return;
}
if (isEexist(err)) {
// The dispatcher owns the existence rule (it is the only layer that can
// see both the node table and the backend); ln owns the wording.
errors.push(`ln: failed to create ${kind} '${typed}': File exists\n`);
return;
}
if (isEnoent(err) || isEnotdir(err)) {
// A parent the name cannot sit under. GNU names a hard link's target
// alongside for these errnos, a symlink's never.
const arrow = flags.symbolic ? '' : ` => '${targetTyped}'`;
errors.push(`ln: failed to create ${kind} '${typed}'${arrow}: ${String(fsStrerror(err))}\n`);
return;
}
if (isRefusal(err)) {
// A read-only region or a policy deny, which ln voices as its own
// per-operand line, as GNU does for EROFS.
errors.push(`ln: failed to create ${kind} '${typed}': ${String(fsStrerror(err))}\n`);
return;
}
throw err;
}
if (flags.verbose) {
// A symlink reports the target it stored (relative under -r); a hard
// link reports its source as typed.
const shown = flags.symbolic && linkTarget !== null ? linkTarget : targetTyped;
const arrow = flags.symbolic ? '->' : '=>';
out.push(`${backupNote}'${typed}' ${arrow} '${shown}'\n`);
}
}
// ln [OPTION]... TARGET... : GNU ln over the namespace and the dispatcher.
//
// -s makes a namespace symbolic link. Without it mirage has no hard link
// to offer, so the "link" is a byte copy through the dispatcher, with one
// faithful exception: a hard link of a symlink is the link itself (GNU's
// default, -P), so it becomes a second symlink with the same target, and
// -L copies the target's bytes instead.
//
// The operand grammar is GNU's: -t DIR, a trailing directory operand, a
// single operand (into the cwd) and -T; a destination that is a link to a
// directory is dereferenced unless -n. -b, --backup=CONTROL and -S move an
// occupant aside before the link is made; -f removes it. -d/-F only change
// the wording of the refusal a directory source gets, since nobody is root
// here. Every write is a dispatch op, so session grants and admission
// policies fire at the dispatcher; this handler keeps the operand semantics and
// renders refusals in ln's own words.
export async function handleLn(namespace, dispatch, session, args) {
const spec = specOf('ln');
const parsed = parseCommand(spec, args.map((a) => wordText(a)), session.cwd, 'ln');
const refusal = optionRefusal(parsed);
if (refusal !== null)
return fail('ln', refusal[0], refusal[1]);
const fl = new FlagView(parseToKwargs(parsed), spec);
let flags;
try {
flags = parseFlags(fl);
}
catch (err) {
if (err instanceof UsageError)
return fail('ln', `${err.message}\n`, err.exitCode);
throw err;
}
const [operands, targetTyped] = operandWords(args);
if (operands.length === 0)
return fail('ln', `ln: missing file operand\n${usageHint('ln')}\n`);
// GNU's order: the operand count first, then -r, then the -T/-t clash.
if (flags.relative && !flags.symbolic) {
return fail('ln', 'ln: cannot do --relative without --symbolic\n');
}
const rawDir = fl.raw('target_directory');
const targetDir = rawDir instanceof PathSpec ? rawDir.virtual : typeof rawDir === 'string' ? rawDir : null;
if (targetDir !== null && flags.noTarget) {
return fail('ln', 'ln: cannot combine --target-directory and --no-target-directory\n');
}
const links = namespace.sessionLinks(session);
const [plans, planRefusal] = await planLinks(links, dispatch, session.cwd, operands, targetDir, targetTyped, flags);
if (planRefusal !== null)
return fail('ln', planRefusal);
const errors = [];
const out = [];
for (const plan of plans) {
await makeLink(links, dispatch, session.cwd, plan, flags, errors, out);
}
return result('ln', {
out: out.length > 0 ? encodeText(out.join('')) : null,
exitCode: errors.length > 0 ? 1 : 0,
...(errors.length > 0 ? { stderr: errors.join('') } : {}),
});
}
//# sourceMappingURL=ln.js.map