diff --git a/changelog.d/workspace-nested-mount-roots.fixed.md b/changelog.d/workspace-nested-mount-roots.fixed.md new file mode 100644 index 000000000..2189c6c21 --- /dev/null +++ b/changelog.d/workspace-nested-mount-roots.fixed.md @@ -0,0 +1,2 @@ +- A mount nested in another is now that mount's directory wherever disk puts it, not only where the outer mount's paths name it as written. A root configured through a symlink, or beneath one, is nested where its real path lies; before, it wasn't nested at all, and the outer mount took in and read the inner mount's files. Each start of the module reads each root's real path again, awaited, so a root on a share that stops answering holds that start, not the process; before the first start, mounts are nested by their configured paths alone, as they were. On a mount that follows symlinks, a path that reaches a nested mount's directory through a link is now refused as one written into it: the file tools answer `"work/l/x.txt" leads into mount "inner", nested in mount "work", which neither reads nor writes it: use "inner/sub/x.txt".`, a sync or a materialize leaves it with `"work/l" leads into mount "inner", nested in mount "work": use "inner/sub" in its place`, and `readFileFromDisk` refuses it with the code `other_mount`. Before, the outer mount read, took in, wrote and unlinked the inner mount's files through such a link (#530). On a mount that doesn't follow symlinks, a path through a link into a nested mount's directory is now refused before anything is stored, with the refusal of the link the mount doesn't follow; a `write`, `edit` or `delete` through it used to store a change that no push could write. Where the path is itself a link to a file in a nested mount's directory, `readFileFromDisk` now refuses it with the code `other_mount` and words naming that mount, where it answered `symlink`. +- A mount configured with a relative root written with `./`, such as `./m`, now hears its watcher; it heard no change on disk. diff --git a/src/modules/workspace/effects.ts b/src/modules/workspace/effects.ts index bf2a49122..264c655ce 100644 --- a/src/modules/workspace/effects.ts +++ b/src/modules/workspace/effects.ts @@ -24,7 +24,8 @@ * Node has no openat, so a create, mkdir or unlink still resolves its path by * name after the parent was checked: a parent replaced concurrently in that * window can redirect that one operation, leaving an empty file or directory - * outside the mount or unlinking a same-named file there. Nor is there an + * outside the mount or in a nested mount's directory, or unlinking a + * same-named file there. Nor is there an * unlink bound to an inode: an unlink checks the entry's identity again just * before it, and a file replaced at its path after that check is the one * unlinked. Content writes stay bound to the descriptor verified before @@ -53,7 +54,9 @@ import { errno, checkDir, checkRoot, + leadsInto, nameOf, + nestedAtReal, parentOf, } from './observe.js'; @@ -273,7 +276,7 @@ export async function writeContained( let stage = 'write the file'; try { const created = touched; - await checkOpened(view, rootReal, root, parent, target, handle, expect, noFollow, touched); + await checkOpened(view, rootReal, rel, root, parent, target, handle, expect, noFollow, touched); touched = true; // The mode the bytes are written at, and the one the file ends with. let before: number | undefined; @@ -369,14 +372,16 @@ export async function writeContained( * Before an effect on an opened file: a regular file, and — unless forced — * still holding what was decided on. The hash reads the whole file, so it * comes first, and the checks of where the file is follow it: canonically - * inside the mount, the one this path names (and not a symlink, where the - * mount follows none and the open couldn't refuse one), with no other names, - * in the directory still at its path beneath the root still at its own; and - * nothing wrote it while those ran. + * inside the mount and in no nested mount's directory (nestedAtReal), the + * one this path names (and not a symlink, where the mount follows none and + * the open couldn't refuse one), with no other names, in the directory still + * at its path beneath the root still at its own; and nothing wrote it while + * those ran. */ async function checkOpened( view: MountView, rootReal: string, + rel: string, root: CheckedDir, parent: CheckedDir, target: string, @@ -391,6 +396,8 @@ async function checkOpened( const real = await realpath(target).catch(() => null); if (real === null) throw new EffectFailed(CHANGED, touched); if (!contained(rootReal, real)) throw new EffectFailed(view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT, touched); + const holder = nestedAtReal(real, view.nested); + if (holder) throw new EffectFailed(leadsInto(view.name, rel, holder), touched); const named = await stat(real, { bigint: true }).catch(() => null); if (named === null || named.dev !== info.dev || named.ino !== info.ino) throw new EffectFailed(CHANGED, touched); if (!view.followSymlinks) { @@ -459,7 +466,7 @@ export async function chmodContained( let touched = false; let stage = 'set the file\'s mode'; try { - await checkOpened(view, rootReal, root, parent, target, handle, expect, noFollow, false); + await checkOpened(view, rootReal, rel, root, parent, target, handle, expect, noFollow, false); const current = Number((await handle.stat({ bigint: true })).mode & 0o777n); if (from !== undefined && current !== from) throw new EffectFailed(MODE_CHANGED, false); const narrowing = (current & ~mode & 0o777) !== 0; @@ -525,6 +532,10 @@ export async function unlinkContained(view: MountView, rootReal: string, rel: st if (!view.followSymlinks) throw new EffectFailed(NOT_FOLLOWED, false); const real = await realpath(target).catch(() => null); if (real === null || !contained(rootReal, real)) throw new EffectFailed('a symlink that leaves the mount', false); + // One leading into a nested mount's directory is that mount's: the link + // and the file it leads to are left as they are. + const holder = nestedAtReal(real, view.nested); + if (holder) throw new EffectFailed(leadsInto(view.name, rel, holder), false); const through = await stat(real, { bigint: true }).catch(() => null); if (through === null) throw new EffectFailed(CHANGED, false); // A followed link stands for the file it leads to, so it's removed only diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 420d258a4..fdc6f62bc 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -8,8 +8,8 @@ import { isUtf8 } from 'node:buffer'; import { constants as fsConstants } from 'node:fs'; import type { Stats } from 'node:fs'; -import { open, readdir, stat, lstat, realpath } from 'node:fs/promises'; -import { isAbsolute, join, resolve, relative, sep } from 'node:path'; +import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path'; import type { JsStore } from '@animalabs/chronicle'; import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js'; import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js'; @@ -40,7 +40,7 @@ import { MountWatcher, type FsChange } from './watcher.js'; import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js'; import { DiskAgreement, sameRootIdentity } from './disk-agreement.js'; import { BranchIntents } from './branch-intent.js'; -import { IGNORED, NONBLOCK, OTHER_MOUNT, checkDir, diskSpelling, filesystemTrustsCtime, inOtherMount, linkLeads, linkOnTheWay, linkRefusal, linkWords, locateRoot, nestedHolder, passedOverAt, readPrefix, type LinkFound } from './observe.js'; +import { IGNORED, NONBLOCK, OTHER_MOUNT, checkDir, diskSpelling, filesystemTrustsCtime, inOtherMount, leadsInto, linkLeads, linkOnTheWay, linkRefusal, linkWords, locateRoot, nestedAtReal, nestedHolder, passedOverAt, readPrefix, type LinkFound, type NestedMount } from './observe.js'; import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js'; import { type MountRuntime, @@ -166,8 +166,8 @@ export type WorkspaceReadStage = * use there. */ class NestedMountError extends Error { - constructor(path: string, mount: string, holder: string, use: string) { - super(`"${path}" lies in mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`); + constructor(path: string, mount: string, holder: string, use: string, how: 'lies in' | 'leads into' = 'lies in') { + super(`"${path}" ${how} mount "${holder}", nested in mount "${mount}", which neither reads nor writes it: use "${use}".`); } } @@ -292,6 +292,67 @@ async function linkReadError( return new WorkspaceReadError(code, reason, 'readlink'); } +/** + * `to` beneath `from`, mount-relative and `/`-separated, whatever the + * platform's separator; undefined where it isn't beneath it. Outside, + * `relative` gives a path that climbs with a `..` segment, or an absolute one + * across Windows drives. A name that only begins with two dots, such as + * `..cache`, is inside. + */ +function nestedDir(from: string, to: string): string | undefined { + const rel = relative(from, to); + if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return undefined; + return rel.split(sep).join('/'); +} + +/** A mount nested in another, as the outer mount's otherMounts holds it. */ +interface NestedMountEntry { + name: string; + /** + * Its real root, as realBeneath places it once a start has read it, and + * its root as resolved until then. None where it's the outer mount's own + * or holds it (NestedMount). + */ + real?: string; + /** Its directory as configured and as the real roots place it. */ + fromRoots: Array<{ dir: string; root: string }>; + /** Those, and each as disk spells it where that differs. */ + spellings: Array<{ dir: string; root: string }>; +} + +/** How many links realBeneath follows to a place not made yet before it stops, as the kernel's limit does. */ +const LINKS_FOLLOWED = 40; + +/** + * An absolute path, a mount root's or one beneath it, as realpath resolves + * it, with the links on its way and in it taken out, read without holding + * the process. Where nothing is there yet, a link leads where its target + * would be, and anything else lies beneath its nearest existing directory's + * real path, with the rest as given. A link's target is read from the + * link's directory as it is on disk, and nothing is resolved lexically on + * the way, so a `..` is taken from where the links before it lead, as the + * kernel takes it. Where realpath fails for any other reason, such as a + * loop or a lack of permission, the whole path as path.resolve reads it. + */ +async function realBeneath(given: string, links = 0): Promise { + // A trailing separator, as a link to a directory is often written, names + // the same place, but readlink would then follow the link it names. + const { root } = parse(given); + let absolute = given; + while (absolute.length > root.length && (absolute.endsWith('/') || absolute.endsWith(sep))) absolute = absolute.slice(0, -1); + try { + return await realpath(absolute); + } catch (err) { + const code = errnoOf(err); + const parent = dirname(absolute); + if ((code !== 'ENOENT' && code !== 'ENOTDIR') || links >= LINKS_FOLLOWED || parent === absolute) return resolve(absolute); + const place = await realBeneath(parent, links); + const target = await readlink(absolute).catch(() => null); + if (target !== null) return realBeneath(isAbsolute(target) ? target : `${place}${sep}${target}`, links + 1); + return join(place, basename(absolute)); + } +} + function isContainedPath(root: string, candidate: string): boolean { if (candidate === root) return true; const normalizedRoot = root.endsWith(sep) ? root : root + sep; @@ -733,11 +794,16 @@ export class WorkspaceModule implements Module { private ctimeTrust = new Map(); /** * Each mount's directories that are other mounts nested inside it, with - * each mount's name, in each spelling that names the directory: as - * configured, and as disk spells it where that differs (spellNestedDirs). - * Each spelling is mount-relative and `/`-separated, with its resolved root. + * each mount's name and real root, in each spelling that names the + * directory: as configured; as the real roots place it, where that differs + * (`fromRoots`); and as disk spells each of those where that differs. + * Each spelling is mount-relative and `/`-separated, with its root beneath + * the mount's resolved root. The constructor nests by configured path, + * with each root as resolved standing for its real one, and each start + * reads it all again with the roots placed where disk has them + * (readNesting). */ - private otherMounts = new Map }>>(); + private otherMounts = new Map(); /** * Whether a file's mode is observed, taken in and applied (MountRuntime's * `modesObserved`): not on Windows, which reports no POSIX modes. A test @@ -766,27 +832,22 @@ export class WorkspaceModule implements Module { // named or not, and in its watcher (OTHER_MOUNT). It's kept apart from // the outer mount's ignore list, which a sync that names a path sets // aside for what it names (#276), so no sync of the outer mount takes in - // the inner one's files. + // the inner one's files. Here by configured path, before any path is + // parsed; each start places the roots where disk has them too + // (readNesting). for (const outer of config.mounts) { for (const inner of config.mounts) { if (outer === inner) continue; const outerPath = resolve(outer.path); const innerPath = resolve(inner.path); - const rel = relative(outerPath, innerPath); - // Outside the outer mount, `relative` gives a path that climbs with a - // `..` segment, or an absolute one across Windows drives. A name that - // only begins with two dots, such as `..cache`, is inside it. - if (rel && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)) { - // inner is nested under outer: passed over as exactly that - // directory, read against `/`-separated paths, whatever the - // platform's. - const relPath = rel.split(sep).join('/'); - this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, spellings: [{ dir: relPath, root: innerPath }] }]); - console.warn( - `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` + - `at ${relPath}/ — passing over it as another mount's directory`, - ); - } + const dir = nestedDir(outerPath, innerPath); + if (dir === undefined) continue; + const configured = [{ dir, root: innerPath }]; + this.otherMounts.set(outer.name, [...(this.otherMounts.get(outer.name) ?? []), { name: inner.name, real: innerPath, fromRoots: configured, spellings: [...configured] }]); + console.warn( + `[workspace] Mount "${outer.name}" contains mount "${inner.name}" ` + + `at ${dir}/ — passing over it as another mount's directory`, + ); } } this.config = config; @@ -962,13 +1023,15 @@ export class WorkspaceModule implements Module { } async start(ctx: ModuleContext): Promise { - // Each nested mount's directory as disk spells it, read before this start - // sets anything, so a restart takes no call and walks or watches nothing - // before the spellings are known. A stop begun meanwhile stands, and this - // start then sets nothing. + // Which mounts are nested where, with the roots placed where disk has + // them and each nested mount's directory as disk spells it, read before + // this start sets anything, so a restart takes no call and walks or + // watches nothing before they're known. A stop begun meanwhile stands, + // and this start then sets nothing. const stops = this.stopsBegun; - await this.spellNestedDirs(); + const nesting = await this.readNesting(); if (this.stopsBegun !== stops) return; + this.otherMounts = nesting; this.ctx = ctx; this.stopped = false; @@ -1660,7 +1723,7 @@ export class WorkspaceModule implements Module { try { // Synchronous, so the path as given: it reaches the same file, and // the nested-mount check matches it as configured or as disk spells - // the nested mount's directory (spellNestedDirs). + // the nested mount's directory (readNesting). const { mount, relativePath } = this.refuseNested({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }); return resolve(mount.config.path, relativePath); } catch { @@ -1856,29 +1919,81 @@ export class WorkspaceModule implements Module { } /** - * Adds to each nested mount's directory the spelling disk lists it by, - * where that differs from the configuration's. On a disk that folds names, - * the outer mount's walks name the directory as disk does, and so does a - * path parsePath has respelled, while a path given as configured - * still names it as configured (resolveAbsolutePath). A directory not on - * disk keeps its configured spelling alone. Run at each start. + * Which mounts are nested in which, read afresh at each start. A mount is + * nested in another where its configured path lies beneath the other's + * root, as configured or as it lies on disk, whatever links the path + * reaches that root through, or where its real root lies beneath the + * other's. Each root, and each directory above a configured path, is + * placed by realBeneath, awaited, so a root on a share that stops + * answering holds this start and not the process, and a root moved while + * the module was stopped is placed where it is now. A mount whose real + * root is the other's own, or holds it, is nested by its configured path + * alone, with no real root, since through it it would claim the other's + * every path. + * + * Each way it lies there is a spelling of its directory (`fromRoots`), and + * so is the spelling disk lists each of those by, where that differs. On a + * disk that folds names, the outer mount's walks name the directory as + * disk does, and so does a path parsePath has respelled, while a path given + * as configured still names it as configured (resolveAbsolutePath). A + * directory not on disk keeps the spellings the roots give alone. */ - private async spellNestedDirs(): Promise { - for (const [outerName, nested] of this.otherMounts) { - const outer = this.config.mounts.find((m) => m.name === outerName); - if (!outer) continue; - const outerRoot = resolve(outer.path); - for (const entry of nested) { - const configured = entry.spellings[0]!; - const dir = await diskSpelling(outerRoot, configured.dir); - entry.spellings = dir === configured.dir ? [configured] : [configured, { dir, root: join(outerRoot, ...dir.split('/')) }]; + private async readNesting(): Promise> { + const reals = new Map(); + // Each directory above a mount's configured path, with where it lies on + // disk, so a path that reaches another mount's root through a link, as + // one under macOS's /var reaches /private/var, is still read beneath it. + const above = new Map>(); + for (const mount of this.config.mounts) { + const given = resolve(mount.path); + reals.set(mount.name, await realBeneath(given)); + const dirs: Array<{ dir: string; real: string }> = []; + for (let dir = dirname(given); ; dir = dirname(dir)) { + dirs.push({ dir, real: await realBeneath(dir) }); + if (dirname(dir) === dir) break; } + above.set(mount.name, dirs); } + const nesting = new Map(); + for (const outer of this.config.mounts) { + const outerPath = resolve(outer.path); + const outerReal = reals.get(outer.name)!; + for (const inner of this.config.mounts) { + if (outer === inner) continue; + const real = reals.get(inner.name)!; + const innerPath = resolve(inner.path); + const configured = nestedDir(outerPath, innerPath); + const fromRoots: Array<{ dir: string; root: string }> = []; + // Its directory as configured, read against this mount's root as + // configured and from each directory above it that is this mount's + // root on disk, and its real root against this mount's. + const throughRoot = above.get(inner.name)!.filter((a) => a.real === outerReal).map((a) => nestedDir(a.dir, innerPath)); + for (const dir of [configured, ...throughRoot, nestedDir(outerReal, real)]) { + if (dir !== undefined && !fromRoots.some((s) => s.dir === dir)) fromRoots.push({ dir, root: join(outerPath, ...dir.split('/')) }); + } + if (fromRoots.length === 0) continue; + const spellings = [...fromRoots]; + for (const found of fromRoots) { + const dir = await diskSpelling(outerPath, found.dir); + if (!spellings.some((s) => s.dir === dir)) spellings.push({ dir, root: join(outerPath, ...dir.split('/')) }); + } + const claims = !isContainedPath(real, outerReal); + nesting.set(outer.name, [...(nesting.get(outer.name) ?? []), { name: inner.name, ...(claims ? { real } : {}), fromRoots, spellings }]); + const placed = fromRoots.filter((s) => s.dir !== configured); + if (placed.length > 0) { + console.warn( + `[workspace] Mount "${outer.name}" contains mount "${inner.name}" where it lies on disk, ` + + `at ${placed.map((s) => `${s.dir}/`).join(' and ')} — passing over it as another mount's directory`, + ); + } + } + } + return nesting; } - /** The mounts nested in `mountName`, by directory and name (MountView's `nested`), in each spelling known. */ - private nestedOf(mountName: string): { dir: string; name: string }[] { - return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name }))); + /** The mounts nested in `mountName`, by directory, name and real root where it has one (MountView's `nested`), in each spelling known. */ + private nestedOf(mountName: string): NestedMount[] { + return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => ({ dir: s.dir, name: o.name, real: o.real }))); } /** @@ -1921,7 +2036,33 @@ export class WorkspaceModule implements Module { * it, naming the innermost mount that holds it and the path to use there. */ private async parseFilePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> { - return this.refuseNested(await this.parsePath(path)); + const parsed = this.refuseNested(await this.parsePath(path)); + // A path can also reach a nested mount's directory through a symlink on + // its way. No push of this mount writes it there and no read reads it, + // so it's refused here, before anything is stored, and the checks where + // disk is acted on refuse it again. + const refusal = await this.leadsIntoNested(parsed.mount, parsed.relativePath, parsed.path); + if (refusal) throw refusal; + return parsed; + } + + /** + * The refusal for a path of `mount` that reaches a nested mount's directory + * through a symlink on its way (nestedAtReal), or null where it doesn't. On + * a mount that doesn't follow the link, the refusal is the link's own + * (linkWords), as the readers and pushes give it. A mount with nothing + * nested has nothing to find, so it pays nothing. + */ + private async leadsIntoNested(mount: MountState, relativePath: string, path: string): Promise { + const nested = this.nestedOf(mount.config.name); + if (nested.length === 0) return null; + const holder = nestedAtReal(await realBeneath(resolve(mount.config.path, relativePath)), nested); + if (!holder) return null; + if (mount.config.followSymlinks !== true) { + const found = await linkOnTheWay({ root: mount.config.path, followSymlinks: false }, relativePath); + if (found !== null) return await linkReadError(mount, found, path, nested); + } + return new NestedMountError(path, mount.config.name, holder.name, holder.use, 'leads into'); } /** A parsed path, refused if it lies in a nested mount's directory (parseFilePath). */ @@ -2178,6 +2319,8 @@ export class WorkspaceModule implements Module { if (!isContainedPath(realMountRoot, realFilePath)) { throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath'); } + const holder = nestedAtReal(realFilePath, this.nestedOf(mount.config.name)); + if (holder) throw new WorkspaceReadError('other_mount', leadsInto(mount.config.name, relativePath, holder), 'realpath'); let pathStat: Stats; try { @@ -2238,6 +2381,8 @@ export class WorkspaceModule implements Module { try { ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath)); } catch (err) { + // A link's refusal (parseFilePath) carries its own code. + if (err instanceof WorkspaceReadError) throw err; const message = err instanceof Error ? err.message : String(err); const code: WorkspaceReadErrorCode = err instanceof NestedMountError ? 'other_mount' : message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal'; @@ -2286,10 +2431,10 @@ export class WorkspaceModule implements Module { // where it leads, with the setting for a link to a directory. return new WorkspaceImageReadError('parent_symlink', err.message); case 'other_mount': - // The core reader's own words for a link on its way into a nested - // mount's directory: that mount and the path to use there in place of - // the link. (#460's own other_mount, a path in that directory, is - // refused at parse.) + // The core reader's own words for a path a link takes into a nested + // mount's directory: that mount and the path to use there. (#460's + // own other_mount, a path in that directory, is refused at parse, + // and so is a link found there before the read.) return new WorkspaceImageReadError('other_mount', err.message); case 'escape': // The core reader's own words: "Symlink escape detected: " for a @@ -3101,12 +3246,29 @@ export class WorkspaceModule implements Module { // on: one selected in between would label its content with the other // branch's states. So a pass whose branch is no longer selected runs // again, and a branch that keeps changing leaves the scope unsearched. + // + // A copy held at a path that now leads into a nested mount's directory + // through a link is that mount's file too: read refuses it + // (parseFilePath), so grep searches none of it and says why. The pass + // can't check such a path's disk, so only an unverified one can be + // one. Asking awaits disk, so it's done before the branch is + // compared, and a branch selected meanwhile runs the pass again. let pass: PassResult | null = null; + let refused = new Map(); for (let attempt = 0; attempt < 3 && pass === null; attempt++) { const next = await this.passUnlocked(mount, fileScope ? { kind: 'paths', paths: [relativePath] } : { kind: 'dir', dir: start, recursive: true }); - if (next.branchId === undefined || next.branchId === store.currentBranch().id) pass = next; + const asked = new Map(); + for (const report of next.reports.values()) { + if (report.state !== 'unverified') continue; + const refusal = await this.leadsIntoNested(mount, report.path, `${mount.config.name}/${report.path}`); + if (refusal) asked.set(report.path, refusal.message); + } + if (next.branchId === undefined || next.branchId === store.currentBranch().id) { + pass = next; + refused = asked; + } } if (pass === null) { incomplete.push({ @@ -3136,6 +3298,11 @@ export class WorkspaceModule implements Module { // none of this mount's copy of it, as read refuses it. Beneath a // scope it's passed over by design, like the directory. if (inOtherMount(report.path, this.othersOf(mount.config.name))) continue; + const refusal = refused.get(report.path); + if (refusal !== undefined) { + skipped.push({ file, reason: refusal }); + continue; + } // The workspace's version, labelled with the path's state. const entry = store.treeGet(mount.treeStateId, report.path); const blob = entry ? store.getBlob(entry.blobHash) : null; diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index 772e5a181..e0e8fc841 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -64,13 +64,28 @@ export interface MountView { */ others: readonly string[]; /** - * The same mounts, each by its directory and its name, for a refusal that - * names the mount a symlink leads into. Without it, that refusal names no - * mount. + * The same mounts, each by its directory, its name and, where it has one, + * its real root (NestedMount), for a refusal that names the mount a path + * leads into: through a symlink this mount doesn't follow (linkWords), or + * through one it follows, or a root configured through one (nestedAtReal). + * Without it, no path is refused for where its links lead, and a link's + * refusal names no mount. */ - nested?: readonly { dir: string; name: string }[]; + nested?: readonly NestedMount[]; } +/** + * A mount nested in another: its directory in one of its spellings, + * mount-relative and `/`-separated, its name, and its real root, which + * every spelling shares. The real root is the root as realpath resolves it + * (the nearest existing directory's, where the root isn't made yet) once + * the module's start has read it, and the root as configured until then. + * There's none where it's this mount's own or holds it: the mount is nested + * by its configured path alone, since through its real root it would claim + * this mount's every path. + */ +export type NestedMount = { dir: string; name: string; real?: string }; + export type StatFingerprint = Omit; /** @@ -290,6 +305,31 @@ export async function linkReason(view: Pick (n.real !== undefined && contained(n.real, real) ? [{ name: n.name, real: n.real }] : [])) + .sort((a, b) => b.real.length - a.real.length)[0]; + if (!holder) return undefined; + const rest = relative(holder.real, real).split(sep).join('/'); + return { name: holder.name, use: rest ? `${holder.name}/${rest}` : holder.name }; +} + +/** + * Why a mount-relative path that resolves into a nested mount's directory is + * refused (nestedAtReal): what it leads into, and the path to use in its + * place. Spelled as the tools take paths, from the mount's name. + */ +export function leadsInto(name: string, rel: string, holder: { name: string; use: string }): string { + return `"${rel === '' ? name : `${name}/${rel}`}" leads into mount "${holder.name}", nested in mount "${name}": use "${holder.use}" in its place`; +} + // =========================================================================== // Disk's spelling of a path // =========================================================================== @@ -512,6 +552,11 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis if (real !== rootReal) return { kind: 'unobserved', reason: ROOT_UNAVAILABLE }; } else if (!contained(rootReal, real)) { return { kind: 'unobserved', reason: OUTSIDE_PARENT, outside: true }; + } else { + // Through a link this mount follows, a directory can be a nested + // mount's, which the mount neither reads nor writes. + const holder = nestedAtReal(real, view.nested); + if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, rel, holder) }; } const checked = await checkedAt(view, rel, real); if (checked) { @@ -572,6 +617,10 @@ async function createDir(view: MountView, rootReal: string, rel: string): Promis if (base === null) return { kind: 'unobserved', reason: ROOT_UNAVAILABLE }; for (let i = depth; i < parts.length; i++) { const childRel = parts.slice(0, i + 1).join('/'); + // Nothing is made in a nested mount's directory, its root included where + // it isn't made yet, by a path that reaches it through a link. + const holder = nestedAtReal(base.at(parts[i]!), view.nested); + if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, childRel, holder) }; try { await mkdir(base.at(parts[i]!)); } catch (err) { @@ -742,7 +791,9 @@ async function parentPlacement(view: MountView, rootReal: string, relativePath: const parent = await realpath(dirname(join(view.root, relativePath))); const found = await linkRefusal(view, rootReal, relativePath, parent, 'parents'); if (found !== null) return { reason: await linkReason(view, rootReal, found) }; - return contained(rootReal, parent) ? 'inside' : { reason: OUTSIDE_PARENT }; + if (!contained(rootReal, parent)) return { reason: OUTSIDE_PARENT }; + const holder = nestedAtReal(parent, view.nested); + return holder ? { reason: leadsInto(view.name, parentOf(relativePath), holder) } : 'inside'; } catch (err) { const code = errno(err); if (code === 'ENOENT' || code === 'ENOTDIR') return 'gone'; @@ -788,6 +839,8 @@ export async function observePath(view: MountView, rootReal: string, relativePat return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${errno(err) ?? String(err)})` }; } if (!contained(rootReal, real)) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' }; + const holder = nestedAtReal(real, view.nested); + if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, relativePath, holder) }; if (!target.isFile()) { return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file' }; } @@ -851,6 +904,8 @@ async function openContained( if (!contained(rootReal, real)) { return { kind: 'unobserved', reason: view.followSymlinks ? 'a symlink that leaves the mount' : OUTSIDE_PARENT }; } + const holder = nestedAtReal(real, view.nested); + if (holder) return { kind: 'unobserved', reason: leadsInto(view.name, relativePath, holder) }; const named = await stat(real, { bigint: true }).catch(() => null); if (named === null || named.dev !== info.dev || named.ino !== info.ino) { return { kind: 'unobserved', reason: 'the file changed while it was read' }; diff --git a/src/modules/workspace/watcher.ts b/src/modules/workspace/watcher.ts index 36d643bcf..346d5af27 100644 --- a/src/modules/workspace/watcher.ts +++ b/src/modules/workspace/watcher.ts @@ -7,7 +7,7 @@ import { watch, type FSWatcher } from 'chokidar'; import { mkdirSync, statSync } from 'node:fs'; -import { basename, sep } from 'node:path'; +import { basename, resolve, sep } from 'node:path'; import { passedOverAt } from './observe.js'; import { type MountConfig } from './types.js'; @@ -63,6 +63,13 @@ export class MountWatcher { private stopped = true; private readonly debounceMs: number; private readonly config: MountConfig; + /** + * The root as the watcher makes, watches and checks it, and as each + * event's path is read against: the configured path resolved (a link in it + * kept), since chokidar hands back the paths it reports normalized, and + * those of a root configured as `./m` matched none of them. + */ + private readonly root: string; private readonly onChangeCallback: (changes: FsChange[]) => void; private readonly lifecycle: WatcherLifecycle; @@ -74,6 +81,7 @@ export class MountWatcher { private readonly others: readonly string[] = [], ) { this.config = config; + this.root = resolve(config.path); this.debounceMs = config.watchDebounceMs ?? 300; this.onChangeCallback = onChange; this.lifecycle = lifecycle; @@ -100,7 +108,7 @@ export class MountWatcher { // directories a user asked us only to read would be surprising. if (this.config.mode === 'read-write') { try { - mkdirSync(this.config.path, { recursive: true }); + mkdirSync(this.root, { recursive: true }); } catch (err) { this.lifecycle.onError?.(err instanceof Error ? err : new Error(String(err))); } @@ -108,7 +116,7 @@ export class MountWatcher { const ignore = this.config.ignore ?? []; - this.watcher = watch(this.config.path, { + this.watcher = watch(this.root, { // What a walk passes over by design, by the path relative to the mount: // another mount's directory, the mount's ignore list and editors' swap // and backup files. chokidar 4 @@ -169,7 +177,7 @@ export class MountWatcher { if (event !== 'rename') return; // A rename on the watched root's path/basename is the one signal that // survives inode reuse on Linux, where unlinkDir/error never fire. - if (this.isRootPath(rawPath) || basename(rawPath) === basename(this.config.path)) { + if (this.isRootPath(rawPath) || basename(rawPath) === basename(this.root)) { void this.checkRootLiveness({ force: true }); } }); @@ -366,7 +374,7 @@ export class MountWatcher { } private readRootIdentity(): RootIdentity { - const stat = statSync(this.config.path); + const stat = statSync(this.root); return { dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs }; } @@ -389,9 +397,7 @@ export class MountWatcher { // Chokidar hands back platform-separated absolute paths; compare with // the platform separator and normalize the result to the '/'-separated // logical form the rest of the workspace uses. No-op on POSIX. - const base = this.config.path.endsWith(sep) - ? this.config.path - : this.config.path + sep; + const base = this.root.endsWith(sep) ? this.root : this.root + sep; if (absolutePath.startsWith(base)) { return absolutePath.slice(base.length).split(sep).join('/'); } @@ -399,9 +405,7 @@ export class MountWatcher { } private isRootPath(absolutePath: string): boolean { - const root = this.config.path.endsWith(sep) - ? this.config.path.slice(0, -1) - : this.config.path; + const root = this.root.endsWith(sep) ? this.root.slice(0, -1) : this.root; const candidate = absolutePath.endsWith(sep) ? absolutePath.slice(0, -1) : absolutePath; diff --git a/test/workspace-nested-mount-roots.test.ts b/test/workspace-nested-mount-roots.test.ts new file mode 100644 index 000000000..8316db73e --- /dev/null +++ b/test/workspace-nested-mount-roots.test.ts @@ -0,0 +1,755 @@ +/** + * A mount nested in another is that mount's directory wherever disk puts + * it, not only where the outer mount's paths name it as written. Before, the + * nested-mount checks read paths and roots lexically, so the outer mount + * reached the inner one's directory three ways. A root configured through a + * symlink, or beneath one, wasn't nested at all, and the outer mount took in + * and read the inner mount's files. On a mount that follows symlinks, a + * link into the inner mount's directory let the outer mount read, take in, + * write and unlink the inner mount's files through it. And a write through a + * link the mount doesn't follow stored a draft no push could write. Each + * root is now read at each start, awaited, and nested where it really is; + * every check that resolves a path canonically refuses one in a nested + * mount's directory; and the file tools refuse such a path before anything + * is stored. Separately, a mount configured as `./m` now hears its watcher. + */ + +import { describe, test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { WorkspaceModule, WorkspaceReadError } from '../src/modules/workspace/index.js'; +import { checkDir, readPath, type MountView } from '../src/modules/workspace/observe.js'; +import { EffectFailed, unlinkContained, writeContained } from '../src/modules/workspace/effects.js'; +import { MountWatcher, type FsChange } from '../src/modules/workspace/watcher.js'; +import type { MountConfig } from '../src/modules/workspace/types.js'; +import type { ModuleContext } from '../src/types/module.js'; + +class Env { + readonly root = mkdtempSync(join(tmpdir(), 'af-ws-nested-roots-')); + readonly store: JsStore; + + constructor(t: TestContext) { + this.store = JsStore.openOrCreate({ path: join(this.root, 'ws.chronicle') }); + t.after(() => { + if (!this.store.isClosed()) this.store.close(); + rmSync(this.root, { recursive: true, force: true }); + }); + } + + at(rel: string): string { + return join(this.root, rel); + } + + write(rel: string, content: string): void { + mkdirSync(dirname(this.at(rel)), { recursive: true }); + writeFileSync(this.at(rel), content); + } + + async open(t: TestContext, mounts: MountConfig[]): Promise { + const m = new WorkspaceModule({ mounts }); + m.initStore(this.store); + await m.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + t.after(() => m.stop().catch(() => {})); + return m; + } + + /** The paths a mount's tree holds. */ + tree(mount: string): string[] { + return this.store.treeList(`workspace/${mount}/tree`).map((e: { path: string }) => e.path).sort(); + } +} + +function mount(name: string, path: string, extra: Partial = {}): MountConfig { + return { name, path, mode: 'read-write', watch: 'never', autoMaterialize: false, ...extra }; +} + +async function call(m: WorkspaceModule, name: string, input: Record): Promise<{ success: boolean; error?: string; data?: any }> { + return m.handleToolCall({ id: 't', name, input }) as Promise<{ success: boolean; error?: string; data?: any }>; +} + +/** The refusal a file tool gives a path that reaches a nested mount's directory through a link the mount follows. */ +function leadsInto(path: string, outer: string, inner: string, use: string): string { + return `"${path}" leads into mount "${inner}", nested in mount "${outer}", which neither reads nor writes it: use "${use}".`; +} + +/** Read `path`, the path a refusal named, and check it holds `text`: the named path reaches the file. */ +async function readsBack(m: WorkspaceModule, path: string, text: string): Promise { + const read = await call(m, 'read', { path }); + assert.equal(read.success, true, `${path}: ${read.error}`); + assert.match(read.data.content, new RegExp(text), path); +} + +/** The skipped reason for a path in a materialize's or a sync's result. */ +function skippedReason(data: any, path: string): string { + const entry = ((data.skipped ?? []) as Array<{ path?: string; reason: string }>) + .find((s) => s.path === path || s.reason.startsWith(`${path}: `)); + assert.ok(entry, `nothing skipped for ${path}: ${JSON.stringify(data)}`); + return entry.reason; +} + +/** Run `fn` with node:fs/promises' `name` replaced by `make(original)`, as the code under test sees it. */ +async function withFsp(name: 'lstat' | 'realpath', make: (original: (...args: any[]) => Promise) => (...args: any[]) => Promise, fn: () => Promise): Promise { + const fsp = createRequire(import.meta.url)('node:fs/promises') as Record Promise>; + const original = fsp[name]!; + fsp[name] = make(original); + syncBuiltinESMExports(); + try { + return await fn(); + } finally { + fsp[name] = original; + syncBuiltinESMExports(); + } +} + +describe('a mount root configured through a symlink', () => { + test('an inner mount configured through a link into the outer mount is nested where the link leads', async (t) => { + const env = new Env(t); + env.write('outer/a/b/x.txt', "the inner mount's\n"); + symlinkSync(env.at('outer/a/b'), env.at('via')); + const m = await env.open(t, [mount('outer', env.at('outer')), mount('inner', env.at('via'))]); + assert.equal((await call(m, 'sync', {})).success, true); + assert.deepEqual(env.tree('outer'), [], "the outer mount took none of the inner mount's files in"); + assert.deepEqual(env.tree('inner'), ['x.txt']); + const read = await call(m, 'read', { path: 'outer/a/b/x.txt' }); + assert.equal(read.success, false); + assert.equal(read.error, '"outer/a/b/x.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/x.txt".'); + await readsBack(m, 'inner/x.txt', "the inner mount's"); + }); + + test('an inner root configured as a link to a directory not made yet inside the outer mount is nested where the link leads', async (t) => { + for (const absolute of [false, true]) { + const env = new Env(t); + env.write('outer/top.txt', "the outer mount's\n"); + symlinkSync(absolute ? env.at('outer/c/inner-dir') : 'outer/c/inner-dir', env.at('via')); + const m = await env.open(t, [mount('outer', env.at('outer')), mount('inner', env.at('via'))]); + // The inner mount's directory is made after the module starts. + env.write('outer/c/inner-dir/x.txt', "the inner mount's\n"); + assert.equal((await call(m, 'sync', {})).success, true); + assert.deepEqual(env.tree('outer'), ['top.txt'], `the outer mount took none of the inner mount's files in (absolute target: ${absolute})`); + const read = await call(m, 'read', { path: 'outer/c/inner-dir/x.txt' }); + assert.equal(read.error, '"outer/c/inner-dir/x.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/x.txt".'); + await readsBack(m, 'inner/x.txt', "the inner mount's"); + } + }); + + test('before its first start, the module nests by configured path alone', async (t) => { + // The roots are read at start, awaited; until then a call has the + // nesting the constructor reads from the configured paths. + const env = new Env(t); + env.write('outer/a/b/x.txt', "the linked inner mount's\n"); + env.write('outer/c/y.txt', "the inner mount's\n"); + symlinkSync(env.at('outer/a/b'), env.at('via')); + const m = new WorkspaceModule({ mounts: [mount('outer', env.at('outer')), mount('linked', env.at('via')), mount('inner', env.at('outer/c'))] }); + m.initStore(env.store); + t.after(() => m.stop().catch(() => {})); + assert.equal(m.resolveAbsolutePath('outer/c/y.txt'), null, 'nested by its configured path'); + assert.equal(m.resolveAbsolutePath('outer/a/b/x.txt'), join(env.at('outer'), 'a/b/x.txt'), 'not yet placed where its root is'); + await m.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + assert.equal(m.resolveAbsolutePath('outer/a/b/x.txt'), null, 'placed at start'); + }); + + test('a root configured as a link to a link to a place not made yet is placed where that leads, however the target is written', async (t) => { + // w/L2 leads to made-later, not made yet. The inner mount is configured + // as w/in, a link to L2 written plain, with a trailing separator, and + // with a doubled one beneath it (Ogden-2688's case). + for (const [target, dir] of [['L2', 'made-later'], ['L2/', 'made-later'], ['L2//sub', 'made-later/sub']] as const) { + const env = new Env(t); + mkdirSync(env.at('w'), { recursive: true }); + symlinkSync('made-later', env.at('w/L2')); + symlinkSync(target, env.at('w/in')); + const m = await env.open(t, [mount('w', env.at('w')), mount('in', env.at('w/in'))]); + env.write(`w/${dir}/f.txt`, "the inner mount's\n"); + const read = await call(m, 'read', { path: `w/${dir}/f.txt` }); + assert.equal(read.error, `"w/${dir}/f.txt" lies in mount "in", nested in mount "w", which neither reads nor writes it: use "in/f.txt".`, target); + await readsBack(m, 'in/f.txt', "the inner mount's"); + } + }); + + test('a mount configured as a link to the outer mount\'s own root is nested by its configured path alone, and refuses none of the outer mount\'s other paths', async (t) => { + const env = new Env(t); + env.write('outer/x.txt', "the outer mount's\n"); + symlinkSync(env.at('outer'), env.at('outer/self')); + const m = await env.open(t, [mount('outer', env.at('outer'), { followSymlinks: true }), mount('self', env.at('outer/self'))]); + assert.equal((await call(m, 'sync', {})).success, true); + assert.deepEqual(env.tree('outer'), ['x.txt'], 'the outer mount passed over the link'); + await readsBack(m, 'outer/x.txt', "the outer mount's"); + const write = await call(m, 'write', { path: 'outer/y.txt', content: 'outer wrote\n' }); + assert.equal(write.success, true, write.error); + const through = await call(m, 'read', { path: 'outer/self/x.txt' }); + assert.equal(through.error, '"outer/self/x.txt" lies in mount "self", nested in mount "outer", which neither reads nor writes it: use "self/x.txt".'); + await readsBack(m, 'self/x.txt', "the outer mount's"); + }); + + test('an outer mount configured through a link keeps the nesting by configured path of a mount configured as a link back to its root', async (t) => { + // ol leads to o, and o/self leads back to o: self's configured path lies + // beneath the outer mount's root as it really is, not as configured. + const env = new Env(t); + env.write('o/x.txt', "the outer mount's\n"); + symlinkSync(env.at('o'), env.at('ol')); + symlinkSync(env.at('o'), env.at('o/self')); + const m = await env.open(t, [mount('outer', env.at('ol'), { followSymlinks: true }), mount('self', env.at('o/self'))]); + assert.equal((await call(m, 'sync', {})).success, true); + assert.deepEqual(env.tree('outer'), ['x.txt'], 'the outer mount passed over the link'); + const through = await call(m, 'read', { path: 'outer/self/x.txt' }); + assert.equal(through.error, '"outer/self/x.txt" lies in mount "self", nested in mount "outer", which neither reads nor writes it: use "self/x.txt".'); + }); + + test('a mount configured as a link back to the outer mount\'s root keeps the nesting by configured path when its path reaches that root through a link above it', async (t) => { + // alias leads to real, as macOS's /var leads to /private/var, and self's + // configured path reaches the outer mount's root through it. + const env = new Env(t); + env.write('real/o/x.txt', "the outer mount's\n"); + symlinkSync(env.at('real'), env.at('alias')); + symlinkSync(env.at('real/o'), env.at('real/o/self')); + const m = await env.open(t, [mount('outer', env.at('real/o'), { followSymlinks: true }), mount('self', env.at('alias/o/self'))]); + assert.equal((await call(m, 'sync', {})).success, true); + assert.deepEqual(env.tree('outer'), ['x.txt'], 'the outer mount passed over the link'); + const through = await call(m, 'read', { path: 'outer/self/x.txt' }); + assert.equal(through.error, '"outer/self/x.txt" lies in mount "self", nested in mount "outer", which neither reads nor writes it: use "self/x.txt".'); + }); + + test('an outer mount configured through a link keeps the nesting by configured path of a mount configured through a link inside it that leads out of it', async (t) => { + // o/via leads to e, outside the outer mount, and e/self back to o. self's + // configured path reaches the outer mount's root at o, above via, so the + // outer mount configured through ol nests it as one configured at o does. + // Its parent placed on disk is e, which isn't beneath that root. + const env = new Env(t); + env.write('o/x.txt', "the outer mount's\n"); + mkdirSync(env.at('e')); + symlinkSync(env.at('e'), env.at('o/via')); + symlinkSync(env.at('o'), env.at('e/self')); + symlinkSync(env.at('o'), env.at('ol')); + const m = await env.open(t, [mount('outer', env.at('ol'), { followSymlinks: true }), mount('self', env.at('o/via/self'))]); + const through = await call(m, 'read', { path: 'outer/via/self/x.txt' }); + assert.equal(through.error, '"outer/via/self/x.txt" lies in mount "self", nested in mount "outer", which neither reads nor writes it: use "self/x.txt".'); + }); + + test('a mount configured as a link inside the outer mount to a directory above it is nested by its configured path, and holds the outer mount where it is', async (t) => { + const env = new Env(t); + env.write('top/top.txt', "the upper mount's\n"); + env.write('top/outer/x.txt', "the outer mount's\n"); + symlinkSync(env.at('top'), env.at('top/outer/up')); + const m = await env.open(t, [mount('outer', env.at('top/outer')), mount('up', env.at('top/outer/up'))]); + assert.equal((await call(m, 'sync', {})).success, true); + assert.deepEqual(env.tree('outer'), ['x.txt']); + assert.deepEqual(env.tree('up'), ['top.txt']); + const through = await call(m, 'read', { path: 'outer/up/top.txt' }); + assert.equal(through.error, '"outer/up/top.txt" lies in mount "up", nested in mount "outer", which neither reads nor writes it: use "up/top.txt".'); + const write = await call(m, 'write', { path: 'outer/up/new.txt', content: 'outer wrote\n' }); + assert.equal(write.error, '"outer/up/new.txt" lies in mount "up", nested in mount "outer", which neither reads nor writes it: use "up/new.txt".'); + const back = await call(m, 'read', { path: 'up/outer/x.txt' }); + assert.equal(back.error, '"up/outer/x.txt" lies in mount "outer", nested in mount "up", which neither reads nor writes it: use "outer/x.txt".'); + await readsBack(m, 'up/top.txt', "the upper mount's"); + await readsBack(m, 'outer/x.txt', "the outer mount's"); + }); + + test('an inner mount configured through a link inside the outer mount is passed over at both of its directories', async (t) => { + // Configured as outer/via, which leads to outer/a/b: the walk reaches + // the inner mount's files under a/b, the real directory, and a path + // written through via names them too. + const env = new Env(t); + env.write('outer/a/b/x.txt', "the inner mount's\n"); + env.write('outer/top.txt', "the outer mount's\n"); + symlinkSync(env.at('outer/a/b'), env.at('outer/via')); + const m = await env.open(t, [mount('outer', env.at('outer')), mount('inner', env.at('outer/via'))]); + assert.equal((await call(m, 'sync', {})).success, true); + assert.deepEqual(env.tree('outer'), ['top.txt'], "the outer mount took none of the inner mount's files in"); + for (const [path, use] of [['outer/a/b/x.txt', 'inner/x.txt'], ['outer/via/x.txt', 'inner/x.txt']] as const) { + const read = await call(m, 'read', { path }); + assert.equal(read.success, false, path); + assert.equal(read.error, `"${path}" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "${use}".`); + } + }); + + test('an outer mount configured through a link holds an inner mount configured at its real path', async (t) => { + const env = new Env(t); + env.write('real-outer/a/b/x.txt', "the inner mount's\n"); + symlinkSync(env.at('real-outer'), env.at('outer-link')); + const m = await env.open(t, [mount('outer', env.at('outer-link')), mount('inner', env.at('real-outer/a/b'))]); + assert.equal((await call(m, 'sync', {})).success, true); + assert.deepEqual(env.tree('outer'), []); + const read = await call(m, 'read', { path: 'outer/a/b/x.txt' }); + assert.equal(read.error, '"outer/a/b/x.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/x.txt".'); + }); + + test('an inner root not made yet, beneath a link into the outer mount, is nested where it would be made', async (t) => { + const env = new Env(t); + env.write('outer/a/keep.txt', "the outer mount's\n"); + symlinkSync(env.at('outer/a'), env.at('via')); + const m = await env.open(t, [mount('outer', env.at('outer'), { autoMaterialize: true }), mount('inner', env.at('via/newdir'))]); + const write = await call(m, 'write', { path: 'outer/a/newdir/x.txt', content: 'outer wrote\n' }); + assert.equal(write.success, false); + assert.equal(write.error, '"outer/a/newdir/x.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/x.txt".'); + assert.equal(existsSync(env.at('outer/a/newdir')), false, "the inner mount's root wasn't made by the outer mount"); + }); +}); + +describe('a link into a nested mount\'s directory, on a mount that follows symlinks', () => { + test('a read or write through it is refused before anything is stored, naming the path to use (Esther-2584\'s case)', async (t) => { + const env = new Env(t); + env.write('work/nested/sub/x.txt', 'inner file\n'); + symlinkSync('nested/sub', env.at('work/l')); + const m = await env.open(t, [ + mount('work', env.at('work'), { followSymlinks: true, autoMaterialize: true }), + mount('inner', env.at('work/nested'), { autoMaterialize: true }), + ]); + const read = await call(m, 'read', { path: 'work/l/x.txt' }); + assert.equal(read.success, false); + assert.equal(read.error, leadsInto('work/l/x.txt', 'work', 'inner', 'inner/sub/x.txt')); + await readsBack(m, 'inner/sub/x.txt', 'inner file'); + const write = await call(m, 'write', { path: 'work/l/new.txt', content: 'via work\n' }); + assert.equal(write.success, false); + assert.equal(write.error, leadsInto('work/l/new.txt', 'work', 'inner', 'inner/sub/new.txt')); + assert.equal(existsSync(env.at('work/nested/sub/new.txt')), false); + assert.deepEqual(env.tree('work'), [], 'nothing was stored'); + await assert.rejects(m.readFileFromDisk('work/l/x.txt'), (err: unknown) => { + assert.ok(err instanceof WorkspaceReadError, String(err)); + assert.equal(err.code, 'other_mount'); + assert.equal(err.message, leadsInto('work/l/x.txt', 'work', 'inner', 'inner/sub/x.txt')); + return true; + }); + assert.deepEqual(await m.readBinaryFromDisk('work/l/x.txt'), { error: leadsInto('work/l/x.txt', 'work', 'inner', 'inner/sub/x.txt') }); + }); + + test('a sync naming a path through it takes nothing in, and says where the path leads', async (t) => { + const env = new Env(t); + env.write('work/nested/sub/x.txt', 'inner file\n'); + symlinkSync('nested/sub', env.at('work/l')); + const m = await env.open(t, [mount('work', env.at('work'), { followSymlinks: true }), mount('inner', env.at('work/nested'))]); + const sync = await call(m, 'sync', { path: 'work/l/x.txt' }); + assert.equal(sync.success, true, sync.error); + assert.equal(skippedReason(sync.data, 'l/x.txt'), 'disk could not be checked: "work/l" leads into mount "inner", nested in mount "work": use "inner/sub" in its place'); + assert.deepEqual(env.tree('work'), []); + }); + + test('a path through a link to a directory holding the nested mount is refused where it reaches it, and the rest is the outer mount\'s (Ada-1017\'s case)', async (t) => { + const env = new Env(t); + env.write('work/a/b/x.txt', 'inner file\n'); + env.write('work/a/top.txt', 'outer file under a\n'); + symlinkSync('a', env.at('work/l')); + const m = await env.open(t, [ + mount('work', env.at('work'), { followSymlinks: true, autoMaterialize: true }), + mount('inner', env.at('work/a/b'), { autoMaterialize: true }), + ]); + const read = await call(m, 'read', { path: 'work/l/b/x.txt' }); + assert.equal(read.error, leadsInto('work/l/b/x.txt', 'work', 'inner', 'inner/x.txt')); + await readsBack(m, 'inner/x.txt', 'inner file'); + const top = await call(m, 'read', { path: 'work/l/top.txt' }); + assert.equal(top.success, true, top.error); + assert.match(top.data.content, /outer file under a/); + for (const [path, use] of [['work/l/b/new.txt', 'inner/new.txt'], ['work/l/b/deep/new.txt', 'inner/deep/new.txt']] as const) { + const write = await call(m, 'write', { path, content: 'via work\n' }); + assert.equal(write.error, leadsInto(path, 'work', 'inner', use)); + } + assert.equal(existsSync(env.at('work/a/b/new.txt')), false); + assert.equal(existsSync(env.at('work/a/b/deep')), false); + }); + + test('a link to a place not made yet in it leads there: a write through it is refused before anything is stored', async (t) => { + const env = new Env(t); + env.write('work/nested/keep.txt', 'inner\n'); + symlinkSync('nested/new.txt', env.at('work/flink.txt')); + symlinkSync('nested/sub', env.at('work/l')); + const m = await env.open(t, [ + mount('work', env.at('work'), { followSymlinks: true, autoMaterialize: true }), + mount('inner', env.at('work/nested'), { autoMaterialize: true }), + ]); + for (const [path, use] of [['work/flink.txt', 'inner/new.txt'], ['work/l/x.txt', 'inner/sub/x.txt']] as const) { + const write = await call(m, 'write', { path, content: 'via work\n' }); + assert.equal(write.error, leadsInto(path, 'work', 'inner', use)); + } + assert.deepEqual(env.tree('work'), [], 'nothing was stored'); + assert.equal(existsSync(env.at('work/nested/new.txt')), false); + assert.equal(existsSync(env.at('work/nested/sub')), false); + }); + + test('a link to a place not made yet is read from its directory as it is on disk, a `..` in its target included', async (t) => { + // work/alias leads to nested/sub, where flink.txt leads to ../y.txt, not + // made yet. On disk that's nested/y.txt, in the nested mount; read from + // the path as written, it would be work/y.txt. + const env = new Env(t); + env.write('work/nested/sub/keep.txt', 'inner\n'); + symlinkSync('nested/sub', env.at('work/alias')); + symlinkSync('../y.txt', env.at('work/nested/sub/flink.txt')); + const m = await env.open(t, [mount('work', env.at('work'), { followSymlinks: true }), mount('inner', env.at('work/nested'))]); + const write = await call(m, 'write', { path: 'work/alias/flink.txt', content: 'via work\n' }); + assert.equal(write.error, leadsInto('work/alias/flink.txt', 'work', 'inner', 'inner/y.txt')); + // A `..` after a link inside the target is taken from where that link + // leads too: x leads to nested/sub/deep, so x/../z.txt is nested/sub/z.txt. + env.write('work/nested/sub/deep/keep.txt', 'inner\n'); + symlinkSync('nested/sub/deep', env.at('work/x')); + symlinkSync('x/../z.txt', env.at('work/flink2.txt')); + const second = await call(m, 'write', { path: 'work/flink2.txt', content: 'via work\n' }); + assert.equal(second.error, leadsInto('work/flink2.txt', 'work', 'inner', 'inner/sub/z.txt')); + assert.deepEqual(env.tree('work'), [], 'nothing was stored'); + assert.equal(existsSync(env.at('work/nested/y.txt')), false); + assert.equal(existsSync(env.at('work/nested/sub/z.txt')), false); + }); + + test('read_image refuses a path that now leads into it, rather than give the copy held from before', async (t) => { + const env = new Env(t); + mkdirSync(env.at('work/l'), { recursive: true }); + env.write('work/nested/sub/keep.txt', 'inner\n'); + const m = await env.open(t, [mount('work', env.at('work'), { followSymlinks: true }), mount('inner', env.at('work/nested'))]); + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==', 'base64'); + const saved = await m.writeBinary('work/l/img.png', png, 'image/png'); + assert.equal(saved.success, true, saved.error); + rmSync(env.at('work/l'), { recursive: true, force: true }); + symlinkSync('nested/sub', env.at('work/l')); + const image = await call(m, 'read_image', { path: 'work/l/img.png' }); + assert.equal(image.success, false); + assert.equal(image.error, leadsInto('work/l/img.png', 'work', 'inner', 'inner/sub/img.png')); + }); + + test('a file link into it is taken in by no sync, and a copy held from before is neither read, searched nor pushed through it', async (t) => { + const env = new Env(t); + env.write('outer/a/b/x.txt', "the inner mount's\n"); + env.write('outer/flink.txt', "the outer mount's copy\n"); + const m = await env.open(t, [mount('outer', env.at('outer'), { followSymlinks: true }), mount('inner', env.at('outer/a/b'))]); + assert.equal((await call(m, 'sync', {})).success, true); + rmSync(env.at('outer/flink.txt')); + symlinkSync(env.at('outer/a/b/x.txt'), env.at('outer/flink.txt')); + const sync = await call(m, 'sync', {}); + assert.equal(skippedReason(sync.data, 'flink.txt'), 'disk could not be checked: "outer/flink.txt" leads into mount "inner", nested in mount "outer": use "inner/x.txt" in its place'); + const read = await call(m, 'read', { path: 'outer/flink.txt' }); + assert.equal(read.error, leadsInto('outer/flink.txt', 'outer', 'inner', 'inner/x.txt')); + for (const path of ['outer', 'outer/flink.txt']) { + const grep = await call(m, 'grep', { pattern: 'copy', path }); + assert.deepEqual(grep.data.results, [], `grep of ${path} searched none of the copy held from before`); + assert.deepEqual(grep.data.skipped, [{ file: 'outer/flink.txt', reason: leadsInto('outer/flink.txt', 'outer', 'inner', 'inner/x.txt') }], path); + } + // A write made now, with the link in place, is refused at the tool + // (anima-research/agent-framework#530's third case). + const write = await call(m, 'write', { path: 'outer/flink.txt', content: 'outer wrote\n' }); + assert.equal(write.error, leadsInto('outer/flink.txt', 'outer', 'inner', 'inner/x.txt')); + const materialize = await call(m, 'materialize', { path: 'outer/flink.txt', force: true }); + assert.equal(materialize.data.count, 0); + assert.equal(skippedReason(materialize.data, 'flink.txt'), 'flink.txt: not written: "outer/flink.txt" leads into mount "inner", nested in mount "outer": use "inner/x.txt" in its place'); + assert.equal(readFileSync(env.at('outer/a/b/x.txt'), 'utf8'), "the inner mount's\n"); + }); + + test('grep gives each file with the states of the branch its text comes from, when a branch is selected while it asks about a held copy', async (t) => { + // grep asks whether each path whose disk its pass couldn't check leads + // into a nested mount, which awaits a realpath. A branch selected in that + // await mustn't have its text labelled with the states of the pass. + const env = new Env(t); + env.write('work/l/x.txt', 'outer copy\n'); + env.write('work/nested/sub/keep.txt', 'inner\n'); + env.write('work/z.txt', 'main\n'); + const m = await env.open(t, [mount('work', env.at('work'), { followSymlinks: true }), mount('inner', env.at('work/nested'))]); + assert.equal((await call(m, 'sync', {})).success, true); + const main = env.store.currentBranch().name; + env.store.createBranch('other', main); + env.store.switchBranch('other'); + assert.equal((await call(m, 'write', { path: 'work/z.txt', content: 'OTHER\n' })).success, true); + env.store.switchBranch(main); + rmSync(env.at('work/l'), { recursive: true }); + symlinkSync('nested/sub', env.at('work/l')); + let switched = false; + const grep = await withFsp('realpath', (realpath) => async (path: string, ...rest: unknown[]) => { + if (!switched && String(path) === env.at('work/l/x.txt')) { + switched = true; + env.store.switchBranch('other'); + } + return realpath(path, ...rest); + }, () => call(m, 'grep', { pattern: 'main|OTHER', path: 'work' })); + assert.ok(switched, 'a branch was selected while grep asked'); + const settled = await call(m, 'grep', { pattern: 'main|OTHER', path: 'work' }); + assert.notEqual(settled.data.results[0]?.state, 'synced', "z.txt's state on the other branch isn't main's"); + assert.deepEqual(grep.data.results, settled.data.results, "grep gave the other branch's text with that branch's states"); + }); + + for (const autoMaterialize of [false, true]) test(`a draft made before a file link into it took the path's place gets the path to use from a push, unforced and forced (autoMaterialize ${autoMaterialize})`, async (t) => { + // anima-research/agent-framework#530's first two cases. On `main` the + // unforced push skipped the path with advice to force it, and the forced + // push wrote into the inner mount's file. + const env = new Env(t); + env.write('outer/a/b/x.txt', "the inner mount's\n"); + const m = await env.open(t, [mount('outer', env.at('outer'), { followSymlinks: true, autoMaterialize }), mount('inner', env.at('outer/a/b'))]); + assert.equal((await call(m, 'write', { path: 'outer/flink.txt', content: 'outer wrote\n' })).success, true); + if (existsSync(env.at('outer/flink.txt'))) rmSync(env.at('outer/flink.txt')); + symlinkSync(env.at('outer/a/b/x.txt'), env.at('outer/flink.txt')); + const unforced = await call(m, 'materialize', { path: 'outer/flink.txt' }); + assert.equal(unforced.data.count, 0); + assert.equal(skippedReason(unforced.data, 'flink.txt'), 'flink.txt: cannot verify disk copy ("outer/flink.txt" leads into mount "inner", nested in mount "outer": use "inner/x.txt" in its place) — fix it and materialize again'); + const forced = await call(m, 'materialize', { path: 'outer/flink.txt', force: true }); + assert.equal(forced.data.count, 0); + assert.equal(skippedReason(forced.data, 'flink.txt'), 'flink.txt: not written: "outer/flink.txt" leads into mount "inner", nested in mount "outer": use "inner/x.txt" in its place'); + assert.equal(readFileSync(env.at('outer/a/b/x.txt'), 'utf8'), "the inner mount's\n"); + }); + + test('a deletion stored before a link into it took the path\'s place is refused by a forced push, through a final link and through a directory link', async (t) => { + // anima-research/agent-framework#530's unlink side. On `main` the push + // unlinked the final link, and through the directory link it unlinked + // the inner mount's file. + const env = new Env(t); + env.write('outer/a/b/x.txt', "the inner mount's\n"); + env.write('outer/flink.txt', "the outer mount's\n"); + env.write('outer/alias/x.txt', "the outer mount's\n"); + const m = await env.open(t, [mount('outer', env.at('outer'), { followSymlinks: true }), mount('inner', env.at('outer/a/b'))]); + assert.equal((await call(m, 'sync', {})).success, true); + for (const path of ['outer/flink.txt', 'outer/alias/x.txt']) assert.equal((await call(m, 'delete', { path })).success, true, path); + rmSync(env.at('outer/flink.txt')); + symlinkSync(env.at('outer/a/b/x.txt'), env.at('outer/flink.txt')); + rmSync(env.at('outer/alias'), { recursive: true }); + symlinkSync(env.at('outer/a/b'), env.at('outer/alias')); + const finalLink = await call(m, 'materialize', { path: 'outer/flink.txt', force: true, applyDeletions: true }); + assert.equal(skippedReason(finalLink.data, 'flink.txt'), 'flink.txt: not unlinked: "outer/flink.txt" leads into mount "inner", nested in mount "outer": use "inner/x.txt" in its place'); + const throughDir = await call(m, 'materialize', { path: 'outer/alias/x.txt', force: true, applyDeletions: true }); + assert.equal(skippedReason(throughDir.data, 'alias/x.txt'), 'alias/x.txt: not unlinked: "outer/alias" leads into mount "inner", nested in mount "outer": use "inner" in its place'); + assert.equal(lstatSync(env.at('outer/flink.txt')).isSymbolicLink(), true, 'the link stays'); + assert.equal(readFileSync(env.at('outer/a/b/x.txt'), 'utf8'), "the inner mount's\n"); + }); + + test('a copy taken in through a file link before the nested mount was configured is no longer vouched for by its fingerprint', async (t) => { + // A listing's pass takes a file whose recorded fingerprint still matches + // as unchanged, without reading it, so only the observation of the link + // can say it now leads into another mount's directory. + const env = new Env(t); + env.write('outer/a/b/x.txt', "the inner mount's\n"); + symlinkSync(env.at('outer/a/b/x.txt'), env.at('outer/flink.txt')); + // Each module trusts this filesystem's ctime, whatever it is, so the + // fingerprint vouches, as on Linux's. + const trust = (m: WorkspaceModule) => (m as any).ctimeTrust.set(realpathSync(env.at('outer')), statSync(env.at('outer/a/b/x.txt'), { bigint: true }).dev.toString()); + const before = new WorkspaceModule({ mounts: [mount('outer', env.at('outer'), { followSymlinks: true })] }); + trust(before); + before.initStore(env.store); + await before.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + await new Promise((r) => setTimeout(r, 2100)); // past the racy window, so the fingerprint the sync records vouches + assert.equal((await call(before, 'sync', {})).success, true); + await before.stop(); + assert.deepEqual(env.tree('outer'), ['a/b/x.txt', 'flink.txt']); + const m = new WorkspaceModule({ mounts: [mount('outer', env.at('outer'), { followSymlinks: true }), mount('inner', env.at('outer/a/b'))] }); + trust(m); + m.initStore(env.store); + await m.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + t.after(() => m.stop().catch(() => {})); + const ls = await call(m, 'ls', { path: 'outer' }); + assert.equal(ls.success, true, ls.error); + const entry = (ls.data.entries as Array<{ name: string; state?: string; note?: string }>).find((e) => e.name === 'flink.txt'); + assert.deepEqual({ state: entry?.state, note: entry?.note }, { + state: 'unverified', note: '"outer/flink.txt" leads into mount "inner", nested in mount "outer": use "inner/x.txt" in its place', + }); + }); + + test('a directory replaced by a link into it is the nested mount\'s: a forced push of a copy held from before writes nothing (#324)', async (t) => { + const env = new Env(t); + env.write('outer/alias/x.txt', 'old copy\n'); + env.write('outer/a/b/keep.txt', 'inner\n'); + const m = await env.open(t, [mount('outer', env.at('outer'), { followSymlinks: true }), mount('inner', env.at('outer/a/b'))]); + assert.equal((await call(m, 'read', { path: 'outer/alias/x.txt' })).success, true); + rmSync(env.at('outer/alias'), { recursive: true }); + symlinkSync(env.at('outer/a/b'), env.at('outer/alias')); + env.write('outer/a/b/x.txt', 'inner v2\n'); + const materialize = await call(m, 'materialize', { path: 'outer/alias/x.txt', force: true }); + assert.equal(materialize.data.count, 0); + assert.equal(skippedReason(materialize.data, 'alias/x.txt'), 'alias/x.txt: not written: "outer/alias" leads into mount "inner", nested in mount "outer": use "inner" in its place'); + assert.equal(readFileSync(env.at('outer/a/b/x.txt'), 'utf8'), 'inner v2\n'); + await readsBack(m, 'inner/x.txt', 'inner v2'); + for (const [name, input] of [ + ['edit', { path: 'outer/alias/x.txt', oldString: 'old', newString: 'outer-edited' }], + ['write', { path: 'outer/alias/y.txt', content: 'outer wrote y\n' }], + ['delete', { path: 'outer/alias/x.txt' }], + ] as const) { + const res = await call(m, name, input); + assert.equal(res.error, leadsInto(String(input.path), 'outer', 'inner', `inner/${String(input.path).slice('outer/alias/'.length)}`), name); + } + assert.equal(existsSync(env.at('outer/a/b/y.txt')), false); + assert.equal(readFileSync(env.at('outer/a/b/x.txt'), 'utf8'), 'inner v2\n'); + }); + + test('the disk reader refuses a path whose directory became a link into it after the tools\' check', async (t) => { + const env = new Env(t); + env.write('work/l/x.txt', "the outer mount's\n"); + env.write('work/nested/sub/x.txt', 'inner file\n'); + const m = await env.open(t, [mount('work', env.at('work'), { followSymlinks: true }), mount('inner', env.at('work/nested'))]); + let swapped = false; + await withFsp('lstat', (lstat) => async (path: string, ...rest: unknown[]) => { + // The reader's lstat of the path comes after the tools' check, which + // found it the outer mount's: the directory becomes a link between. + if (!swapped && String(path) === env.at('work/l/x.txt')) { + swapped = true; + renameSync(env.at('work/l'), env.at('work/l-was')); + symlinkSync('nested/sub', env.at('work/l')); + } + return lstat(path, ...rest); + }, async () => { + await assert.rejects(m.readFileFromDisk('work/l/x.txt'), (err: unknown) => { + assert.ok(err instanceof WorkspaceReadError, String(err)); + assert.equal(err.code, 'other_mount'); + assert.equal(err.message, '"work/l/x.txt" leads into mount "inner", nested in mount "work": use "inner/sub/x.txt" in its place'); + return true; + }); + }); + assert.equal(swapped, true); + }); + + test('read_image refuses a path whose directory became a link into it after the tools\' check, over the copy held from before', async (t) => { + // The copy isn't an image, so a disk refusal read as "not found" would + // answer the copy's failure instead (#468's case, which this pins now + // that the tools' check refuses the write that used to store the copy). + const env = new Env(t); + env.write('work/l/img.png', 'not an image\n'); + env.write('work/nested/sub/img.png', 'the inner mount\'s\n'); + const m = await env.open(t, [mount('work', env.at('work'), { followSymlinks: true }), mount('inner', env.at('work/nested'))]); + assert.equal((await call(m, 'sync', {})).success, true); + assert.ok(env.tree('work').includes('l/img.png'), 'the outer mount holds the copy'); + let swapped = false; + await withFsp('lstat', (lstat) => async (path: string, ...rest: unknown[]) => { + if (!swapped && String(path) === env.at('work/l/img.png')) { + swapped = true; + renameSync(env.at('work/l'), env.at('work/l-was')); + symlinkSync('nested/sub', env.at('work/l')); + } + return lstat(path, ...rest); + }, async () => { + const image = await call(m, 'read_image', { path: 'work/l/img.png' }); + assert.equal(image.success, false); + assert.equal(image.error, '"work/l/img.png" leads into mount "inner", nested in mount "work": use "inner/sub/img.png" in its place'); + }); + assert.equal(swapped, true); + }); +}); + +describe('a root placed at each start', () => { + test('a root moved while the module was stopped is placed where it is at the next start', async (t) => { + const env = new Env(t); + env.write('outer/a/b/x.txt', 'under a/b\n'); + env.write('outer/c/y.txt', 'under c\n'); + symlinkSync(env.at('outer/a/b'), env.at('via')); + const m = await env.open(t, [mount('outer', env.at('outer')), mount('inner', env.at('via'))]); + const ctx = { isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext; + assert.equal((await call(m, 'read', { path: 'outer/c/y.txt' })).success, true); + await m.stop(); + rmSync(env.at('via')); + symlinkSync(env.at('outer/c'), env.at('via')); + await m.start({ ...ctx, isRestart: true } as unknown as ModuleContext); + const read = await call(m, 'read', { path: 'outer/c/y.txt' }); + assert.equal(read.error, '"outer/c/y.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/y.txt".'); + await readsBack(m, 'outer/a/b/x.txt', 'under a/b'); + }); +}); + +describe('a link into a nested mount\'s directory, on a mount that doesn\'t follow symlinks', () => { + test('a path that is itself a link into it is refused with the mount it leads into, by the tools and the disk reader, and nothing is stored', async (t) => { + // On `main`, readFileFromDisk answered `symlink` ("Symlinks are not + // allowed"), and a write stored a draft that no push could write. + const env = new Env(t); + env.write('work/nested/x.txt', 'inner file\n'); + symlinkSync('nested/x.txt', env.at('work/flink.txt')); + const m = await env.open(t, [mount('work', env.at('work')), mount('inner', env.at('work/nested'))]); + const refusal = 'work/flink.txt: "work/flink.txt" is a symlink to "work/nested/x.txt", which this mount does not follow; it lies in mount "inner", nested in mount "work": use "inner/x.txt" in place of "work/flink.txt"'; + for (const [name, input] of [['read', { path: 'work/flink.txt' }], ['write', { path: 'work/flink.txt', content: 'via work\n' }]] as const) { + const res = await call(m, name, input); + assert.equal(res.error, refusal, name); + } + assert.deepEqual(env.tree('work'), [], 'nothing was stored'); + await assert.rejects(m.readFileFromDisk('work/flink.txt'), (err: unknown) => { + assert.ok(err instanceof WorkspaceReadError, String(err)); + assert.equal(err.code, 'other_mount'); + assert.equal(err.message, refusal); + return true; + }); + await readsBack(m, 'inner/x.txt', 'inner file'); + }); + + test('a write through a link to a place not made yet in it is refused before anything is stored', async (t) => { + const env = new Env(t); + env.write('work/nested/keep.txt', 'inner\n'); + symlinkSync('nested/new.txt', env.at('work/flink.txt')); + const m = await env.open(t, [mount('work', env.at('work')), mount('inner', env.at('work/nested'))]); + // #468's words for a link whose target isn't there to name. + const write = await call(m, 'write', { path: 'work/flink.txt', content: 'via work\n' }); + assert.equal(write.success, false); + assert.equal(write.error, 'work/flink.txt: "work/flink.txt" is a symlink, which this mount does not follow'); + assert.deepEqual(env.tree('work'), [], 'nothing was stored'); + assert.equal(existsSync(env.at('work/nested/new.txt')), false); + }); + + test('a write through it is refused before anything is stored, with the link\'s own refusal', async (t) => { + // The readers and pushes give this refusal already (#468). A write used + // to store a draft that no push could write. + const env = new Env(t); + env.write('work/nested/sub/x.txt', 'inner file\n'); + symlinkSync('nested/sub', env.at('work/l')); + const m = await env.open(t, [mount('work', env.at('work')), mount('inner', env.at('work/nested'))]); + const write = await call(m, 'write', { path: 'work/l/new.txt', content: 'via work\n' }); + assert.equal(write.success, false); + assert.equal(write.error, 'work/l/new.txt: "work/l" is a symlink to "work/nested/sub", which this mount does not follow; it lies in mount "inner", nested in mount "work": use "inner/sub" in place of "work/l"'); + assert.deepEqual(env.tree('work'), [], 'nothing was stored'); + }); +}); + +describe('the checks where disk is acted on, called directly as a push and a read call them', () => { + /** A mount following symlinks, with `inner` nested at `a/b` and a file link `flink.txt` into it. */ + function rig(t: TestContext): { env: Env; view: MountView; rootReal: string } { + const env = new Env(t); + env.write('outer/a/b/x.txt', "the inner mount's"); + symlinkSync(env.at('outer/a/b/x.txt'), env.at('outer/flink.txt')); + // Cast, so the file also compiles against code whose nested mounts carry + // no real root, for the run of these tests on it. + const nested = [{ dir: 'a/b', name: 'inner', real: realpathSync(env.at('outer/a/b')) }]; + const view = { + root: env.at('outer'), name: 'outer', followSymlinks: true, maxFileSize: 1 << 20, ignore: [], others: ['a/b'], nested, + } as MountView; + return { env, view, rootReal: realpathSync(env.at('outer')) }; + } + const reason = '"outer/flink.txt" leads into mount "inner", nested in mount "outer": use "inner/x.txt" in its place'; + + // These two pin the checks #282 added for slimepriestess's review (its + // 577258f4, by Ada-1017), carried here with the reason this change gives. + test('the contained write refuses a file it opens through a link into a nested mount', async (t) => { + const { env, view, rootReal } = rig(t); + const err = await writeContained(view, rootReal, 'flink.txt', Buffer.from('outer wrote'), { kind: 'any' }).then(() => null, (e) => e); + assert.ok(err instanceof EffectFailed, String(err)); + assert.equal(err.message, reason); + assert.equal(readFileSync(env.at('outer/a/b/x.txt'), 'utf8'), "the inner mount's", "the inner mount's file is as it was"); + }); + + test('the contained unlink refuses a final link into a nested mount, and leaves the link and the file', async (t) => { + const { env, view, rootReal } = rig(t); + const err = await unlinkContained(view, rootReal, 'flink.txt', { kind: 'any' }).then(() => null, (e) => e); + assert.ok(err instanceof EffectFailed, String(err)); + assert.equal(err.message, reason); + assert.ok(lstatSync(env.at('outer/flink.txt')).isSymbolicLink(), 'the link is still there'); + assert.equal(readFileSync(env.at('outer/a/b/x.txt'), 'utf8'), "the inner mount's", "the inner mount's file is as it was"); + }); + + test('the located read refuses a file it opens through a link into a nested mount', async (t) => { + const { view, rootReal } = rig(t); + assert.deepEqual(await readPath(view, rootReal, 'flink.txt', 1 << 20), { kind: 'unobserved', reason }); + }); + + test('a directory made for a push isn\'t made where a link leads into a nested mount\'s root not made yet (G3)', async (t) => { + const { env, view, rootReal } = rig(t); + symlinkSync(env.at('outer/a'), env.at('outer/l')); + const nested = [{ dir: 'a/c', name: 'later', real: join(realpathSync(env.at('outer/a')), 'c') }]; + const later = { ...view, nested } as MountView; + assert.deepEqual(await checkDir(later, rootReal, 'l/c/d', { create: true }), { + kind: 'unobserved', reason: '"outer/l/c" leads into mount "later", nested in mount "outer": use "later" in its place', + }); + assert.equal(existsSync(env.at('outer/a/c')), false, "the nested mount's root wasn't made"); + }); +}); + +describe('a watcher of a mount configured as a relative path', () => { + test('hears a change beneath a root written with `./`', async (t) => { + const env = new Env(t); + mkdirSync(env.at('m')); + const path = `./${relative(process.cwd(), env.at('m'))}`; + const changes: FsChange[] = []; + let ready = false; + const watcher = new MountWatcher({ name: 'm', path, mode: 'read-write', watch: 'always', watchDebounceMs: 50 }, (batch) => changes.push(...batch), { onReady: () => { ready = true; } }); + watcher.start(); + t.after(() => watcher.stop()); + for (let i = 0; i < 400 && !ready; i++) await new Promise((r) => setTimeout(r, 25)); + assert.equal(ready, true, 'the watcher is ready'); + writeFileSync(env.at('m/x.txt'), 'x\n'); + for (let i = 0; i < 400 && !changes.some((c) => c.path === 'x.txt'); i++) await new Promise((r) => setTimeout(r, 25)); + assert.ok(changes.some((c) => c.path === 'x.txt'), `heard: ${JSON.stringify(changes)}`); + }); +}); diff --git a/test/workspace-parent-link.test.ts b/test/workspace-parent-link.test.ts index 1fd3e5535..b71b8f6fd 100644 --- a/test/workspace-parent-link.test.ts +++ b/test/workspace-parent-link.test.ts @@ -388,9 +388,8 @@ describe('a symlinked parent on a mount that does not follow symlinks', () => { assert.ok(String(res.error).includes(reason), String(res.error)); assert.ok(!String(res.error).includes('followSymlinks'), String(res.error)); assert.equal(existsSync(join(env.dir, 'nested', 'sub', 'new.txt')), false); - // read_image, where the workspace's own copy can't be read as an image, - // gives the refusal, not that copy's failure. - await m.handleToolCall({ id: 't', name: 'write', input: { path: 'work/l/img.png', content: 'not an image\n' } }); + // read_image gives the refusal too: the file tools refuse a path that + // leads into a nested mount before looking for the workspace's copy. const image = await m.handleToolCall({ id: 'i', name: 'read_image', input: { path: 'work/l/img.png' } }); assert.equal(image.success, false); assert.equal(image.error, `work/l/img.png: ${reason}`); diff --git a/test/workspace-three-way.test.ts b/test/workspace-three-way.test.ts index 611edbca0..f7ac690aa 100644 --- a/test/workspace-three-way.test.ts +++ b/test/workspace-three-way.test.ts @@ -7451,3 +7451,20 @@ describe('the mount root', () => { assert.match(await refused(m, 'sync', { path: 'work/a.txt', acceptRoot: true }), /whole mount/); }); }); + +// anima-research/agent-framework#530 (slimepriestess): an outer mount's forced +// materialize through a file link wrote into a nested mount's file. Her probe, +// as the issue gives it. +for (const autoMaterialize of [false, true]) test(`a forced materialize through a file link into a nested mount leaves the inner mount's file alone (autoMaterialize ${autoMaterialize})`, async (t) => { + const env = new Env(t); + env.writeDisk('a/b/x.txt', "the inner mount's"); + const m = await env.open({}, { mounts: [ + { name: 'work', path: env.dir, mode: 'read-write', watch: 'never', followSymlinks: true, autoMaterialize }, + { name: 'deep', path: env.disk('a/b'), mode: 'read-write', watch: 'never' }, + ] }); + await m.handleToolCall({ id: 't', name: 'write', input: { path: 'work/flink.txt', content: 'outer wrote' } }); + if (existsSync(env.disk('flink.txt'))) rmSync(env.disk('flink.txt')); + symlinkSync(env.disk('a/b/x.txt'), env.disk('flink.txt')); + await m.handleToolCall({ id: 't', name: 'materialize', input: { path: 'work/flink.txt', force: true } }); + assert.equal(readFileSync(env.disk('a/b/x.txt'), 'utf8'), "the inner mount's"); +});