UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

780 lines • 34.6 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { 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