From 6455978360e8b1468ac137cce2ee63fdcf9da256 Mon Sep 17 00:00:00 2001 From: Tabitha-2696 Date: Sun, 18 Oct 2026 07:29:12 -0400 Subject: [PATCH 1/6] fix(workspace): a path spelled otherwise than disk spells it takes disk's spelling (F181) On a disk that folds names, as macOS's does by default (case, and Unicode normalization), a path spelled otherwise than disk spells it reaches disk's file. The workspace named what it found from the spelling it was given, so a search with path `work/SRC`, where disk has `src/`, took `src/a.ts` in a second time as `SRC/a.ts` and announced it as created, and the next pass over the mount announced it deleted. A write to `work/SRC/b.ts` was held as `SRC/b.ts` while disk put it in `src/`. Probed on main d1b9d35c on a disk that folds case and preserves it. - observe.ts's diskSpelling takes a mount-relative path as disk spells it, from the mount's root down. A segment's directory is listed only where disk folds that segment: its case flipped, or its other Unicode normalization form, finds the same file (device and inode) as the segment's own entry, or, for the last segment, which isn't looked up as given unless the listing must settle it, finds anything. There, the name as given wins if it's listed; otherwise the segment takes the listed name that folds to it and is the same file. A disk that tells the spellings apart is listed only where a path's last name has a sibling spelled as one of its variants. The walk goes on only beneath a directory, not a symlink, and stops at an empty, `.` or `..` segment. - parsePath, where each tool's handler and each public reader and writer finds its mount, awaits the step and returns the path so spelled, and the handlers answer with it. A handler reads what it reads at the moment it's asked, such as handleSync's branch, before parsing. parseFilePath's nested-mount check reads the respelled path, so a path into a nested mount's directory spelled in another case is refused as that mount's (#324's case-variant half). resolveAbsolutePath stays synchronous and takes its path as given, which reaches the same file. Tests (test/workspace-path-spelling.test.ts) run where the temp directory folds case, and skip with their reason elsewhere: searches, read, write, edit and delete through `SRC/`, a new name beneath a respelled directory, the nested-mount refusal, the public readers and writer, a caseless name in the other normalization form (where the disk folds normalization), and a symlink and a `..` the step doesn't read past. Where the disk tells case apart, two hard links that differ only in case keep their own names. Co-Authored-By: Claude Opus 5.5 --- .../workspace-path-disk-spelling.fixed.md | 1 + src/modules/workspace/index.ts | 78 ++++++-- src/modules/workspace/observe.ts | 73 +++++++ test/workspace-path-spelling.test.ts | 179 ++++++++++++++++++ 4 files changed, 310 insertions(+), 21 deletions(-) create mode 100644 changelog.d/workspace-path-disk-spelling.fixed.md create mode 100644 test/workspace-path-spelling.test.ts diff --git a/changelog.d/workspace-path-disk-spelling.fixed.md b/changelog.d/workspace-path-disk-spelling.fixed.md new file mode 100644 index 000000000..7e9807d2c --- /dev/null +++ b/changelog.d/workspace-path-disk-spelling.fixed.md @@ -0,0 +1 @@ +- On a disk that folds names, as macOS's does by default (case, and Unicode normalization), the workspace's tools take a path as disk spells it. A `path` spelled otherwise, such as `work/SRC` for disk's `src/`, reaches disk's files, and `glob`, `grep` and `ls` used to name what they found from that spelling. So the workspace took each file in a second time under that name, announced it as created, and announced it deleted at the next pass over the mount; a `write` there was held under the name given while disk put it in `src/`. Now each segment of a `path` that reaches an entry under another spelling takes the entry's own name, from the mount's root down, and answers name files as disk lists them: `read work/SRC/a.ts` answers with `path: "work/src/a.ts"`. A segment disk doesn't have keeps the spelling given, and nothing past a symlink is respelled. `readBinary`, `readBinaryFromDisk`, `readFileFromDisk` and `writeBinary` take their paths the same way; `resolveAbsolutePath` doesn't, and the path it returns reaches the same file. A path into a nested mount's directory, spelled in another case, is now refused as that mount's, as it is in disk's spelling. On a disk that tells spellings apart, nothing changes, and the workspace lists no directory to check a path's spelling. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 2151447e8..1f3443cb6 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -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, OTHER_MOUNT, checkDir, filesystemTrustsCtime, inOtherMount, locateRoot, passedOverAt, readPrefix } from './observe.js'; +import { IGNORED, OTHER_MOUNT, checkDir, diskSpelling, filesystemTrustsCtime, inOtherMount, locateRoot, passedOverAt, readPrefix } from './observe.js'; import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js'; import { type MountRuntime, @@ -1583,7 +1583,8 @@ export class WorkspaceModule implements Module { */ resolveAbsolutePath(mountPrefixedPath: string): string | null { try { - const { mount, relativePath } = this.parseFilePath(mountPrefixedPath); + // Synchronous, so the path as given: it reaches the same file. + const { mount, relativePath } = this.refuseNested({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }); return resolve(mount.config.path, relativePath); } catch { return null; @@ -1621,7 +1622,7 @@ export class WorkspaceModule implements Module { let mount: MountState; let relativePath: string; try { - ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath)); + ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath)); } catch (error) { return { success: false, @@ -1685,7 +1686,7 @@ export class WorkspaceModule implements Module { let mount: MountState; let relativePath: string; try { - ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath)); + ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath)); } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; } @@ -1722,7 +1723,7 @@ export class WorkspaceModule implements Module { let mount: MountState; let relativePath: string; try { - ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath)); + ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath)); } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; } @@ -1785,17 +1786,40 @@ export class WorkspaceModule implements Module { * observe it and its pushes don't write it, so its file operations refuse * it, naming the innermost mount that holds it and the path to use there. */ - private parseFilePath(path: string): { mount: MountState; relativePath: string } { - const parsed = this.parsePath(path); + private async parseFilePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> { + return this.refuseNested(await this.parsePath(path)); + } + + /** A parsed path, refused if it lies in a nested mount's directory (parseFilePath). */ + private refuseNested

(parsed: P): P { const holder = this.holderOf(parsed.mount.config.name, parsed.relativePath); - if (holder) throw new NestedMountError(path, parsed.mount.config.name, holder.name, holder.use); + if (holder) throw new NestedMountError(parsed.path, parsed.mount.config.name, holder.name, holder.use); return parsed; } /** - * Parse a mount-prefixed path into (mountName, relativePath). + * Parse a mount-prefixed path into its mount and mount-relative path, as + * disk spells it (diskSpelling), with `path` the mount-prefixed path so + * spelled. On a disk that folds names, a path spelled otherwise reaches + * disk's file, so every tool takes it by disk's name: one name per file, + * and the names its answers give are disk's. A handler reads what it reads + * at the moment it was asked, such as handleSync's branch, before calling + * this, since this awaits disk. + */ + private async parsePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> { + const given = this.parsePathAsGiven(path); + const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath); + return { + mount: given.mount, + relativePath, + path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`, + }; + } + + /** + * Parse a mount-prefixed path into (mountName, relativePath), as spelled. */ - private parsePath(path: string): { mount: MountState; relativePath: string } { + private parsePathAsGiven(path: string): { mount: MountState; relativePath: string } { // A file's name on disk is UTF-8, which can't hold a lone surrogate: the // store would keep one name and disk another. if (LONE_SURROGATE.test(path)) { @@ -2048,7 +2072,7 @@ export class WorkspaceModule implements Module { let mount: MountState; let relativePath: string; try { - ({ mount, relativePath } = this.parseFilePath(mountPrefixedPath)); + ({ mount, relativePath, path: mountPrefixedPath } = await this.parseFilePath(mountPrefixedPath)); } catch (err) { const message = err instanceof Error ? err.message : String(err); const code: WorkspaceReadErrorCode = err instanceof NestedMountError ? 'other_mount' @@ -2300,7 +2324,8 @@ export class WorkspaceModule implements Module { } let content = generatedContent; if (content === undefined) { - const { mount, relativePath } = this.parseFilePath(input.path); + const { mount, relativePath, path } = await this.parseFilePath(input.path); + input = { ...input, path }; const store = this.getStore(); const found = await this.withMount(mount, async (): Promise<{ text: string } | { error: string }> => { const report = await this.ensureSynced(mount, relativePath); @@ -2388,7 +2413,9 @@ export class WorkspaceModule implements Module { let mount: MountState; let relativePath: string; try { - ({ mount, relativePath } = this.parseFilePath(input.path)); + let path: string; + ({ mount, relativePath, path } = await this.parseFilePath(input.path)); + input = { ...input, path }; } catch (err) { return { success: false, @@ -2460,7 +2487,8 @@ export class WorkspaceModule implements Module { if (LONE_SURROGATE.test(input.content)) { return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' }; } - const { mount, relativePath } = this.parseFilePath(input.path); + const { mount, relativePath, path } = await this.parseFilePath(input.path); + input = { ...input, path }; if (mount.config.mode === 'read-only') { return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; } @@ -2505,7 +2533,8 @@ export class WorkspaceModule implements Module { return { success: false, isError: true, error: `The edit did nothing: "${field}" holds a lone UTF-16 surrogate, which UTF-8 can't store.` }; } } - const { mount, relativePath } = this.parseFilePath(input.path); + const { mount, relativePath, path } = await this.parseFilePath(input.path); + input = { ...input, path }; if (mount.config.mode === 'read-only') { return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; } @@ -2649,7 +2678,8 @@ export class WorkspaceModule implements Module { } private async handleDelete(input: DeleteInput): Promise { - const { mount, relativePath } = this.parseFilePath(input.path); + const { mount, relativePath, path } = await this.parseFilePath(input.path); + input = { ...input, path }; if (mount.config.mode === 'read-only') { return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true }; } @@ -2692,7 +2722,8 @@ export class WorkspaceModule implements Module { return { success: true, data: { mounts } }; } - const { mount, relativePath } = this.parsePath(input.path); + const { mount, relativePath, path } = await this.parsePath(input.path); + input = { ...input, path }; const recursive = input.recursive === true; // Every listing checks the paths it shows against disk first, under the @@ -2745,7 +2776,7 @@ export class WorkspaceModule implements Module { // Search across mounts const mountsToSearch = input.path - ? [this.parsePath(input.path)] + ? [await this.parsePath(input.path)] : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' })); // Every match lies beneath the pattern's literal directory, so the walk @@ -2797,7 +2828,7 @@ export class WorkspaceModule implements Module { const contextAfter = input.contextAfter ?? 0; const mountsToSearch = input.path - ? [this.parsePath(input.path)] + ? [await this.parsePath(input.path)] : [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' })); type GrepMatch = { line: number; text: string; context?: string[] }; @@ -3062,7 +3093,11 @@ export class WorkspaceModule implements Module { const modesKept: Array<{ mount: string; path: string; reason: string }> = []; let explicit: { mount: MountState; relativePath: string } | null = null; - if (input.path) explicit = this.parsePath(input.path); + if (input.path) { + const parsed = await this.parsePath(input.path); + explicit = parsed; + input = { ...input, path: parsed.path }; + } let mountsToMaterialize: Array<{ name: string; mount: MountState }>; if (explicit) { @@ -3248,7 +3283,8 @@ export class WorkspaceModule implements Module { // A path names its own mount; otherwise the given mount, or every mount. let targets: Array<{ name: string; mount: MountState; relativePath: string }>; if (input.path) { - const { mount, relativePath } = this.parsePath(input.path); + const { mount, relativePath, path } = await this.parsePath(input.path); + input = { ...input, path }; targets = [{ name: mount.config.name, mount, relativePath }]; } else if (input.mount) { const m = this.mounts.get(input.mount); diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index 19f9b2ec6..ab5e3ed8d 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -141,6 +141,79 @@ function lexicalOf(view: MountView, rel: string): string { return rel === '' ? view.root : join(view.root, rel); } +// =========================================================================== +// Disk's spelling of a path +// =========================================================================== + +const sameFile = (a: BigIntStats, b: BigIntStats): boolean => a.dev === b.dev && a.ino === b.ino; + +/** The name with each letter's case flipped. */ +const flippedCase = (name: string): string => + [...name].map((c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase())).join(''); + +/** What a disk that folds names compares: NFC, in lower case. */ +const folded = (name: string): string => name.normalize('NFC').toLowerCase(); + +/** + * The name `dir` lists for `segment`: `segment` itself, unless disk folds it + * there and lists it otherwise. Disk folds it there when its case flipped, or + * its other Unicode normalization form, finds the same file (device and + * inode) as `given`, the segment's own entry, or, for the last segment, which + * isn't looked up as given unless it must be, finds anything. Only then is + * `dir` listed, so a disk that tells the spellings apart is never listed. + * The name as given wins if it's listed; otherwise the segment takes the + * listed name that folds to it and is the same file, so the name returned + * reaches the file the segment does, or is the segment as given. + */ +async function listedName(dir: string, segment: string, given: BigIntStats | null): Promise { + const variants = new Set([flippedCase(segment), segment.normalize('NFC'), segment.normalize('NFD')]); + variants.delete(segment); + let folds = false; + for (const variant of variants) { + const other = await lstat(join(dir, variant), { bigint: true }).catch(() => null); + if (other !== null && (given === null || sameFile(other, given))) { folds = true; break; } + } + if (!folds) return segment; + const listed = await readdir(dir).catch(() => null); + if (listed === null || listed.includes(segment)) return segment; + const target = given ?? await lstat(join(dir, segment), { bigint: true }).catch(() => null); + if (target === null) return segment; + for (const name of listed) { + if (folded(name) !== folded(segment)) continue; + const info = await lstat(join(dir, name), { bigint: true }).catch(() => null); + if (info !== null && sameFile(info, target)) return name; + } + return segment; +} + +/** + * `relativePath` as disk spells it. On a disk that folds names, as macOS's + * does by default (case and Unicode normalization), `SRC/a.ts` reaches + * `src/a.ts`, and whatever names what it finds from the spelling it was + * given takes the file in a second time, under a name disk doesn't list. So + * each segment, from the mount's root down, takes the name its directory + * lists for it (listedName). The walk goes on beneath a segment only if it's + * a directory, a symlink not included, so nothing past a link is read and + * nothing outside the mount, and it stops at an empty, `.` or `..` segment, + * keeping the rest as given. + */ +export async function diskSpelling(root: string, relativePath: string): Promise { + if (relativePath === '') return relativePath; + const segments = relativePath.split('/'); + let dir = root; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]!; + if (segment === '' || segment === '.' || segment === '..') break; + const last = i === segments.length - 1; + const given = last ? null : await lstat(join(dir, segment), { bigint: true }).catch(() => null); + if (!last && given === null) break; + segments[i] = await listedName(dir, segment, given); + if (given === null || !given.isDirectory()) break; + dir = join(dir, segments[i]!); + } + return segments.join('/'); +} + // =========================================================================== // Checked directories // =========================================================================== diff --git a/test/workspace-path-spelling.test.ts b/test/workspace-path-spelling.test.ts new file mode 100644 index 000000000..ae50d1f1a --- /dev/null +++ b/test/workspace-path-spelling.test.ts @@ -0,0 +1,179 @@ +// On a disk that folds names (macOS's default folds case, and Unicode +// normalization), a path spelled otherwise than disk spells it reaches disk's +// file. The workspace's tools take such a path as disk spells it, so they +// name each file by one name and take nothing in twice. The tests of that run +// where the temp directory folds names, and skip, saying why, where it +// doesn't; the step's own rules are tested on any disk. +import test, { type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { WorkspaceModule, WorkspaceReadError } from '../src/modules/workspace/index.js'; +import { diskSpelling } from '../src/modules/workspace/observe.js'; +import type { MountConfig } from '../src/modules/workspace/types.js'; +import type { ModuleContext } from '../src/types/module.js'; +import type { ProcessEvent, ToolResult } from '../src/types/events.js'; + +/** What the temp directory folds: case, and NFC against NFD. */ +const folds = ((): { case: boolean; normalization: boolean } => { + const dir = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-folds-')); + try { + writeFileSync(join(dir, 'Fold'), ''); + writeFileSync(join(dir, 'caf\u00e9'), ''); + return { case: existsSync(join(dir, 'FOLD')), normalization: existsSync(join(dir, 'cafe\u0301')) }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +})(); +const FOLDING_ONLY = folds.case ? false + : "the temp directory tells case apart, so a path spelled in another case reaches nothing: these run on a disk that folds case, such as macOS's default"; +const NORMALIZATION_FOLDING_ONLY = folds.normalization ? false + : 'the temp directory tells NFC and NFD apart, so a name in the other form reaches nothing'; +const CASE_SENSITIVE_ONLY = folds.case + ? 'the temp directory folds case, so two names that differ only in case are one name here' : false; + +async function setup(t: TestContext, mounts: (dir: string) => MountConfig[] = (dir) => [ + { name: 'work', path: dir, mode: 'read-write', watch: 'never', autoMaterialize: true }, +]) { + const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-')); + const dir = join(root, 'mount'); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'src', 'a.ts'), 'export const a = 1;\n'); + const store = JsStore.openOrCreate({ path: join(root, 'store') }); + const workspace = new WorkspaceModule({ mounts: mounts(dir) }); + workspace.initStore(store); + const events: ProcessEvent[] = []; + await workspace.start({ + isRestart: false, getState: () => null, setState: () => {}, pushEvent: (e: ProcessEvent) => events.push(e), + } as unknown as ModuleContext); + t.after(async () => { await workspace.stop(); store.close(); rmSync(root, { recursive: true, force: true }); }); + const call = async (name: string, input: unknown): Promise => { + const result = await workspace.handleToolCall({ id: name, name, input } as Parameters[0]); + assert.equal(result.success, true, `${name}: ${result.error}`); + return result; + }; + /** The created, modified and deleted events pushed since the last call, as `op path`. */ + const pushed = (): string[] => events.splice(0) + .filter((e) => /^workspace:(created|modified|deleted)$/.test(e.type)) + .flatMap((e) => (e as unknown as { paths: string[] }).paths.map((p) => `${e.type.slice('workspace:'.length)} ${p}`)); + return { workspace, dir, call, pushed }; +} + +test('a search whose path is spelled in another case names what it finds as disk does, and takes nothing in twice', + { skip: FOLDING_ONLY }, async (t) => { + const { call, pushed } = await setup(t); + await call('ls', { path: 'work', recursive: true }); + assert.deepEqual(pushed(), ['created work/src/a.ts']); + + const glob = await call('glob', { pattern: '*.ts', path: 'work/SRC' }); + assert.deepEqual((glob.data as { matches: Array<{ path: string }> }).matches.map((m) => m.path), ['work/src/a.ts']); + const grep = await call('grep', { pattern: 'const', path: 'work/Src' }); + assert.deepEqual((grep.data as { results: Array<{ file: string }> }).results.map((r) => r.file), ['work/src/a.ts']); + const ls = await call('ls', { path: 'work/SRC' }); + assert.equal((ls.data as { path: string }).path, 'work/src'); + assert.deepEqual(pushed(), [], 'nothing was taken in under the spelling given'); + + const all = await call('ls', { path: 'work', recursive: true }); + assert.deepEqual((all.data as { entries: Array<{ path: string }> }).entries.map((e) => e.path), ['src/a.ts']); + assert.deepEqual(pushed(), [], 'and nothing was deleted when the whole mount was listed'); + }); + +test('a read, write, edit and delete spelled in another case act on disk\'s file, under its own name', + { skip: FOLDING_ONLY }, async (t) => { + const { dir, call, pushed } = await setup(t); + await call('ls', { path: 'work', recursive: true }); + pushed(); + + const read = await call('read', { path: 'work/SRC/A.TS', offsetChars: 0, limitChars: 100 }); + assert.deepEqual(read.data, { + path: 'work/src/a.ts', totalChars: 20, offsetChars: 0, nextOffsetChars: null, content: 'export const a = 1;\n', + }); + const write = await call('write', { path: 'work/SRC/b.ts', content: 'export const b = 2;\n' }); + assert.equal((write.data as { path: string }).path, 'work/src/b.ts'); + const edit = await call('edit', { path: 'work/Src/B.ts', oldString: '2', newString: '3' }); + assert.equal((edit.data as { path: string }).path, 'work/src/b.ts'); + assert.equal(readFileSync(join(dir, 'src', 'b.ts'), 'utf8'), 'export const b = 3;\n'); + + const listed = await call('ls', { path: 'work', recursive: true }); + assert.deepEqual((listed.data as { entries: Array<{ path: string }> }).entries.map((e) => e.path), ['src/a.ts', 'src/b.ts']); + assert.deepEqual(pushed(), [], 'the write is held under the name disk lists, so listing the mount moves nothing'); + + const deleted = await call('delete', { path: 'work/SRC/B.TS' }); + assert.deepEqual(deleted.data, { path: 'work/src/b.ts', deleted: true }); + assert.equal(existsSync(join(dir, 'src', 'b.ts')), false); + const after = await call('ls', { path: 'work', recursive: true }); + assert.deepEqual((after.data as { entries: Array<{ path: string }> }).entries.map((e) => e.path), ['src/a.ts']); + assert.deepEqual(pushed(), []); + }); + +test('beneath a directory disk has, a name disk doesn\'t have keeps the spelling given', + { skip: FOLDING_ONLY }, async (t) => { + const { dir, call } = await setup(t); + const write = await call('write', { path: 'work/SRC/New/x.ts', content: 'x\n' }); + assert.equal((write.data as { path: string }).path, 'work/src/New/x.ts'); + assert.equal(readFileSync(join(dir, 'src', 'New', 'x.ts'), 'utf8'), 'x\n'); + }); + +test('a path into a nested mount\'s directory, spelled in another case, is refused as that mount\'s', + { skip: FOLDING_ONLY }, async (t) => { + const { workspace, dir } = await setup(t, (d) => [ + { name: 'outer', path: d, mode: 'read-write', watch: 'never' }, + { name: 'inner', path: join(d, 'inner'), mode: 'read-write', watch: 'never' }, + ]); + mkdirSync(join(dir, 'inner'), { recursive: true }); + writeFileSync(join(dir, 'inner', 'x.txt'), 'inner\n'); + const read = await workspace.handleToolCall({ id: 'r', name: 'read', input: { path: 'outer/INNER/x.txt' } } as Parameters[0]); + assert.equal(read.success, false); + assert.equal(read.error, '"outer/inner/x.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/x.txt".'); + await assert.rejects(workspace.readFileFromDisk('outer/INNER/x.txt'), + (err: unknown) => err instanceof WorkspaceReadError && err.code === 'other_mount'); + assert.deepEqual(await workspace.readBinary('outer/INNER/x.txt'), { error: read.error }); + }); + +test('the public readers and writer take a path as disk spells it too', { skip: FOLDING_ONLY }, async (t) => { + const { workspace, dir } = await setup(t); + const fromDisk = await workspace.readBinaryFromDisk('work/SRC/a.ts'); + assert.ok('absolutePath' in fromDisk, JSON.stringify(fromDisk)); + assert.equal(fromDisk.absolutePath, join(dir, 'src', 'a.ts')); + const written = await workspace.writeBinary('work/SRC/c.bin', Buffer.from([1, 2, 3]), 'application/octet-stream'); + assert.equal(written.success, true, written.error); + assert.equal((written.data as { path: string }).path, 'work/src/c.bin'); +}); + +test('a name in the other Unicode normalization form takes the form disk lists', { skip: NORMALIZATION_FOLDING_ONLY }, async (t) => { + const { dir, call, pushed } = await setup(t); + // が has no case, so only its normalization forms tell this name apart: NFD on disk, NFC in the path. + mkdirSync(join(dir, '\u304b\u3099')); + writeFileSync(join(dir, '\u304b\u3099', 'a.txt'), 'nfd\n'); + await call('ls', { path: 'work', recursive: true }); + pushed(); + const read = await call('read', { path: 'work/\u304c/a.txt', offsetChars: 0, limitChars: 100 }); + assert.equal((read.data as { path: string }).path, 'work/\u304b\u3099/a.txt'); + await call('ls', { path: 'work', recursive: true }); + assert.deepEqual(pushed(), []); +}); + +test('the step reads no further than a symlink or a `..`, and keeps the rest as spelled', { skip: FOLDING_ONLY }, async (t) => { + const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-link-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, 'real', 'sub'), { recursive: true }); + symlinkSync('real', join(root, 'lnk')); + assert.equal(await diskSpelling(root, 'REAL/SUB'), 'real/sub'); + assert.equal(await diskSpelling(root, 'LNK/SUB'), 'lnk/SUB'); + assert.equal(await diskSpelling(root, 'REAL/../REAL'), 'real/../REAL'); +}); + +test('on a disk that tells case apart, two hard links that differ only in case keep their own names', + { skip: CASE_SENSITIVE_ONLY }, async (t) => { + const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-links-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + // Each name's case flipped is the other, the same file, so the step lists + // the directory for either, and each name, listed as given, keeps it. + writeFileSync(join(root, 'a.txt'), 'one file\n'); + linkSync(join(root, 'a.txt'), join(root, 'A.TXT')); + assert.equal(await diskSpelling(root, 'A.TXT'), 'A.TXT'); + assert.equal(await diskSpelling(root, 'a.txt'), 'a.txt'); + assert.equal(await diskSpelling(root, 'SRC/a.ts'), 'SRC/a.ts', 'a name disk lacks keeps its spelling'); + }); From 452fd1a251c5f3dcc42900782d393ec80ca0a811 Mon Sep 17 00:00:00 2001 From: Tabitha-2696 Date: Sun, 18 Oct 2026 08:23:52 -0400 Subject: [PATCH 2/6] fix(workspace): a nested mount's directory in both spellings, and the internal review's other points (F181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal review of the first commit found that the nested-mount checks compared the respelled path, in disk's spelling, with each nested mount's directory as configured. On a disk that folds names, a nested mount configured as `Inner` where disk has `inner` was then no longer refused: a read, write or sync of outer/Inner/... went through the outer mount, where main refused it. Main itself already missed that directory in the outer mount's walks, which name it as disk does, and took its files in as the outer mount's. - Each nested mount's directory is known in its configured spelling and, where it differs, in disk's, found by diskSpelling at each start (spellNestedDirs). holderOf and othersOf take either, so the tools' refusal, resolveAbsolutePath's check of a path as given, the pass-over checks, the walks and the watcher all match it. - flippedCase flips a letter only where its other case is one letter, so `ß`, whose upper case is `SS`, stays `ß` and the probe still reaches a ß-named file on a disk whose folding is simple. - diskSpelling passes over an empty or `.` segment, which names the directory already reached, and still stops at `..`, which can climb above the mount's root in a path that comes back inside. - handleSync and handleMaterialize no longer reassign input.path, which nothing read afterwards. - Comments: listedName says a failed listing or lookup keeps the segment as given, walkStart that its base is in disk's spelling, and the listing rule now says a disk that tells spellings apart is listed only where a path's last name has a sibling spelled as one of its variants. Tests: a nested mount configured in another spelling than disk's, refused in either spelling by read, write and resolveAbsolutePath, with nothing taken in by a sync or a listing of the outer mount; sync and materialize through another spelling; read_image's answer; a name with ß; readBinary through another spelling; `.` and empty segments. The test file's header now says which tests run where. Co-Authored-By: Claude Opus 5.5 --- .../workspace-path-disk-spelling.fixed.md | 2 +- src/modules/workspace/index.ts | 54 +++++++++---- src/modules/workspace/observe.ts | 42 ++++++---- test/workspace-path-spelling.test.ts | 78 +++++++++++++++++-- 4 files changed, 139 insertions(+), 37 deletions(-) diff --git a/changelog.d/workspace-path-disk-spelling.fixed.md b/changelog.d/workspace-path-disk-spelling.fixed.md index 7e9807d2c..bed2a38dc 100644 --- a/changelog.d/workspace-path-disk-spelling.fixed.md +++ b/changelog.d/workspace-path-disk-spelling.fixed.md @@ -1 +1 @@ -- On a disk that folds names, as macOS's does by default (case, and Unicode normalization), the workspace's tools take a path as disk spells it. A `path` spelled otherwise, such as `work/SRC` for disk's `src/`, reaches disk's files, and `glob`, `grep` and `ls` used to name what they found from that spelling. So the workspace took each file in a second time under that name, announced it as created, and announced it deleted at the next pass over the mount; a `write` there was held under the name given while disk put it in `src/`. Now each segment of a `path` that reaches an entry under another spelling takes the entry's own name, from the mount's root down, and answers name files as disk lists them: `read work/SRC/a.ts` answers with `path: "work/src/a.ts"`. A segment disk doesn't have keeps the spelling given, and nothing past a symlink is respelled. `readBinary`, `readBinaryFromDisk`, `readFileFromDisk` and `writeBinary` take their paths the same way; `resolveAbsolutePath` doesn't, and the path it returns reaches the same file. A path into a nested mount's directory, spelled in another case, is now refused as that mount's, as it is in disk's spelling. On a disk that tells spellings apart, nothing changes, and the workspace lists no directory to check a path's spelling. +- On a disk that folds names, as macOS's does by default (case, and Unicode normalization), the workspace's tools take a path as disk spells it. A `path` spelled otherwise, such as `work/SRC` for disk's `src/`, reaches disk's files, and `glob`, `grep` and `ls` used to name what they found from that spelling. So the workspace took each file in a second time under that name, announced it as created, and announced it deleted at the next pass over the mount; a `write` there was held under the name given while disk put it in `src/`. Now each segment of a `path` that reaches an entry under another spelling takes the entry's own name, from the mount's root down, and answers name files as disk lists them: `read work/SRC/a.ts` answers with `path: "work/src/a.ts"`. A segment disk doesn't have keeps the spelling given, and nothing past a symlink or a `..` is respelled. `readBinary`, `readBinaryFromDisk`, `readFileFromDisk` and `writeBinary` take their paths the same way; `resolveAbsolutePath` takes its path as given, and the path it returns reaches the same file. A nested mount's directory is now known in its configured spelling and in disk's: the tools refuse a path into it in any spelling as that mount's, and an outer mount no longer takes in the files of a nested mount whose directory was configured in another spelling than disk's. On a disk that tells spellings apart, every answer is as before. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index 1f3443cb6..f7b1a56a7 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -691,10 +691,12 @@ export class WorkspaceModule implements Module { /** Per canonical root: the device of its filesystem when that maintains ctime, else null. */ private ctimeTrust = new Map(); /** - * Each mount's directories that are other mounts nested inside it: - * mount-relative, `/`-separated, with each mount's name and resolved root. + * 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. */ - 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 @@ -738,7 +740,7 @@ export class WorkspaceModule implements Module { // 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) ?? []), { dir: relPath, name: inner.name, root: innerPath }]); + 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`, @@ -862,7 +864,7 @@ export class WorkspaceModule implements Module { } /** - * Where a search's walk starts: `base`, its path as given, then each + * Where a search's walk starts: `base`, its path as disk spells it, then each * segment of `beneath` while disk lists it, in the directory reached so * far, as a directory with exactly that spelling. The walk names what it * finds from its start's spelling, and a case-insensitive disk would let @@ -917,6 +919,8 @@ export class WorkspaceModule implements Module { } async start(ctx: ModuleContext): Promise { + // Before anything walks or watches a mount with another nested in it. + await this.spellNestedDirs(); this.ctx = ctx; this.stopped = false; @@ -1583,7 +1587,9 @@ export class WorkspaceModule implements Module { */ resolveAbsolutePath(mountPrefixedPath: string): string | null { try { - // Synchronous, so the path as given: it reaches the same file. + // 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). const { mount, relativePath } = this.refuseNested({ ...this.parsePathAsGiven(mountPrefixedPath), path: mountPrefixedPath }); return resolve(mount.config.path, relativePath); } catch { @@ -1742,9 +1748,30 @@ export class WorkspaceModule implements Module { } } - /** The directories of the mounts nested inside a mount, mount-relative. */ + /** The directories of the mounts nested inside a mount, mount-relative, in each spelling. */ private othersOf(mountName: string): string[] { - return (this.otherMounts.get(mountName) ?? []).map((o) => o.dir); + return (this.otherMounts.get(mountName) ?? []).flatMap((o) => o.spellings.map((s) => s.dir)); + } + + /** + * 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 and watcher 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. + */ + 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('/')) }]; + } + } } /** @@ -1759,7 +1786,7 @@ export class WorkspaceModule implements Module { if (!mount) return undefined; const resolved = resolve(mount.config.path, relativePath); const holder = (this.otherMounts.get(mountName) ?? []) - .filter((o) => isContainedPath(o.root, resolved)) + .flatMap((o) => o.spellings.filter((s) => isContainedPath(s.root, resolved)).map((s) => ({ name: o.name, root: s.root }))) .sort((a, b) => b.root.length - a.root.length)[0]; if (!holder) return undefined; const rest = relative(holder.root, resolved).split(sep).join('/'); @@ -3093,11 +3120,7 @@ export class WorkspaceModule implements Module { const modesKept: Array<{ mount: string; path: string; reason: string }> = []; let explicit: { mount: MountState; relativePath: string } | null = null; - if (input.path) { - const parsed = await this.parsePath(input.path); - explicit = parsed; - input = { ...input, path: parsed.path }; - } + if (input.path) explicit = await this.parsePath(input.path); let mountsToMaterialize: Array<{ name: string; mount: MountState }>; if (explicit) { @@ -3283,8 +3306,7 @@ export class WorkspaceModule implements Module { // A path names its own mount; otherwise the given mount, or every mount. let targets: Array<{ name: string; mount: MountState; relativePath: string }>; if (input.path) { - const { mount, relativePath, path } = await this.parsePath(input.path); - input = { ...input, path }; + const { mount, relativePath } = await this.parsePath(input.path); targets = [{ name: mount.config.name, mount, relativePath }]; } else if (input.mount) { const m = this.mounts.get(input.mount); diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index ab5e3ed8d..9f3e38208 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -147,23 +147,34 @@ function lexicalOf(view: MountView, rel: string): string { const sameFile = (a: BigIntStats, b: BigIntStats): boolean => a.dev === b.dev && a.ino === b.ino; -/** The name with each letter's case flipped. */ +/** + * The name with each letter's case flipped, where its other case is one + * letter: `ß`, whose upper case is `SS`, stays `ß`, so the name still reaches + * the same file on a disk whose folding is simple, letter for letter. + */ const flippedCase = (name: string): string => - [...name].map((c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase())).join(''); + [...name].map((c) => { + const other = c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase(); + return [...other].length === 1 ? other : c; + }).join(''); /** What a disk that folds names compares: NFC, in lower case. */ const folded = (name: string): string => name.normalize('NFC').toLowerCase(); /** * The name `dir` lists for `segment`: `segment` itself, unless disk folds it - * there and lists it otherwise. Disk folds it there when its case flipped, or - * its other Unicode normalization form, finds the same file (device and - * inode) as `given`, the segment's own entry, or, for the last segment, which - * isn't looked up as given unless it must be, finds anything. Only then is - * `dir` listed, so a disk that tells the spellings apart is never listed. - * The name as given wins if it's listed; otherwise the segment takes the - * listed name that folds to it and is the same file, so the name returned - * reaches the file the segment does, or is the segment as given. + * there and lists it otherwise. `dir` is listed only where a variant of + * `segment`, its case flipped or its other Unicode normalization form, finds + * the same file (device and inode) as `given`, the segment's own entry, as on + * a disk that folds names. The last segment isn't looked up as given unless + * the listing must settle it, so for it any variant found leads to the + * listing: on a disk that tells spellings apart, that's only where `dir` + * holds a variant as a name of its own. The name as given wins if it's + * listed; otherwise the segment takes the listed name that folds to it and is + * the same file, so the name returned reaches the file the segment does, or + * is the segment as given. A directory that can't be listed, or a name that + * can't be looked up, leaves the segment as given, as it was before this + * step. */ async function listedName(dir: string, segment: string, given: BigIntStats | null): Promise { const variants = new Set([flippedCase(segment), segment.normalize('NFC'), segment.normalize('NFD')]); @@ -193,9 +204,11 @@ async function listedName(dir: string, segment: string, given: BigIntStats | nul * given takes the file in a second time, under a name disk doesn't list. So * each segment, from the mount's root down, takes the name its directory * lists for it (listedName). The walk goes on beneath a segment only if it's - * a directory, a symlink not included, so nothing past a link is read and - * nothing outside the mount, and it stops at an empty, `.` or `..` segment, - * keeping the rest as given. + * a directory, a symlink not included, so nothing past a link is read. It + * passes over an empty or `.` segment, which names the directory reached, + * and stops at `..`, keeping the rest as given: a path can climb above the + * mount's root and come back in, as `../proj/x` does for a root named + * `proj`, and walking it would list the directory above the root. */ export async function diskSpelling(root: string, relativePath: string): Promise { if (relativePath === '') return relativePath; @@ -203,7 +216,8 @@ export async function diskSpelling(root: string, relativePath: string): Promise< let dir = root; for (let i = 0; i < segments.length; i++) { const segment = segments[i]!; - if (segment === '' || segment === '.' || segment === '..') break; + if (segment === '' || segment === '.') continue; + if (segment === '..') break; const last = i === segments.length - 1; const given = last ? null : await lstat(join(dir, segment), { bigint: true }).catch(() => null); if (!last && given === null) break; diff --git a/test/workspace-path-spelling.test.ts b/test/workspace-path-spelling.test.ts index ae50d1f1a..466bf70f7 100644 --- a/test/workspace-path-spelling.test.ts +++ b/test/workspace-path-spelling.test.ts @@ -1,9 +1,11 @@ // On a disk that folds names (macOS's default folds case, and Unicode // normalization), a path spelled otherwise than disk spells it reaches disk's // file. The workspace's tools take such a path as disk spells it, so they -// name each file by one name and take nothing in twice. The tests of that run -// where the temp directory folds names, and skip, saying why, where it -// doesn't; the step's own rules are tested on any disk. +// name each file by one name and take nothing in twice. These tests need a +// temp directory that folds case (one needs it to fold normalization), and +// skip, saying why, where it doesn't: a disk that tells spellings apart has +// nothing to respell. The last runs only there instead: two hard links whose +// names differ in case each keep their own name. import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; @@ -116,7 +118,7 @@ test('beneath a directory disk has, a name disk doesn\'t have keeps the spelling assert.equal(readFileSync(join(dir, 'src', 'New', 'x.ts'), 'utf8'), 'x\n'); }); -test('a path into a nested mount\'s directory, spelled in another case, is refused as that mount\'s', +test('a nested mount whose directory is configured as disk spells it is that mount\'s in another spelling too', { skip: FOLDING_ONLY }, async (t) => { const { workspace, dir } = await setup(t, (d) => [ { name: 'outer', path: d, mode: 'read-write', watch: 'never' }, @@ -132,8 +134,70 @@ test('a path into a nested mount\'s directory, spelled in another case, is refus assert.deepEqual(await workspace.readBinary('outer/INNER/x.txt'), { error: read.error }); }); +test('a nested mount whose directory is configured in another spelling than disk\'s is that mount\'s in either', + { skip: FOLDING_ONLY }, async (t) => { + const { workspace, dir, call, pushed } = await setup(t, (d) => { + mkdirSync(join(d, 'inner'), { recursive: true }); + writeFileSync(join(d, 'inner', 'x.txt'), 'inner\n'); + return [ + { name: 'outer', path: d, mode: 'read-write', watch: 'never' }, + { name: 'inner', path: join(d, 'Inner'), mode: 'read-write', watch: 'never' }, + ]; + }); + const refusal = '"outer/inner/x.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/x.txt".'; + for (const path of ['outer/Inner/x.txt', 'outer/inner/x.txt']) { + const read = await workspace.handleToolCall({ id: 'r', name: 'read', input: { path } } as Parameters[0]); + assert.equal(read.error, refusal, path); + assert.equal(workspace.resolveAbsolutePath(path), null, path); + } + const write = await workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'outer/Inner/y.txt', content: 'y\n' } } as Parameters[0]); + assert.equal(write.success, false); + assert.equal(existsSync(join(dir, 'inner', 'y.txt')), false); + const synced = await call('sync', { path: 'outer/Inner' }); + assert.equal((synced.data as { totalSynced: number }).totalSynced, 0); + const listed = await call('ls', { path: 'outer', recursive: true }); + assert.deepEqual((listed.data as { entries: Array<{ path: string }> }).entries.map((e) => e.path), ['src/a.ts']); + assert.deepEqual(pushed(), ['created outer/src/a.ts'], 'the outer mount takes nothing of the inner one\'s in'); + }); + +test('a sync and a materialize of a path spelled in another case act on disk\'s file, under its own name', + { skip: FOLDING_ONLY }, async (t) => { + const { dir, call, pushed } = await setup(t, (d) => [{ name: 'work', path: d, mode: 'read-write', watch: 'never' }]); + await call('ls', { path: 'work', recursive: true }); + pushed(); + writeFileSync(join(dir, 'src', 'b.ts'), 'b\n'); + await call('sync', { path: 'work/SRC/B.TS' }); + assert.deepEqual(pushed(), ['created work/src/b.ts']); + await call('write', { path: 'work/src/a.ts', content: 'export const a = 2;\n' }); + assert.equal(readFileSync(join(dir, 'src', 'a.ts'), 'utf8'), 'export const a = 1;\n', 'not on disk until materialized'); + await call('materialize', { path: 'work/SRC/A.TS' }); + assert.equal(readFileSync(join(dir, 'src', 'a.ts'), 'utf8'), 'export const a = 2;\n'); + const listed = await call('ls', { path: 'work', recursive: true }); + assert.deepEqual((listed.data as { entries: Array<{ path: string }> }).entries.map((e) => e.path), ['src/a.ts', 'src/b.ts']); + assert.deepEqual(pushed().filter((e) => !e.startsWith('modified work/src/a.ts')), []); + }); + +test('read_image of a path spelled in another case names the file as disk does', { skip: FOLDING_ONLY }, async (t) => { + const { dir, call } = await setup(t); + // A 1x1 PNG. + writeFileSync(join(dir, 'src', 'dot.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64')); + const image = await call('read_image', { path: 'work/SRC/DOT.PNG' }); + const text = (image.data as Array<{ type: string; text?: string }>).find((b) => b.type === 'text')?.text ?? ''; + assert.match(text, /^Path: work\/src\/dot\.png\n/); +}); + +test('a name with ß, spelled in another case, takes disk\'s spelling', { skip: FOLDING_ONLY }, async (t) => { + const { dir, call } = await setup(t); + // ß's upper case is two letters, SS, which a disk whose folding is simple doesn't fold to ß. + writeFileSync(join(dir, 'src', 'stra\u00dfe.txt'), 'sharp s\n'); + const read = await call('read', { path: 'work/src/Stra\u00dfe.txt', offsetChars: 0, limitChars: 100 }); + assert.equal((read.data as { path: string }).path, 'work/src/stra\u00dfe.txt'); +}); + test('the public readers and writer take a path as disk spells it too', { skip: FOLDING_ONLY }, async (t) => { - const { workspace, dir } = await setup(t); + const { workspace, dir, call } = await setup(t); + await call('ls', { path: 'work', recursive: true }); + assert.deepEqual(await workspace.readBinary('work/SRC/A.TS'), { data: Buffer.from('export const a = 1;\n') }); const fromDisk = await workspace.readBinaryFromDisk('work/SRC/a.ts'); assert.ok('absolutePath' in fromDisk, JSON.stringify(fromDisk)); assert.equal(fromDisk.absolutePath, join(dir, 'src', 'a.ts')); @@ -155,7 +219,7 @@ test('a name in the other Unicode normalization form takes the form disk lists', assert.deepEqual(pushed(), []); }); -test('the step reads no further than a symlink or a `..`, and keeps the rest as spelled', { skip: FOLDING_ONLY }, async (t) => { +test('the step reads no further than a symlink or a `..`, keeping the rest as spelled, and passes over `.` and empty segments', { skip: FOLDING_ONLY }, async (t) => { const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-link-')); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'real', 'sub'), { recursive: true }); @@ -163,6 +227,8 @@ test('the step reads no further than a symlink or a `..`, and keeps the rest as assert.equal(await diskSpelling(root, 'REAL/SUB'), 'real/sub'); assert.equal(await diskSpelling(root, 'LNK/SUB'), 'lnk/SUB'); assert.equal(await diskSpelling(root, 'REAL/../REAL'), 'real/../REAL'); + assert.equal(await diskSpelling(root, 'REAL/./SUB'), 'real/./sub'); + assert.equal(await diskSpelling(root, 'REAL//SUB'), 'real//sub'); }); test('on a disk that tells case apart, two hard links that differ only in case keep their own names', From 8f5c21cc3c13ea188c5d7afe32374bff7740bbef Mon Sep 17 00:00:00 2001 From: Tabitha-2696 Date: Sun, 18 Oct 2026 09:03:42 -0400 Subject: [PATCH 3/6] fix(workspace): start and stop around the step's awaits, and the step's letters and `..` (F181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal review of the first round found that its new awaits had opened two gaps that main didn't have, and that the step missed some names a folding disk folds. - start() reads the nested mounts' spellings after it sets its context and running state, where it used to await first and set them after. A stop made while start read disk was then undone when start went on; now it leaves the workspace stopped. - stop waits for the calls under way as well as for the mounts' turns. On main a tool call took its mount's turn in the same step as the running check, so stop's drain caught every call it let through. A call now awaits disk in parsePath first, so handleToolCall, readBinary and writeBinary record each call until it settles, and stop drains them with the turns until both are quiet. - flippedCase flips a letter only where its other case is one letter whose own other case is the letter again, so dotless `ı`, whose upper case `I` lowers to `i`, stays `ı` and the probe still reaches the file. - listedName compares names as NFC in lower case first and, where none of those is the file, through upper case and back, which folds `ß` with `ss`, `ſ` with `s` and a word-final `ς` with `σ`, as a disk that folds fully does. The same-file check still decides. - diskSpelling goes back up at a `..` to the directory it came from, and still stops at a `..` at the mount's root, so the segments after any other `..` are respelled too. - Comments: parsePath says a handler that must read something when it's asked reads it first, and listedName's no longer refers to history. Tests: a stop during start leaves the workspace stopped, and a write whose parse is under way when stop begins is on disk when stop returns (both on any disk); `ss` for disk's `ß` where the disk folds fully; dotless `ı`; `..` going back up and stopping at the root; on a disk that tells case apart, an outer directory spelled otherwise than a nested mount's stays the outer mount's. The nested write's refusal and the materialize test's announcements are asserted exactly. Co-Authored-By: Claude Opus 5.5 --- .../workspace-path-disk-spelling.fixed.md | 2 +- src/modules/workspace/index.ts | 51 ++++++++-- src/modules/workspace/observe.ts | 57 +++++++---- test/workspace-path-spelling.test.ts | 97 ++++++++++++++++--- 4 files changed, 168 insertions(+), 39 deletions(-) diff --git a/changelog.d/workspace-path-disk-spelling.fixed.md b/changelog.d/workspace-path-disk-spelling.fixed.md index bed2a38dc..473531879 100644 --- a/changelog.d/workspace-path-disk-spelling.fixed.md +++ b/changelog.d/workspace-path-disk-spelling.fixed.md @@ -1 +1 @@ -- On a disk that folds names, as macOS's does by default (case, and Unicode normalization), the workspace's tools take a path as disk spells it. A `path` spelled otherwise, such as `work/SRC` for disk's `src/`, reaches disk's files, and `glob`, `grep` and `ls` used to name what they found from that spelling. So the workspace took each file in a second time under that name, announced it as created, and announced it deleted at the next pass over the mount; a `write` there was held under the name given while disk put it in `src/`. Now each segment of a `path` that reaches an entry under another spelling takes the entry's own name, from the mount's root down, and answers name files as disk lists them: `read work/SRC/a.ts` answers with `path: "work/src/a.ts"`. A segment disk doesn't have keeps the spelling given, and nothing past a symlink or a `..` is respelled. `readBinary`, `readBinaryFromDisk`, `readFileFromDisk` and `writeBinary` take their paths the same way; `resolveAbsolutePath` takes its path as given, and the path it returns reaches the same file. A nested mount's directory is now known in its configured spelling and in disk's: the tools refuse a path into it in any spelling as that mount's, and an outer mount no longer takes in the files of a nested mount whose directory was configured in another spelling than disk's. On a disk that tells spellings apart, every answer is as before. +- On a disk that folds names, as macOS's does by default (case, and Unicode normalization), the workspace's tools take a path as disk spells it. A `path` spelled otherwise, such as `work/SRC` for disk's `src/`, reaches disk's files, and `glob`, `grep` and `ls` used to name what they found from that spelling. So the workspace took each file in a second time under that name, announced it as created, and announced it deleted at the next pass over the mount; a `write` there was held under the name given while disk put it in `src/`. Now each segment of a `path` that reaches an entry under another spelling takes the entry's own name, from the mount's root down, and answers name files as disk lists them: `read work/SRC/a.ts` answers with `path: "work/src/a.ts"`. A segment disk doesn't have keeps the spelling given, and nothing past a symlink, or past a `..` that climbs above the mount's root, is respelled. `readBinary`, `readBinaryFromDisk`, `readFileFromDisk` and `writeBinary` take their paths the same way; `resolveAbsolutePath` takes its path as given, and the path it returns reaches the same file. A nested mount's directory is now known in its configured spelling and in disk's: the tools refuse a path into it in any spelling as that mount's, and an outer mount no longer takes in the files of a nested mount whose directory was configured in another spelling than disk's. On a disk that tells spellings apart, every answer is as before. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index f7b1a56a7..ce8408804 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -919,8 +919,6 @@ export class WorkspaceModule implements Module { } async start(ctx: ModuleContext): Promise { - // Before anything walks or watches a mount with another nested in it. - await this.spellNestedDirs(); this.ctx = ctx; this.stopped = false; @@ -930,6 +928,9 @@ export class WorkspaceModule implements Module { this.savedState = ctx.getState() ?? null; } + // Each nested mount's directory as disk spells it, before this start + // walks or watches the mounts. A stop made meanwhile still stops it. + await this.spellNestedDirs(); this.applySavedState(); this.ensureRunning(); } @@ -1182,11 +1183,13 @@ export class WorkspaceModule implements Module { for (const watcher of stopping) { await watcher.stop(); } + // A call under way can await disk (parsePath) before it takes its turn, + // so the calls are waited for as well as the turns. for (;;) { const tails = [...this.mountTurns.values()]; - await Promise.all(tails); + await Promise.allSettled([...this.callsUnderWay, ...tails]); const now = [...this.mountTurns.values()]; - if (now.length === tails.length && now.every((tail, i) => tail === tails[i])) break; + if (this.callsUnderWay.size === 0 && now.length === tails.length && now.every((tail, i) => tail === tails[i])) break; } // Nothing is queued now, and nothing more is taken: what follows saves // the pins and watermarks as the last work left them. @@ -1464,6 +1467,25 @@ export class WorkspaceModule implements Module { async handleToolCall(call: ToolCall): Promise { if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING }; + return this.underWay(this.runToolCall(call)); + } + + /** + * Tool calls, and the public reads and writes that take a mount's turn, + * under way. Each can await disk (parsePath) before it takes its turn, so + * stop waits for these as well as for the turns. + */ + private callsUnderWay = new Set>(); + + /** `work`, recorded as under way until it settles (callsUnderWay). */ + private underWay(work: Promise): Promise { + this.callsUnderWay.add(work); + const done = (): void => { this.callsUnderWay.delete(work); }; + work.then(done, done); + return work; + } + + private async runToolCall(call: ToolCall): Promise { try { // Every handler reads its input as its schema describes it; a call // that sends something else is refused here, before anything is read. @@ -1625,6 +1647,15 @@ export class WorkspaceModule implements Module { options: { branch?: string } = {}, ): Promise { if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING }; + return this.underWay(this.writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options)); + } + + private async writeBinaryUnderWay( + mountPrefixedPath: string, + data: Buffer, + mimeType: string, + options: { branch?: string }, + ): Promise { let mount: MountState; let relativePath: string; try { @@ -1689,6 +1720,12 @@ export class WorkspaceModule implements Module { mountPrefixedPath: string, ): Promise<{ data: Buffer } | { error: string }> { if (!this.running()) return { error: NOT_RUNNING }; + return this.underWay(this.readBinaryUnderWay(mountPrefixedPath)); + } + + private async readBinaryUnderWay( + mountPrefixedPath: string, + ): Promise<{ data: Buffer } | { error: string }> { let mount: MountState; let relativePath: string; try { @@ -1829,9 +1866,9 @@ export class WorkspaceModule implements Module { * disk spells it (diskSpelling), with `path` the mount-prefixed path so * spelled. On a disk that folds names, a path spelled otherwise reaches * disk's file, so every tool takes it by disk's name: one name per file, - * and the names its answers give are disk's. A handler reads what it reads - * at the moment it was asked, such as handleSync's branch, before calling - * this, since this awaits disk. + * and the names its answers give are disk's. A handler that must read + * something at the moment it's asked, as handleSync reads the branch a path + * sync adopts on, reads it before calling this, since this awaits disk. */ private async parsePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> { const given = this.parsePathAsGiven(path); diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index 9f3e38208..958c4987f 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -147,20 +147,31 @@ function lexicalOf(view: MountView, rel: string): string { const sameFile = (a: BigIntStats, b: BigIntStats): boolean => a.dev === b.dev && a.ino === b.ino; +/** A letter's other case: its upper case if it's in lower case, else its lower case. */ +const otherCase = (c: string): string => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()); + /** * The name with each letter's case flipped, where its other case is one - * letter: `ß`, whose upper case is `SS`, stays `ß`, so the name still reaches - * the same file on a disk whose folding is simple, letter for letter. + * letter whose own other case is the letter again, so the flip changes only + * pairs that any folding disk folds together. `ß`, whose upper case is `SS`, + * and dotless `ı`, whose upper case `I` lowers to `i`, stay as they are. */ const flippedCase = (name: string): string => [...name].map((c) => { - const other = c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase(); - return [...other].length === 1 ? other : c; + const other = otherCase(c); + return [...other].length === 1 && otherCase(other) === c ? other : c; }).join(''); -/** What a disk that folds names compares: NFC, in lower case. */ +/** Names compared as NFC in lower case: how most folding disks compare them. */ const folded = (name: string): string => name.normalize('NFC').toLowerCase(); +/** + * Names compared more loosely, through upper case and back, which folds `ß` + * with `ss`, `ſ` with `s` and a word-final `ς` with `σ`, as a disk that folds + * fully does. + */ +const foldedLoosely = (name: string): string => name.normalize('NFC').toLowerCase().toUpperCase().toLowerCase(); + /** * The name `dir` lists for `segment`: `segment` itself, unless disk folds it * there and lists it otherwise. `dir` is listed only where a variant of @@ -172,9 +183,10 @@ const folded = (name: string): string => name.normalize('NFC').toLowerCase(); * holds a variant as a name of its own. The name as given wins if it's * listed; otherwise the segment takes the listed name that folds to it and is * the same file, so the name returned reaches the file the segment does, or - * is the segment as given. A directory that can't be listed, or a name that - * can't be looked up, leaves the segment as given, as it was before this - * step. + * is the segment as given. Names are compared as NFC in lower case first, and + * only where none of those is the file, more loosely (foldedLoosely). A + * directory that can't be listed, or a name that can't be looked up, leaves + * the segment as given. */ async function listedName(dir: string, segment: string, given: BigIntStats | null): Promise { const variants = new Set([flippedCase(segment), segment.normalize('NFC'), segment.normalize('NFD')]); @@ -189,10 +201,12 @@ async function listedName(dir: string, segment: string, given: BigIntStats | nul if (listed === null || listed.includes(segment)) return segment; const target = given ?? await lstat(join(dir, segment), { bigint: true }).catch(() => null); if (target === null) return segment; - for (const name of listed) { - if (folded(name) !== folded(segment)) continue; - const info = await lstat(join(dir, name), { bigint: true }).catch(() => null); - if (info !== null && sameFile(info, target)) return name; + for (const fold of [folded, foldedLoosely]) { + for (const name of listed) { + if (fold(name) !== fold(segment)) continue; + const info = await lstat(join(dir, name), { bigint: true }).catch(() => null); + if (info !== null && sameFile(info, target)) return name; + } } return segment; } @@ -206,24 +220,31 @@ async function listedName(dir: string, segment: string, given: BigIntStats | nul * lists for it (listedName). The walk goes on beneath a segment only if it's * a directory, a symlink not included, so nothing past a link is read. It * passes over an empty or `.` segment, which names the directory reached, - * and stops at `..`, keeping the rest as given: a path can climb above the - * mount's root and come back in, as `../proj/x` does for a root named - * `proj`, and walking it would list the directory above the root. + * and goes back up at `..`, to the directory it came from. At the mount's + * root a `..` stops it, keeping the rest as given: a path can climb above + * the root and come back in, as `../proj/x` does for a root named `proj`, and + * walking that would list the directory above the root. */ export async function diskSpelling(root: string, relativePath: string): Promise { if (relativePath === '') return relativePath; const segments = relativePath.split('/'); - let dir = root; + // The directories walked into, the mount's root first. + const dirs = [root]; for (let i = 0; i < segments.length; i++) { const segment = segments[i]!; if (segment === '' || segment === '.') continue; - if (segment === '..') break; + if (segment === '..') { + if (dirs.length === 1) break; + dirs.pop(); + continue; + } + const dir = dirs[dirs.length - 1]!; const last = i === segments.length - 1; const given = last ? null : await lstat(join(dir, segment), { bigint: true }).catch(() => null); if (!last && given === null) break; segments[i] = await listedName(dir, segment, given); if (given === null || !given.isDirectory()) break; - dir = join(dir, segments[i]!); + dirs.push(join(dir, segments[i]!)); } return segments.join('/'); } diff --git a/test/workspace-path-spelling.test.ts b/test/workspace-path-spelling.test.ts index 466bf70f7..cf59618ca 100644 --- a/test/workspace-path-spelling.test.ts +++ b/test/workspace-path-spelling.test.ts @@ -1,16 +1,16 @@ // On a disk that folds names (macOS's default folds case, and Unicode // normalization), a path spelled otherwise than disk spells it reaches disk's // file. The workspace's tools take such a path as disk spells it, so they -// name each file by one name and take nothing in twice. These tests need a -// temp directory that folds case (one needs it to fold normalization), and -// skip, saying why, where it doesn't: a disk that tells spellings apart has -// nothing to respell. The last runs only there instead: two hard links whose -// names differ in case each keep their own name. +// name each file by one name and take nothing in twice. Most of these tests +// need a temp directory that folds case, and skip, saying why, where it +// doesn't, since a disk that tells spellings apart has nothing to respell; +// one needs it to fold normalization, and one to fold ß with ss. Two run only +// where the disk tells case apart, and the two about stopping run anywhere. import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { JsStore } from '@animalabs/chronicle'; import { WorkspaceModule, WorkspaceReadError } from '../src/modules/workspace/index.js'; import { diskSpelling } from '../src/modules/workspace/observe.js'; @@ -18,13 +18,18 @@ import type { MountConfig } from '../src/modules/workspace/types.js'; import type { ModuleContext } from '../src/types/module.js'; import type { ProcessEvent, ToolResult } from '../src/types/events.js'; -/** What the temp directory folds: case, and NFC against NFD. */ -const folds = ((): { case: boolean; normalization: boolean } => { +/** What the temp directory folds: case, NFC against NFD, and ß with ss. */ +const folds = ((): { case: boolean; normalization: boolean; sharpS: boolean } => { const dir = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-folds-')); try { writeFileSync(join(dir, 'Fold'), ''); writeFileSync(join(dir, 'caf\u00e9'), ''); - return { case: existsSync(join(dir, 'FOLD')), normalization: existsSync(join(dir, 'cafe\u0301')) }; + writeFileSync(join(dir, 'stra\u00dfe'), ''); + return { + case: existsSync(join(dir, 'FOLD')), + normalization: existsSync(join(dir, 'cafe\u0301')), + sharpS: existsSync(join(dir, 'STRASSE')), + }; } finally { rmSync(dir, { recursive: true, force: true }); } @@ -33,6 +38,8 @@ const FOLDING_ONLY = folds.case ? false : "the temp directory tells case apart, so a path spelled in another case reaches nothing: these run on a disk that folds case, such as macOS's default"; const NORMALIZATION_FOLDING_ONLY = folds.normalization ? false : 'the temp directory tells NFC and NFD apart, so a name in the other form reaches nothing'; +const SHARP_S_FOLDING_ONLY = folds.sharpS ? false + : 'the temp directory keeps \u00df apart from ss, so STRASSE reaches nothing where disk has stra\u00dfe'; const CASE_SENSITIVE_ONLY = folds.case ? 'the temp directory folds case, so two names that differ only in case are one name here' : false; @@ -151,7 +158,7 @@ test('a nested mount whose directory is configured in another spelling than disk assert.equal(workspace.resolveAbsolutePath(path), null, path); } const write = await workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'outer/Inner/y.txt', content: 'y\n' } } as Parameters[0]); - assert.equal(write.success, false); + assert.equal(write.error, '"outer/inner/y.txt" lies in mount "inner", nested in mount "outer", which neither reads nor writes it: use "inner/y.txt".'); assert.equal(existsSync(join(dir, 'inner', 'y.txt')), false); const synced = await call('sync', { path: 'outer/Inner' }); assert.equal((synced.data as { totalSynced: number }).totalSynced, 0); @@ -174,7 +181,9 @@ test('a sync and a materialize of a path spelled in another case act on disk\'s assert.equal(readFileSync(join(dir, 'src', 'a.ts'), 'utf8'), 'export const a = 2;\n'); const listed = await call('ls', { path: 'work', recursive: true }); assert.deepEqual((listed.data as { entries: Array<{ path: string }> }).entries.map((e) => e.path), ['src/a.ts', 'src/b.ts']); - assert.deepEqual(pushed().filter((e) => !e.startsWith('modified work/src/a.ts')), []); + const announced = pushed(); + assert.deepEqual(announced, [...new Set(announced)], 'nothing announced twice'); + assert.ok(announced.every((e) => e === 'modified work/src/a.ts'), announced.join('; ')); }); test('read_image of a path spelled in another case names the file as disk does', { skip: FOLDING_ONLY }, async (t) => { @@ -194,6 +203,21 @@ test('a name with ß, spelled in another case, takes disk\'s spelling', { skip: assert.equal((read.data as { path: string }).path, 'work/src/stra\u00dfe.txt'); }); +test('where disk folds ß with ss, a name spelled with ss takes disk\'s ß', { skip: SHARP_S_FOLDING_ONLY }, async (t) => { + const { dir, call } = await setup(t); + writeFileSync(join(dir, 'src', 'stra\u00dfe.txt'), 'sharp s\n'); + const read = await call('read', { path: 'work/src/STRASSE.txt', offsetChars: 0, limitChars: 100 }); + assert.equal((read.data as { path: string }).path, 'work/src/stra\u00dfe.txt'); +}); + +test('a name with dotless ı, spelled in another case, takes disk\'s spelling', { skip: FOLDING_ONLY }, async (t) => { + const { dir, call } = await setup(t); + // ı's upper case, I, lowers to i, which no folding disk folds with ı. + writeFileSync(join(dir, 'src', 'k\u0131r.txt'), 'dotless i\n'); + const read = await call('read', { path: 'work/src/K\u0131r.txt', offsetChars: 0, limitChars: 100 }); + assert.equal((read.data as { path: string }).path, 'work/src/k\u0131r.txt'); +}); + test('the public readers and writer take a path as disk spells it too', { skip: FOLDING_ONLY }, async (t) => { const { workspace, dir, call } = await setup(t); await call('ls', { path: 'work', recursive: true }); @@ -219,18 +243,65 @@ test('a name in the other Unicode normalization form takes the form disk lists', assert.deepEqual(pushed(), []); }); -test('the step reads no further than a symlink or a `..`, keeping the rest as spelled, and passes over `.` and empty segments', { skip: FOLDING_ONLY }, async (t) => { +test('the step reads no further than a symlink or a `..` at the root, goes back up at any other `..`, and passes over `.` and empty segments', { skip: FOLDING_ONLY }, async (t) => { const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-link-')); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'real', 'sub'), { recursive: true }); symlinkSync('real', join(root, 'lnk')); assert.equal(await diskSpelling(root, 'REAL/SUB'), 'real/sub'); assert.equal(await diskSpelling(root, 'LNK/SUB'), 'lnk/SUB'); - assert.equal(await diskSpelling(root, 'REAL/../REAL'), 'real/../REAL'); + assert.equal(await diskSpelling(root, 'REAL/../REAL'), 'real/../real'); + assert.equal(await diskSpelling(root, 'REAL/SUB/../SUB'), 'real/sub/../sub'); + assert.equal(await diskSpelling(root, `../${basename(root)}/REAL`), `../${basename(root)}/REAL`, 'a `..` at the root lists nothing above it'); assert.equal(await diskSpelling(root, 'REAL/./SUB'), 'real/./sub'); assert.equal(await diskSpelling(root, 'REAL//SUB'), 'real//sub'); }); +test('on a disk that tells case apart, a directory spelled otherwise than a nested mount\'s is the outer mount\'s own', + { skip: CASE_SENSITIVE_ONLY }, async (t) => { + const { workspace, call } = await setup(t, (d) => { + mkdirSync(join(d, 'inner'), { recursive: true }); + mkdirSync(join(d, 'Inner'), { recursive: true }); + writeFileSync(join(d, 'Inner', 'x.txt'), 'outer\n'); + return [ + { name: 'outer', path: d, mode: 'read-write', watch: 'never' }, + { name: 'inner', path: join(d, 'inner'), mode: 'read-write', watch: 'never' }, + ]; + }); + const read = await call('read', { path: 'outer/Inner/x.txt', offsetChars: 0, limitChars: 100 }); + assert.equal((read.data as { content: string }).content, 'outer\n'); + assert.notEqual(workspace.resolveAbsolutePath('outer/Inner/x.txt'), null); + const listed = await call('ls', { path: 'outer', recursive: true }); + assert.deepEqual((listed.data as { entries: Array<{ path: string }> }).entries.map((e) => e.path), ['Inner/x.txt', 'src/a.ts']); + }); + +test('a stop made while start reads a nested mount\'s directory leaves the workspace stopped', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-stop-')); + const dir = join(root, 'mount'); + mkdirSync(join(dir, 'inner'), { recursive: true }); + const store = JsStore.openOrCreate({ path: join(root, 'store') }); + const workspace = new WorkspaceModule({ mounts: [ + { name: 'outer', path: dir, mode: 'read-write', watch: 'never' }, + { name: 'inner', path: join(dir, 'inner'), mode: 'read-write', watch: 'never' }, + ] }); + workspace.initStore(store); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const started = workspace.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + await workspace.stop(); + await started; + const listed = await workspace.handleToolCall({ id: 'l', name: 'ls', input: { path: 'outer' } } as Parameters[0]); + assert.equal(listed.success, false); + assert.match(String(listed.error), /The workspace has stopped/); +}); + +test('a write still reading disk for its path when stop begins finishes before stop does', async (t) => { + const { workspace, dir } = await setup(t); + const write = workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'work/src/late.ts', content: 'late\n' } } as Parameters[0]); + await workspace.stop(); + assert.equal(readFileSync(join(dir, 'src', 'late.ts'), 'utf8'), 'late\n', 'on disk when stop returns'); + assert.equal((await write).success, true); +}); + test('on a disk that tells case apart, two hard links that differ only in case keep their own names', { skip: CASE_SENSITIVE_ONLY }, async (t) => { const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-links-')); From 3e16cc3f9c4894b7c725a691f48df4a7921ea201 Mon Sep 17 00:00:00 2001 From: Tabitha-2696 Date: Sun, 18 Oct 2026 09:58:30 -0400 Subject: [PATCH 4/6] fix(workspace): start reads the spellings before it sets anything, and tests for stop's waits (F181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal review of the second round found that its start, which set its context and running state before awaiting the nested mounts' spellings, let a stop made during that read be followed by watchers the stop never saw: ensureRunning gates on the context alone, and stop clears it only after its drain. A restart also took calls before the spellings and the saved pins were in place. - start reads the spellings first and then does what main's start did, with no await between: it sets its context and running state, applies the saved state and starts the watchers. If a stop began while it read (a count of stops begun, which stop raises first), it sets nothing. - Comments: foldedLoosely also folds dotless `ı` with `i`, which the same-file check keeps harmless, and folds `ς` with `σ` anywhere, not only at a word's end; spellNestedDirs no longer speaks for the watcher. - The changelog fragment names the tools that refuse a path into a nested mount's directory: read, read_image, write, edit and delete, and the public readers and writer. Tests: a stop made while start reads leaves the workspace stopped, with nothing announced after it, on watching mounts; a restart takes no call before it has read the spellings; a write taken while stop drains, a writeBinary and a readBinary under way when stop begins each finish before stop returns; SOAP.txt for disk's ſoap.txt and ΟΔΟΣ.txt for disk's οδος.txt where the disk folds them. The materialize test asserts that nothing is announced. Co-Authored-By: Claude Opus 5.5 --- .../workspace-path-disk-spelling.fixed.md | 2 +- src/modules/workspace/index.ts | 18 ++- src/modules/workspace/observe.ts | 9 +- test/workspace-path-spelling.test.ts | 109 +++++++++++++++--- 4 files changed, 113 insertions(+), 25 deletions(-) diff --git a/changelog.d/workspace-path-disk-spelling.fixed.md b/changelog.d/workspace-path-disk-spelling.fixed.md index 473531879..2a078c7bf 100644 --- a/changelog.d/workspace-path-disk-spelling.fixed.md +++ b/changelog.d/workspace-path-disk-spelling.fixed.md @@ -1 +1 @@ -- On a disk that folds names, as macOS's does by default (case, and Unicode normalization), the workspace's tools take a path as disk spells it. A `path` spelled otherwise, such as `work/SRC` for disk's `src/`, reaches disk's files, and `glob`, `grep` and `ls` used to name what they found from that spelling. So the workspace took each file in a second time under that name, announced it as created, and announced it deleted at the next pass over the mount; a `write` there was held under the name given while disk put it in `src/`. Now each segment of a `path` that reaches an entry under another spelling takes the entry's own name, from the mount's root down, and answers name files as disk lists them: `read work/SRC/a.ts` answers with `path: "work/src/a.ts"`. A segment disk doesn't have keeps the spelling given, and nothing past a symlink, or past a `..` that climbs above the mount's root, is respelled. `readBinary`, `readBinaryFromDisk`, `readFileFromDisk` and `writeBinary` take their paths the same way; `resolveAbsolutePath` takes its path as given, and the path it returns reaches the same file. A nested mount's directory is now known in its configured spelling and in disk's: the tools refuse a path into it in any spelling as that mount's, and an outer mount no longer takes in the files of a nested mount whose directory was configured in another spelling than disk's. On a disk that tells spellings apart, every answer is as before. +- On a disk that folds names, as macOS's does by default (case, and Unicode normalization), the workspace's tools take a path as disk spells it. A `path` spelled otherwise, such as `work/SRC` for disk's `src/`, reaches disk's files, and `glob`, `grep` and `ls` used to name what they found from that spelling. So the workspace took each file in a second time under that name, announced it as created, and announced it deleted at the next pass over the mount; a `write` there was held under the name given while disk put it in `src/`. Now each segment of a `path` that reaches an entry under another spelling takes the entry's own name, from the mount's root down, and answers name files as disk lists them: `read work/SRC/a.ts` answers with `path: "work/src/a.ts"`. A segment disk doesn't have keeps the spelling given, and nothing past a symlink, or past a `..` that climbs above the mount's root, is respelled. `readBinary`, `readBinaryFromDisk`, `readFileFromDisk` and `writeBinary` take their paths the same way; `resolveAbsolutePath` takes its path as given, and the path it returns reaches the same file. A nested mount's directory is now known in its configured spelling and in disk's: `read`, `read_image`, `write`, `edit` and `delete`, and the public readers and writer, refuse a path into it spelled in another case or normalization form as that mount's, and an outer mount no longer takes in the files of a nested mount whose directory was configured in another spelling than disk's. On a disk that tells spellings apart, every answer is as before. diff --git a/src/modules/workspace/index.ts b/src/modules/workspace/index.ts index ce8408804..e9d5a7c35 100644 --- a/src/modules/workspace/index.ts +++ b/src/modules/workspace/index.ts @@ -676,6 +676,8 @@ export class WorkspaceModule implements Module { private store: JsStore | null = null; /** Set once stop has drained and saved, cleared by start (running()). */ private stopped = false; + /** Stops begun, so a start that read disk meanwhile can tell that one came. */ + private stopsBegun = 0; private config: WorkspaceConfig; private mounts = new Map(); private watchers = new Map(); @@ -919,6 +921,14 @@ 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. + const stops = this.stopsBegun; + await this.spellNestedDirs(); + if (this.stopsBegun !== stops) return; + this.ctx = ctx; this.stopped = false; @@ -928,9 +938,6 @@ export class WorkspaceModule implements Module { this.savedState = ctx.getState() ?? null; } - // Each nested mount's directory as disk spells it, before this start - // walks or watches the mounts. A stop made meanwhile still stops it. - await this.spellNestedDirs(); this.applySavedState(); this.ensureRunning(); } @@ -1173,6 +1180,7 @@ export class WorkspaceModule implements Module { } async stop(): Promise { + this.stopsBegun++; // No new watcher-driven passes, then let every pass and write finish // before the store goes away — including any queued behind the ones in // flight (a tool call, a scan continuing past its deadline): drained @@ -1793,8 +1801,8 @@ 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 and watcher name the directory as disk does, and - * so does a path parsePath has respelled, while a path given as configured + * 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. */ diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index 958c4987f..4deee6d3c 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -162,13 +162,14 @@ const flippedCase = (name: string): string => return [...other].length === 1 && otherCase(other) === c ? other : c; }).join(''); -/** Names compared as NFC in lower case: how most folding disks compare them. */ +/** Names compared as NFC in lower case. */ const folded = (name: string): string => name.normalize('NFC').toLowerCase(); /** - * Names compared more loosely, through upper case and back, which folds `ß` - * with `ss`, `ſ` with `s` and a word-final `ς` with `σ`, as a disk that folds - * fully does. + * Names compared more loosely, through upper case and back, which also folds + * `ß` with `ss`, `ſ` with `s` and `ς` with `σ`, as a disk that folds fully + * does. It also folds dotless `ı` with `i`, which a disk may keep apart; + * the same-file check keeps that from naming another file. */ const foldedLoosely = (name: string): string => name.normalize('NFC').toLowerCase().toUpperCase().toLowerCase(); diff --git a/test/workspace-path-spelling.test.ts b/test/workspace-path-spelling.test.ts index cf59618ca..1f8e2300d 100644 --- a/test/workspace-path-spelling.test.ts +++ b/test/workspace-path-spelling.test.ts @@ -4,8 +4,9 @@ // name each file by one name and take nothing in twice. Most of these tests // need a temp directory that folds case, and skip, saying why, where it // doesn't, since a disk that tells spellings apart has nothing to respell; -// one needs it to fold normalization, and one to fold ß with ss. Two run only -// where the disk tells case apart, and the two about stopping run anywhere. +// one needs it to fold normalization, and three to fold ß with ss, ſ with s +// and ς with σ. Two run only where the disk tells case apart, and those about +// starting and stopping run anywhere. import test, { type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; @@ -18,17 +19,21 @@ import type { MountConfig } from '../src/modules/workspace/types.js'; import type { ModuleContext } from '../src/types/module.js'; import type { ProcessEvent, ToolResult } from '../src/types/events.js'; -/** What the temp directory folds: case, NFC against NFD, and ß with ss. */ -const folds = ((): { case: boolean; normalization: boolean; sharpS: boolean } => { +/** What the temp directory folds: case, NFC against NFD, ß with ss, ſ with s, and ς with σ. */ +const folds = ((): { case: boolean; normalization: boolean; sharpS: boolean; longS: boolean; finalSigma: boolean } => { const dir = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-folds-')); try { writeFileSync(join(dir, 'Fold'), ''); writeFileSync(join(dir, 'caf\u00e9'), ''); writeFileSync(join(dir, 'stra\u00dfe'), ''); + writeFileSync(join(dir, '\u017foap'), ''); + writeFileSync(join(dir, '\u03bf\u03b4\u03bf\u03c2'), ''); return { case: existsSync(join(dir, 'FOLD')), normalization: existsSync(join(dir, 'cafe\u0301')), sharpS: existsSync(join(dir, 'STRASSE')), + longS: existsSync(join(dir, 'SOAP')), + finalSigma: existsSync(join(dir, '\u03bf\u03b4\u03bf\u03c3')), }; } finally { rmSync(dir, { recursive: true, force: true }); @@ -40,6 +45,10 @@ const NORMALIZATION_FOLDING_ONLY = folds.normalization ? false : 'the temp directory tells NFC and NFD apart, so a name in the other form reaches nothing'; const SHARP_S_FOLDING_ONLY = folds.sharpS ? false : 'the temp directory keeps \u00df apart from ss, so STRASSE reaches nothing where disk has stra\u00dfe'; +const LONG_S_FOLDING_ONLY = folds.longS ? false + : 'the temp directory keeps \u017f apart from s, so SOAP reaches nothing where disk has \u017foap'; +const FINAL_SIGMA_FOLDING_ONLY = folds.finalSigma ? false + : 'the temp directory keeps \u03c2 apart from \u03c3, so \u039f\u0394\u039f\u03a3 reaches nothing where disk has \u03bf\u03b4\u03bf\u03c2'; const CASE_SENSITIVE_ONLY = folds.case ? 'the temp directory folds case, so two names that differ only in case are one name here' : false; @@ -181,9 +190,7 @@ test('a sync and a materialize of a path spelled in another case act on disk\'s assert.equal(readFileSync(join(dir, 'src', 'a.ts'), 'utf8'), 'export const a = 2;\n'); const listed = await call('ls', { path: 'work', recursive: true }); assert.deepEqual((listed.data as { entries: Array<{ path: string }> }).entries.map((e) => e.path), ['src/a.ts', 'src/b.ts']); - const announced = pushed(); - assert.deepEqual(announced, [...new Set(announced)], 'nothing announced twice'); - assert.ok(announced.every((e) => e === 'modified work/src/a.ts'), announced.join('; ')); + assert.deepEqual(pushed(), [], 'nothing announced: the materialize wrote disk\'s file, which the listing finds as the workspace holds it'); }); test('read_image of a path spelled in another case names the file as disk does', { skip: FOLDING_ONLY }, async (t) => { @@ -210,9 +217,24 @@ test('where disk folds ß with ss, a name spelled with ss takes disk\'s ß', { s assert.equal((read.data as { path: string }).path, 'work/src/stra\u00dfe.txt'); }); +test('where disk folds ſ with s, a name spelled with s takes disk\'s ſ', { skip: LONG_S_FOLDING_ONLY }, async (t) => { + const { dir, call } = await setup(t); + writeFileSync(join(dir, 'src', '\u017foap.txt'), 'long s\n'); + const read = await call('read', { path: 'work/src/SOAP.txt', offsetChars: 0, limitChars: 100 }); + assert.equal((read.data as { path: string }).path, 'work/src/\u017foap.txt'); +}); + +test('where disk folds ς with σ, a name spelled with σ takes disk\'s ς', { skip: FINAL_SIGMA_FOLDING_ONLY }, async (t) => { + const { dir, call } = await setup(t); + // Lower case keeps them apart here: ΟΔΟΣ.txt lowers to οδοσ.txt, since a letter follows the dot. + writeFileSync(join(dir, 'src', '\u03bf\u03b4\u03bf\u03c2.txt'), 'final sigma\n'); + const read = await call('read', { path: 'work/src/\u039f\u0394\u039f\u03a3.txt', offsetChars: 0, limitChars: 100 }); + assert.equal((read.data as { path: string }).path, 'work/src/\u03bf\u03b4\u03bf\u03c2.txt'); +}); + test('a name with dotless ı, spelled in another case, takes disk\'s spelling', { skip: FOLDING_ONLY }, async (t) => { const { dir, call } = await setup(t); - // ı's upper case, I, lowers to i, which no folding disk folds with ı. + // ı's upper case, I, lowers to i, which Unicode's case folding keeps apart from ı. writeFileSync(join(dir, 'src', 'k\u0131r.txt'), 'dotless i\n'); const read = await call('read', { path: 'work/src/K\u0131r.txt', offsetChars: 0, limitChars: 100 }); assert.equal((read.data as { path: string }).path, 'work/src/k\u0131r.txt'); @@ -275,25 +297,52 @@ test('on a disk that tells case apart, a directory spelled otherwise than a nest assert.deepEqual((listed.data as { entries: Array<{ path: string }> }).entries.map((e) => e.path), ['Inner/x.txt', 'src/a.ts']); }); -test('a stop made while start reads a nested mount\'s directory leaves the workspace stopped', async (t) => { - const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-stop-')); +/** A workspace with a mount nested in another, stored but not yet started. */ +function nestedWorkspace(t: TestContext, watch?: 'never'): { workspace: WorkspaceModule; ls: () => Promise } { + const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-start-')); const dir = join(root, 'mount'); mkdirSync(join(dir, 'inner'), { recursive: true }); const store = JsStore.openOrCreate({ path: join(root, 'store') }); const workspace = new WorkspaceModule({ mounts: [ - { name: 'outer', path: dir, mode: 'read-write', watch: 'never' }, - { name: 'inner', path: join(dir, 'inner'), mode: 'read-write', watch: 'never' }, + { name: 'outer', path: dir, mode: 'read-write', ...(watch ? { watch } : {}) }, + { name: 'inner', path: join(dir, 'inner'), mode: 'read-write', ...(watch ? { watch } : {}) }, ] }); workspace.initStore(store); - t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); - const started = workspace.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + t.after(async () => { await workspace.stop(); store.close(); rmSync(root, { recursive: true, force: true }); }); + const ls = () => workspace.handleToolCall({ id: 'l', name: 'ls', input: { path: 'outer' } } as Parameters[0]); + return { workspace, ls }; +} + +test('a stop made while start reads a nested mount\'s directory leaves the workspace stopped, watching nothing', async (t) => { + // Both mounts watch by default, so a start that went on after the stop + // would start their watchers and announce the mounts. + const { workspace, ls } = nestedWorkspace(t); + const events: ProcessEvent[] = []; + const started = workspace.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: (e: ProcessEvent) => events.push(e) } as unknown as ModuleContext); await workspace.stop(); + const atStop = events.length; await started; - const listed = await workspace.handleToolCall({ id: 'l', name: 'ls', input: { path: 'outer' } } as Parameters[0]); + assert.deepEqual(events.slice(atStop).map((e) => e.type), [], 'nothing announced, so no watcher started, after stop returned'); + const listed = await ls(); assert.equal(listed.success, false); assert.match(String(listed.error), /The workspace has stopped/); }); +test('a restart takes no call before it has read the nested mounts\' directories', async (t) => { + const { workspace, ls } = nestedWorkspace(t, 'never'); + let saved: unknown = null; + const ctx = { isRestart: false, getState: () => saved, setState: (state: unknown) => { saved = state; }, pushEvent: () => {} }; + await workspace.start(ctx as unknown as ModuleContext); + await workspace.stop(); + const restarted = workspace.start({ ...ctx, isRestart: true } as unknown as ModuleContext); + const during = await ls(); + await restarted; + assert.equal(during.success, false); + assert.match(String(during.error), /The workspace has stopped/); + const after = await ls(); + assert.equal(after.success, true, String(after.error)); +}); + test('a write still reading disk for its path when stop begins finishes before stop does', async (t) => { const { workspace, dir } = await setup(t); const write = workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'work/src/late.ts', content: 'late\n' } } as Parameters[0]); @@ -302,6 +351,36 @@ test('a write still reading disk for its path when stop begins finishes before s assert.equal((await write).success, true); }); +test('a write taken while stop drains finishes before stop does', async (t) => { + const { workspace, dir } = await setup(t); + const stopping = workspace.stop(); + // stop takes calls until it has drained, so this one, made after it began, is taken. + const write = workspace.handleToolCall({ id: 'w', name: 'write', input: { path: 'work/src/late.ts', content: 'late\n' } } as Parameters[0]); + await stopping; + assert.equal(readFileSync(join(dir, 'src', 'late.ts'), 'utf8'), 'late\n', 'on disk when stop returns'); + assert.equal((await write).success, true); +}); + +test('a writeBinary still reading disk for its path when stop begins finishes before stop does', async (t) => { + const { workspace, dir } = await setup(t); + const written = workspace.writeBinary('work/src/late.bin', Buffer.from('late'), 'application/octet-stream'); + await workspace.stop(); + assert.equal(readFileSync(join(dir, 'src', 'late.bin'), 'utf8'), 'late', 'on disk when stop returns'); + assert.equal((await written).success, true); +}); + +test('a readBinary still reading disk for its path when stop begins finishes before stop does', async (t) => { + const { workspace } = await setup(t); + let finished = false; + const read = workspace.readBinary('work/src/a.ts').then((result) => { finished = true; return result; }); + await workspace.stop(); + // Its promise settles a microtask or two after the read does; a read stop + // didn't wait for is still waiting on disk then. + for (let i = 0; i < 3; i++) await null; + assert.equal(finished, true, 'finished when stop returns'); + assert.deepEqual(await read, { data: Buffer.from('export const a = 1;\n') }); +}); + test('on a disk that tells case apart, two hard links that differ only in case keep their own names', { skip: CASE_SENSITIVE_ONLY }, async (t) => { const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-links-')); From 86fa0adf1334b1091175b65663a68398bd7577aa Mon Sep 17 00:00:00 2001 From: Tabitha-2696 Date: Sun, 18 Oct 2026 10:58:25 -0400 Subject: [PATCH 5/6] fix(workspace): an absolute path after a mount's name keeps its spelling (F181) A tool path whose part after the mount's name is absolute, such as `work//tmp/work/a.txt` for a mount at `/tmp/work`, passes the containment check when it resolves inside the mount. diskSpelling then passed over its leading empty segment and walked the rest from the mount's root, so on a folding mount holding `TMP/work/`, it respelled the path to `/TMP/work/a.txt`. readBinaryFromDisk resolves its path against the mount's root, so it would read that path outside the mount, where the filesystem above the mount tells case apart, after the containment check had passed on the path as given. Greptile's review of #525 found it. diskSpelling now returns an absolute path unchanged, as it does an empty one: such a path names a place from the filesystem's root, not the mount's. Tests: diskSpelling keeps `/REAL/SUB` as given where the mount holds `real/sub`, and readBinaryFromDisk, given the absolute path of a file in the mount after the mount's name, with a directory in the mount spelled as that path's first segment in another case, reads that file. Co-Authored-By: Claude Opus 5.5 --- src/modules/workspace/observe.ts | 9 ++++++--- test/workspace-path-spelling.test.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/modules/workspace/observe.ts b/src/modules/workspace/observe.ts index 4deee6d3c..e69a2a6ef 100644 --- a/src/modules/workspace/observe.ts +++ b/src/modules/workspace/observe.ts @@ -41,7 +41,7 @@ import { constants as fsConstants } from 'node:fs'; import type { BigIntStats } from 'node:fs'; import { lstat, mkdir, open, readdir, realpath, stat, statfs } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; -import { dirname, join, posix, sep } from 'node:path'; +import { dirname, isAbsolute, join, posix, sep } from 'node:path'; import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js'; export interface MountView { @@ -224,10 +224,13 @@ async function listedName(dir: string, segment: string, given: BigIntStats | nul * and goes back up at `..`, to the directory it came from. At the mount's * root a `..` stops it, keeping the rest as given: a path can climb above * the root and come back in, as `../proj/x` does for a root named `proj`, and - * walking that would list the directory above the root. + * walking that would list the directory above the root. An absolute path + * keeps its spelling too: it names a place from the filesystem's root, not + * the mount's, so walking it from the mount's root would respell it against + * other directories, and the path returned could lead outside the mount. */ export async function diskSpelling(root: string, relativePath: string): Promise { - if (relativePath === '') return relativePath; + if (relativePath === '' || isAbsolute(relativePath)) return relativePath; const segments = relativePath.split('/'); // The directories walked into, the mount's root first. const dirs = [root]; diff --git a/test/workspace-path-spelling.test.ts b/test/workspace-path-spelling.test.ts index 1f8e2300d..f0148f1e9 100644 --- a/test/workspace-path-spelling.test.ts +++ b/test/workspace-path-spelling.test.ts @@ -277,6 +277,19 @@ test('the step reads no further than a symlink or a `..` at the root, goes back assert.equal(await diskSpelling(root, `../${basename(root)}/REAL`), `../${basename(root)}/REAL`, 'a `..` at the root lists nothing above it'); assert.equal(await diskSpelling(root, 'REAL/./SUB'), 'real/./sub'); assert.equal(await diskSpelling(root, 'REAL//SUB'), 'real//sub'); + assert.equal(await diskSpelling(root, '/REAL/SUB'), '/REAL/SUB', 'an absolute path is not walked from the mount\'s root'); +}); + +test('a path whose part after the mount\'s name is absolute keeps its spelling, so a disk reader reads the file it names', { skip: FOLDING_ONLY }, async (t) => { + const { workspace, dir } = await setup(t); + // The mount holds a directory spelled as the first segment of its own + // absolute path in another case, so walking that path from the mount's + // root would respell it and lead to a place outside the mount. + const first = dir.split('/')[1]!; + mkdirSync(join(dir, first === first.toUpperCase() ? first.toLowerCase() : first.toUpperCase())); + const fromDisk = await workspace.readBinaryFromDisk(`work/${join(dir, 'src', 'a.ts')}`); + assert.ok('absolutePath' in fromDisk, JSON.stringify(fromDisk)); + assert.equal(fromDisk.absolutePath, join(dir, 'src', 'a.ts')); }); test('on a disk that tells case apart, a directory spelled otherwise than a nested mount\'s is the outer mount\'s own', From 697e20d60a7334ae75a1171f1e6d8983a0ab89cb Mon Sep 17 00:00:00 2001 From: Tabitha-2696 Date: Sun, 18 Oct 2026 11:11:47 -0400 Subject: [PATCH 6/6] test(workspace): a link into a nested mount's directory configured in another spelling is that mount's (F181) #468's link checks name a link into a nested mount's directory as that mount's (other_mount) by comparing the link's target with nestedOf's directories. The merge of main made nestedOf take every spelling of a nested mount's directory. This test pins that: on a folding disk, a link spelled as disk spells the directory, where the nested mount is configured in another spelling, is refused by readFileFromDisk as the nested mount's, naming the path to use there. Co-Authored-By: Claude Opus 5.5 --- test/workspace-path-spelling.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/workspace-path-spelling.test.ts b/test/workspace-path-spelling.test.ts index f0148f1e9..0e79c642d 100644 --- a/test/workspace-path-spelling.test.ts +++ b/test/workspace-path-spelling.test.ts @@ -292,6 +292,30 @@ test('a path whose part after the mount\'s name is absolute keeps its spelling, assert.equal(fromDisk.absolutePath, join(dir, 'src', 'a.ts')); }); +test('a link into a nested mount\'s directory configured in another spelling than disk\'s is refused as that mount\'s', { skip: FOLDING_ONLY }, async (t) => { + const root = mkdtempSync(join(tmpdir(), 'workspace-path-spelling-nested-link-')); + const dir = join(root, 'mount'); + mkdirSync(join(dir, 'inner', 'sub'), { recursive: true }); + writeFileSync(join(dir, 'inner', 'sub', 'x.txt'), 'inner\n'); + // The link spells the directory as disk does, and the nested mount is + // configured in another spelling. + symlinkSync('inner/sub', join(dir, 'l')); + const store = JsStore.openOrCreate({ path: join(root, 'store') }); + const workspace = new WorkspaceModule({ mounts: [ + { name: 'outer', path: dir, mode: 'read-write', watch: 'never' }, + { name: 'nested', path: join(dir, 'Inner'), mode: 'read-write', watch: 'never' }, + ] }); + workspace.initStore(store); + await workspace.start({ isRestart: false, getState: () => null, setState: () => {}, pushEvent: () => {} } as unknown as ModuleContext); + t.after(async () => { await workspace.stop(); store.close(); rmSync(root, { recursive: true, force: true }); }); + await assert.rejects(workspace.readFileFromDisk('outer/l/x.txt'), (err: unknown) => { + assert.ok(err instanceof WorkspaceReadError, String(err)); + assert.equal(err.code, 'other_mount', err.message); + assert.match(err.message, /it lies in mount "nested", nested in mount "outer": use "nested\/sub" in place of "outer\/l"/); + return true; + }); +}); + test('on a disk that tells case apart, a directory spelled otherwise than a nested mount\'s is the outer mount\'s own', { skip: CASE_SENSITIVE_ONLY }, async (t) => { const { workspace, call } = await setup(t, (d) => {