Repository navigation
fix(workspace): take a path as disk spells it on a disk that folds names - #525
Conversation
…sk'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 d1b9d35 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 (anima-research#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 <noreply@anthropic.com>
… internal review's other points (F181) 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 <noreply@anthropic.com>
…'s letters and `..` (F181) 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 <noreply@anthropic.com>
…d tests for stop's waits (F181) 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 <noreply@anthropic.com>
[Medium impact] The changes reach workspace file paths and lifecycle calls across the workspace feature. The absolute-suffix boundary bug should be fixed before merging because Findings
Fix with agent prompt### Issue 1
src/modules/workspace/index.ts:1882-1887
`parsePathAsGiven` accepts an absolute suffix when it resolves inside the mount, but `diskSpelling` walks that suffix from the mount root. If `/tmp/work` folds case, its parent distinguishes case, and the mount contains `TMP/work/`, then `work//tmp/work/a.txt` passes the guard but can become `work//TMP/work/a.txt`. `readBinaryFromDisk` then reads `/TMP/work/a.txt` outside the mount, if that file exists.
Reject absolute suffixes before this walk, or convert them to truly mount-relative paths and check the result again.
> **How this was verified:** The rewritten absolute suffix reaches `stat` and `readFile` in `readBinaryFromDisk` without another mount-boundary check.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThe PR makes workspace paths use the names listed on disk and keeps nested mounts separate under both configured and disk spellings.
Acknowledged limits:
Reviews (1) · Last reviewed commit: "fix(workspace): start reads the spelling..." · Reviewed by Greptile |
| 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}`, |
There was a problem hiding this comment.
parsePathAsGiven accepts an absolute suffix when it resolves inside the mount, but diskSpelling walks that suffix from the mount root. If /tmp/work folds case, its parent distinguishes case, and the mount contains TMP/work/, then work//tmp/work/a.txt passes the guard but can become work//TMP/work/a.txt. readBinaryFromDisk then reads /TMP/work/a.txt outside the mount, if that file exists.
Reject absolute suffixes before this walk, or convert them to truly mount-relative paths and check the result again.
How this was verified: The rewritten absolute suffix reaches
statandreadFileinreadBinaryFromDiskwithout another mount-boundary check.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/modules/workspace/index.ts
Line: 1882-1887
Comment:
**Reads can leave the mount**
`parsePathAsGiven` accepts an absolute suffix when it resolves inside the mount, but `diskSpelling` walks that suffix from the mount root. If `/tmp/work` folds case, its parent distinguishes case, and the mount contains `TMP/work/`, then `work//tmp/work/a.txt` passes the guard but can become `work//TMP/work/a.txt`. `readBinaryFromDisk` then reads `/TMP/work/a.txt` outside the mount, if that file exists.
Reject absolute suffixes before this walk, or convert them to truly mount-relative paths and check the result again.
> **How this was verified:** The rewritten absolute suffix reaches `stat` and `readFile` in `readBinaryFromDisk` without another mount-boundary check.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in 86fa0adf. diskSpelling now returns an absolute path unchanged, as it does an empty one, so a path whose part after the mount's name is absolute keeps the spelling the containment check passed. A new test reads a file in the mount through readBinaryFromDisk with such a path, beside a directory in the mount spelled as that path's first segment in another case. With the check taken out, that test fails on a folding filesystem mounted under one that tells case apart, which is the case you describe.
Written by Tabitha-2696 (Claude Opus 5.5).
— Lottie-2661
…ing (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 anima-research#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 <noreply@anthropic.com>
main now holds anima-research#468, which reads otherMounts in the shape this change replaces. Two import lines conflicted, ./observe.js's in index.ts and node:path's in observe.ts, and the merge takes both sides' names in each. anima-research#468's nestedOf now takes every spelling of a nested mount's directory, as othersOf does, so the merge typechecks and a link into a nested mount's directory reads as that mount's in either spelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… another spelling is that mount's (F181) anima-research#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 <noreply@anthropic.com>
main at c0155d3 brings anima-research#525, which landed before this branch, so this branch carries it. Its rule: every file tool takes a path as disk spells it (parseFilePath, now async, through diskSpelling), so on a disk that folds names one file has one name, and the tools' answers name it as disk does. The one conflict is the start of handleEdit, whose work applyEdits does: - editablePath awaits parseFilePath, as handleEdit did, and gives each edit's file its mount-relative path and its mount-prefixed path as disk spells them. applyEdits keys each file by the first, so two spellings of one file on a folding disk are one file, planned once from one text, and its answers name it by the second. - The plain-spelling and root checks read that path. diskSpelling keeps empty, "." and ".." segments as given, so a path not spelled plainly is still refused, naming its plain spelling. writeBinary's split into writeBinary and writeBinaryUnderWay merges as git made it, with this branch's answer to a store that fails after recording inside writeBinaryUnderWay. test/workspace-path-spelling.test.ts adds edit_many beside anima-research#525's edit: two spellings of one file in one call, planned once and named as disk spells it. Like the rest of that file it runs where the temp directory folds case, and skips elsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The include (9f3e429) is on main 2482625. Since then main has gained, in the workspace module, anima-research#460's nested mounts and editor files, anima-research#468's refusal of a link on a mount that doesn't follow links, anima-research#511's walk start for glob and grep, and anima-research#525's disk spelling. Where they meet the include: - Nested mounts (anima-research#460): a path in a nested mount's directory is that mount's, whatever the outer mount's include covers. For every tool the nested mount's refusal comes first and the include's after: refuseNotHeld, which parseFilePath and resolveAbsolutePath share, and refusesAsOutside, which defers to holderOf. A nested mount's own include decides inside its directory. Status and entriesByEvidence leave out a path outside the include beside one in another mount's directory (ownPaths). - Editor files (anima-research#460): main passes over editors' swap and backup files on every mount. The walk checks those reasons first and the include after, so a file both editor-named and uncovered counts under the editor reason, and passedOverByDesign counts OUTSIDE_INCLUDE with them. The include's walk, history and watcher tests now use names anima-research#460 doesn't pass over. - Links (anima-research#468): a link's code says no more than its message: other_mount only where the refusal names the nested mount, parent_symlink otherwise. namesHolder (observe.ts) is the one test linkWords and linkReadError use, and nestedHolder takes any { dir, name }, so linkWords keeps the nested mount's include. - glob and grep (anima-research#511): the include refuses an uncovered path before walkStart, and the walk judges each file by the include wherever it starts. - Disk spelling (anima-research#525): parsePathAsGiven is the include's reader, and a tool's path reaches diskSpelling only after it, so the step never sees an empty, `.` or `..` segment, and a path whose part after the mount's name is absolute is refused before it. The include judges a path as disk spells it, and its refusal shows the path as given, since disk's spelling of a name the include doesn't cover would tell whether a file is there. Past a nested mount's directory, an answer shows the rest of the path as given, set once in parsePath (nestedDepth). resolveAbsolutePath stays synchronous and judges the path as given. diskSpelling keeps its branches for '', '.', '..' and an absolute rest, which no tool's path reaches now: they keep it total, and its own test pins them. Texts: MountConfig.include's doc and the two fragments say how links, nested mounts and disks that fold names meet the include, and the spellings fragment names a draft stored under a key the one spelling no longer names, such as `a//b.txt`, with how to recover it from the store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, not only where a path names it The nested-mount checks (#460) read paths and roots lexically, so an outer mount reached an inner mount's directory three ways it couldn't see: - A root configured through a symlink, or beneath one, wasn't nested at all. The outer mount's sync took the inner mount's files in, and a read of `outer/a/b/x.txt` returned the inner's file. That held both for an inner mount configured through a link into the outer one, and for an outer mount configured through a link to the directory holding the inner one. - On a mount that follows symlinks, a link into the inner mount's directory passed every check. A read through it returned the inner's file and stored it as the outer's. A forced push through a directory link wrote a new file into the inner mount, and a forced unlink removed the inner's file. A forced push through a file link overwrote the inner's file. - On a mount that doesn't follow symlinks, #468 refuses every push, unlink and disk read through such a link. A write through it still stored a draft that no push could write. The changes: - Each mount's root is resolved once, at construction (realRoot, realpathSync.native, the same resolution as the async realpath the checks use). A root not made yet takes its nearest existing directory's real path, with the rest as configured. A mount is nested in another where its configured path or its real root lies beneath the other's, and each way it lies there is a spelling of its directory (#525's model), passed over by every walk and push and refused by the tools' lexical check. spellNestedDirs respells each spelling found at construction (`fromRoots`), not only the first. - MountView's nested mounts carry their real root. Every check that resolves a path canonically also asks nestedAtReal: locateDir, parentPlacement, observePath's followed link and openContained in observe.ts; checkOpened and unlinkContained's followed link in effects.ts; and openContainedFile in index.ts. A path whose real location is in a nested mount's directory is refused there, as `"work/l" leads into mount "inner", nested in mount "work": use "inner/sub" in its place`. createDir asks it before each mkdir, so no directory is made where a nested root, made or not, would be (Greptile's G3 on #282). - parseFilePath, on a mount with a mount nested in it, resolves the path (its nearest existing directory's real path, with the rest as given) and refuses one that lands in a nested mount's directory before anything is stored. On a mount that follows links the refusal is NestedMountError's, with "leads into" in place of "lies in". On one that doesn't, it's the link's own refusal (linkWords, as #468's readers and pushes give it), with its code. It runs before the mount's turn, since the checks where disk is acted on are the boundary (Ada-1017, room-203 #145666), and a mount with nothing nested skips it. - readFileFromDisk passes a WorkspaceReadError from parsing through with its own code, rather than reading every parse failure as `traversal`. - The watcher watches and reads its events against the resolved root: a root configured as `./m` matched none of chokidar's normalized paths, so the mount heard nothing. Tests: test/workspace-nested-mount-roots.test.ts, sixteen. They cover the three root configurations; Esther-2584's link into the nested directory, for reads, writes, the disk readers and a named sync; Ada-1017's link to a directory holding the nested mount; a file link into it, through a sync, a read and a forced push; #324's alias, through a forced push and the file tools; the disk reader after a link swapped in past the tools' check; a write through an unfollowed link; the four checks where disk is acted on, called directly (the contained write and unlink are #282's 577258f, by Ada-1017 at slimepriestess's ask); and a `./m` watcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d review round's fixes Ada-1017's call (room-203 #146358), after Oscar-1808's question (#146326): a synchronous realpath at construction held the whole event loop on a share that stops answering, the class #311 names. - The roots are placed at each start, awaited, before start sets anything (readNesting, where #525's spellNestedDirs read disk's spellings). The constructor nests by configured path, as #460 does, so a call before start has that nesting, as it has #525's spellings only from start. A root moved while the module was stopped is placed again at the next start. realRoot, the synchronous copy, goes: realBeneath places roots and paths. - realBeneath reads a link's target from the link's directory as it is on disk, and resolves nothing lexically on the way, so a `..` in a target is taken from where the links before it lead, as the kernel takes it (phoebe-af-mount-roots-r2 I1). A path it can't place, such as one behind a loop, is resolved lexically from there. - A mount whose real root is the outer mount's own, or holds it, keeps its nesting by configured path and has no real root to claim by (r2 I2): round 1's skip dropped #460's nesting for it too, so a following outer walked into the link and a write through it was stored. - read_image's mapping of other_mount keeps its comment true for the checks this change adds. - Tests: a link with a `..` target under a link (I1); the own-root mount on a following outer, through its link (I2, I4); a mount linked to a directory above the outer (I3); a write through a link to a place not made yet on a mount that doesn't follow links (I8); the dangling root link with a relative target (I10); a root moved while stopped; and, from Langston-2479's read (#146366), read_image with a link swapped in after the tools' check, and a path that is itself a link into a nested mount on a mount that doesn't follow links. The changelog says the last one's code changes from symlink to other_mount. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
af-ws-planner-whole read all of anima-research#463 at c63578f, with the Sol battery nissa asked for before she answers its merge. Its issues that were real here, by number: - I1: edit refused its own input's problems only after looking its path up on disk, which since anima-research#525 reads directories; main refused them first. applyEdits now refuses edit's input problems before any path is parsed. - I2: edit_many's receipt counted files it didn't list only in filesNotListed. Its last line now counts them too ("N of the call's M files"). - I8: shownRegions sliced, split and numbered a whole region before measuring it, so a new text of millions of lines built millions of numbered lines for a receipt that names the region by its lines. It now builds a region's lines one at a time while they fit. An emptied file's region then shows as nothing, so the guard that skipped it goes. - I9: with readForm's accept, scan searched again from each place it passed over, so a long run of one character cost the run times the old text (2 s of CPU for 200,000 and 50,000). scan now finds every occurrence in one pass there (Knuth-Morris-Pratt), giving the old answers: 300,000 generated cases matched. - I26: the receipt kept 600 characters aside for the notes it adds after its budget, which twenty ranges of eight-digit line numbers and every count can pass. It now keeps the longest those notes can be for the call (receiptReserve), as fitted keeps a refusal's last line. - I27: Math.min's spread over a problem's edits, and a spread of a file's problems into push, take one argument each, which a call of 200,000 edits passes. Both are loops. - I31: on a mount that doesn't push, the answer to a store that failed after recording the edits left out the conflict the success answer names. It names it now, for edit and edit_many. Tests for each, and for the pieces whose removal no test caught: - edit's cap (I12); - a region measured as JSON (I13); - surrogate pairs at three cuts (I14); - CRLF strings (I15); - an emptied file (I16); - an exact match before a quote match (I17); - each occurrence's own line end (I18); - each BOM byte (I19); - no await between the read and the set (I20); - the materialize-failed event (I21); - a missing blob (I22); - three count wordings (I24); - a match on a line end (I37); - an in-process call's cap (I39); - dotted names reaching their files (I40). The clash-cost test now holds the CPU time planning takes, not the wall clock (I5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ke, and a root is found as its own entry (F194) Answers the fourth review of this change, of 4f4224f..11d5561, and Ezra-2423's read of it. - usable is asked of the whole path an entry's name would make: the segments taken so far, the name, and the rest as given up to a `..`. Asked of the name's prefix alone, it refused an entry's spelling of a nested mount's parent for every path through it, so a sibling such as proj/z.txt beside a nested proj/inner kept the spelling given and made a second entry beside the draft Proj/z.txt. A path into the nested directory is still refused its unknown spellings, and the directory itself still takes only the configured or disk's spelling. - foldsCase tells whether the holder folds by its own entries: lstat of the root's name against lstat of the flipped name, with the device check still through links. A root that is a link is found as itself, so an empty one on a folding disk folds again (Ezra-2423's probe), and one that links to its flipped twin on a disk that tells case apart doesn't fold. - Docs: entryName, lowered (ß takes its own lower case, only İ comes back as two letters), listedName's caseFolds for a segment not looked up as given, diskSpelling, usable and entriesOf for the whole-path rule and the loose comparison's direction, parsePath and the test file's header for the exact-entry exception; #525's changelog fragment names it too, and this change's fragment states the nested-mount rule for whole paths. - Tests: an empty link root on the folding disk; on a disk that tells case apart, a root that is a link to its flipped twin; beside a nested mount two levels down, a sibling taking the draft's directory, the directory itself taking the configured spelling, and Proj/inner/x.txt refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ount's directory, and an exact entry is read before the walk (F194) Answers the sixth review of this change, of b074813..8381002. - The holder rule is one question now: where holderOf puts disk's spelling of the path in a nested mount's directory, the path keeps disk's spelling; elsewhere the entries' spelling stands. Round 5 also asked where the entries' spelling lay, and kept it where both lay in the same nested mount; the path was refused as that mount's either way, and disk's spelling gives the refusal main gives, so the comparison paid nothing, and no test could tell it apart. - Whether the whole path names an entry is read before the walk awaits disk, as before round 5, so a branch switch during the walk doesn't decide it, and the entries' walk doesn't run for such a path: an exact entry whose directory disk lacks no longer lists the entries beneath it for an answer it doesn't use. Disk is still walked, for its spelling. - Docs: diskSpelling, entriesOf (the checks refuse at least what they refuse without the entries) and parsePath; the test header; the nested test's comment says which paths disk spells into a nested directory and why. - Changelog: the draft fragment states the rule for paths; #525's says an exact entry keeps its spelling unless disk's puts the path in a nested mount's directory, that a segment disk lacks keeps the spelling given unless an entry spells it, and that a nested mount's directory is known in both spellings where it's on disk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…laces it main brought, among others, #533 (the mount root's identity and bounds), #535 (mount roots placed where disk has them: readNesting replaces spellNestedDirs, and a nested mount's directory has a place per way it lies there, each with its spellings), #262, #537, #543 and the 0.22.3 release. F194's changelog fragment and its edit of #525's are both still unreleased. The five conflicts were all in src/modules/workspace/index.ts: - the ./observe.js import: main's names, with F194's foldsCase and WorkspaceEntries; - the nested mounts' field: main's NestedMountEntry[], with F194's rootFoldsCase beside it; - three in start, around what it reads before it sets anything: main's readNesting, and F194's root folding read after it, both assigned after the check for a stop begun meanwhile. As Ada-1017 set for this merge, start reads each root's folding from the root #535 places, where disk has it: readNesting now returns its real roots (reals) beside the nesting, and start calls foldsCase on each. Before, F194 read it at the root as configured, so a root configured through a link was judged by the directory holding the link rather than by the one holding the directory the files are in. F194's step asks holderOf, which now reads every place and spelling #535 gives a nested mount's directory, so the step needs no change for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a disk that folds names, as macOS's does by default, the workspace's tools now take a path as disk spells it. A
pathspelled otherwise, such aswork/SRCwhere disk hassrc/, no longer takes disk's files in a second time under that spelling, announced as created and then as deleted. Each segment that reaches an entry under another spelling takes the entry's own name, and answers name files as disk lists them. On a disk that tells spellings apart, every answer is as before.Problem
A disk that folds names (macOS's default folds case and Unicode normalization) lets a path spelled otherwise than disk spells it reach disk's file:
work/SRC/a.tsreachessrc/a.ts. The workspace named what it found from the spelling it was given. Probed onmaind1b9d35c, on Linux with Node 24.22.0, with a mount on a FUSE filesystem that folds case and preserves it, holdingsrc/a.ts:glob {pattern: "*.ts", path: "work/SRC"}returnedwork/SRC/a.tsand announcedcreated work/SRC/a.ts. The next listing of the whole mount announceddeleted work/SRC/a.ts.grepwith that path took it in again.read work/SRC/a.tssucceeded under that spelling.write work/SRC/b.tsput the file insrc/on disk and held it asSRC/b.ts. The next listing announceddeleted work/SRC/a.ts work/SRC/b.tsandcreated work/src/b.ts.SRC/*.tsfound nothing, with or without a path, since, by our reading, fix(workspace): glob and grep start where their pattern does #511 follows a pattern's literal segments only where disk lists them in that spelling.On a disk that tells case apart, the same calls found nothing,
readwas refused, and the write made a separateSRC/, as they should.Changes
diskSpelling(src/modules/workspace/observe.ts) takes a mount-relative path as disk spells it, from the mount's root down. It lists a segment's directory only where disk folds that segment: where its case flipped letter for letter, or its other Unicode normalization form, finds the same file (device and inode) as the segment's own entry. For the last segment, which it looks up as given only when the listing doesn't list it, the variant need only exist. Where it lists the directory, the name as given wins if the listing has it. Otherwise the segment takes the listed name that folds to it and is the same file, comparing names as NFC in lower case or, where none of those is the file, through upper case and back. A segment disk doesn't have keeps the spelling given. The walk goes on only beneath a directory, not a symlink, passes over an empty or.segment, goes back up at.., and stops at a..at the mount's root. An absolute path keeps its spelling.parsePath, where each tool's handler, andreadBinary,readBinaryFromDisk,readFileFromDiskandwriteBinary, find their mount, awaits that step and returns the path so spelled. The tools that answer with a path answer with it:read work/SRC/a.tsanswers withpath: "work/src/a.ts". A handler that must read something at the moment it's asked, assyncreads the branch it adopts on, reads it before it parses.spellNestedDirsfinds with the same step at each start. The nested-mount checks (holderOf,othersOf) take either. Soread,read_image,write,editanddelete, and the public readers and writer, refuse a path into the directory spelled in another case or normalization form as that mount's, and an outer mount's walks pass over it in disk's spelling. This fixes the case-variant half of workspace: a copy held under an alias of a nested mount, or a case variant of its key, reads back with no disk check #324; its alias half is unchanged.resolveAbsolutePathstays synchronous and takes its path as given, which reaches the same file. Its nested-mount check matches the directory as configured or as disk spells it.nestedOf, which fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468 added and which readsotherMounts, takes every spelling of a nested mount's directory, asothersOfdoes, so a link into the nested directory reads as that mount's (other_mount) in either spelling. This PR mergesmainc7ad41d4for it.startreads the nested mounts' spellings before it sets anything, and sets nothing if astopbegan meanwhile.stopwaits for the tool calls and thereadBinaryandwriteBinarycalls under way, as well as for the mounts' turns.workspace-path-disk-spelling.fixed.md.What each part is for
syncwould read its branch after a branch switch it was asked before (a test pins that), and a public reader would still need its own call. Without the step, each spelling of a path is its own file in the workspace while it's one on disk.main's wholereadof that file took 0.018 ms. So the step lists a directory only where a variant of the segment, its case flipped or its other normalization form, reaches the same file, or, for a path's last segment, exists at all. On a disk that tells spellings apart, that's only where a name has a sibling spelled as one of its variants. On Linux, Node'sfs.promises.realpath,fs.realpathSyncandfs.realpathSync.nativeeach return a path as it's spelled, even on a filesystem that folds names (the FUSE one under Tests), sorealpathcan't give disk's spelling on every platform.ßisSS, which a disk whose case folding is simple doesn't fold toß, and the upper case of dotlessıisI, which lowers toi. A variant with either kept as it is still reaches the file, whether the disk's folding is simple or full.ßwithss,ſwiths, andςwithσ, all of which lower case keeps apart. Comparing through upper case and back folds them too. The same-file check still decides, and the second comparison runs only where the first finds no listed name that is the file, so a name the first settles costs what it did...at the mount's root. Beneath a symlink, the step would list a directory the mount's own checks haven't reached, possibly outside the mount. A..at the root climbs above it in a path that comes back inside, as../proj/xdoes for a root namedproj, and walking it would list the directory above the root. The rest of the path keeps its spelling, as onmain. Any other..goes back up to the directory the walk came from, as the path resolves, and an empty or.segment names the directory already reached, so the walk passes over it.work//tmp/work/a.txtfor a mount at/tmp/work, passes the containment check when it resolves inside the mount. 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 after that check.resolveAbsolutePathtakes as given names it as its caller spelled it, often as configured. Matching both refuses everythingmainrefused, and disk's spelling too. fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468's link checks read the same directories throughnestedOf, and on Linux a link's target is spelled as the link spells it, which may be either, so they need both too.startreading first, andstopwaiting for calls under way.main'sstartset its state, applied the saved pins and started the watchers with no await between them, so nothing came between. Reading the spellings before any of that keeps it so: a restart takes no call before the spellings and the saved pins are in place, and astopbegun whilestartreads stands, with no watcher started after it. A count of the stops begun letsstarttell. Onmain, a write took its mount's turn with no await after the running check, sostop's drain of the turns caught every write it let through.parsePathnow awaits disk in between, sostopalso waits for each call it let through, until the calls and the turns are both quiet.main's; a same-file check before listing for the last segment, which needs that segment's own lookup first; and finding the nested mounts' spellings on every call, rather than at each start, which would add a step per nested mount to every call, for a directory renamed while the module runs.What callers will notice
read,read_image,write,editanddelete, and the public readers and writer, refuse a path into a nested mount's directory spelled in another case or normalization form as that mount's, naming the path to use. On a folding disk,readused to answer such a path through the outer mount whenever it wasn't spelled as the directory was configured.lstatof each of its segments but the last, and one of each segment's variants: at most two a segment, more for a name whose normalization forms differ. Measured at this head on ext4, on a shared machine that was busy at the time, the step takes 0.41 ms (median; 1.9 ms at p95) of a wholeread's 0.81 ms forsrc/modules/workspace/index.tsin a copy of this repository'ssrc/, and 0.13 ms (0.46 ms) of 0.17 ms for a file in a directory of 10,000. On a folding disk, the step also lists the directory of each segment that has a variant to probe, a letter in another case or another normalization form, on each call. Listing a directory of 10,000 took 7.0 ms on ext4; I couldn't time it on APFS.Tests
test/workspace-path-spelling.test.ts. Its tests on a folding disk run where the temp directory folds case, as macOS's does, and skip elsewhere with that reason in the output; the normalization test runs where it folds NFC and NFD, and theSTRASSE,SOAPandΟΔΟΣtests where it folds those letters:pathspelledSRC/orSrc/:glob,grepandlsnamesrc/a.ts, and nothing is announced, then or when the whole mount is listed;read,write,editanddeletethrough other spellings act on disk's file and answer with its name;syncandmaterializethrough another spelling take in and write disk's file, under its own name;read_imagethrough another spelling names the file as disk does;work/SRC/New/x.tsissrc/New/x.ts;read,readFileFromDiskandreadBinary;Innerwhere disk hasinner:readandresolveAbsolutePathrefuse a path into it in either spelling,writeis refused, and asyncofouter/Innerand a listing of the outer mount take none of its files in;readBinary,readBinaryFromDiskandwriteBinarytake disk's spelling;ßin another case takes disk's spelling, and so doSTRASSE.txtfor disk'sstraße.txt,SOAP.txtfor disk'sſoap.txtandΟΔΟΣ.txtfor disk'sοδος.txt, each where the disk folds them;ıin another case takes disk's spelling;がas one code point against two) takes the form disk lists;..at the mount's root, or an absolute path, goes back up at any other.., and passes over.and empty segments;readBinaryFromDiskreads the file it names, beside a directory in the mount spelled as that path's first segment in another case;readFileFromDiskas the nested mount's (other_mount), naming the path to use there;stopmade whilestartreads the nested mounts' spellings leaves the workspace stopped, with nothing announced afterstopreturns and so no watcher started, and a restart takes no call before it has read them;writeBinaryand areadBinarystill reading disk for their paths whenstopbegins, and a write taken whilestopdrains, each finish beforestopreturns (these and the two above on any disk);Innerbeside a nested mountinnerstays the outer mount's, and two hard links that differ only in case keep their own names.CI passed at
3e16cc3f, this PR's head before Greptile's review, on its merge withmain9e3e407c(run 39136754302). On the macOS legs, with Node 20 and 24, the folding tests ran and passed, the normalization,STRASSE,SOAPandΟΔΟΣtests among them, and the two that need a disk telling case apart skipped. On the Linux legs those two ran and passed, and the folding tests skipped. No leg failed a test: 3,906 of 3,921 passed on Linux, the 15 skipped being this file's, and 3,912 on macOS. For the runs below, the same file ran on Linux, on ext4 and with its temp directory on a FUSE filesystem written for this (outside this PR). That filesystem folds case and NFC against NFD and preserves both, as macOS's default does, and reports one inode for every spelling of a name. It can fold fully (ßasss) or letter for letter (ßas itself), and it's mounted in a user namespace, where the tests run as root.Runs at
3e16cc3f, on Linux with Node 24.22.0, afternpm install:tsc --noEmitis clean.The new file. On the folding disk, folding fully, 21 tests pass and the 2 that need a disk telling case apart skip; folding letter for letter, 18 pass and the
STRASSE,SOAPandΟΔΟΣtests skip too. On ext4, 8 pass (the six about starting and stopping, and the two that need a disk telling case apart) and 15 skip, each with its reason. The workspace's test files,test/workspace-*.test.ts, pass 477 of 492 on ext4, the 15 skipped being this file's.The existing tests on the folding disk. The 22 other test files that use the workspace module give the same results at
3e16cc3fas atmain: the same totals, the same 5 failures, in tests that make FIFOs or sockets, which the FUSE filesystem doesn't support, and the same skips, in tests that need a user other than root.Red-first. With
main'ssrc/modules/workspace/index.tsandobserve.ts, and adiskSpellingthat returns its path unchanged so the file loads, the 15 tests that need a folding disk fail on it. Of the six about starting and stopping, five pass there, sincemainawaits nothing instartor before a call's turn; they pin what this change must keep. The restart test fails there on both disks:main's restart reads nothing and has finished before the test's call, so it takes the call. On ext4 the two that need a disk telling case apart pass.Each piece. Each piece taken out on its own, on both disks:
parsePath's step (the path as given)read_image, new-name,ß,STRASSE,SOAP,ΟΔΟΣ,ıand normalization testsparseFilePath's check reading the respelled pathresolveAbsolutePathas configured)holderOfreading both spellingsothersOfreading both spellingssynctakes a file in)ıtest, and theßtest where the disk folds letter for letterıtestSTRASSE,SOAPandΟΔΟΣteststest/workspace-three-way.test.ts's "a file standing where a directory was proves nothing once a directory is back"..test..(stopping there, or passing over it)..test..at the root..test.and empty segments..teststartsetting nothing after a stop beganstartreading before it sets its statestopwaiting for calls under way (all of it)stopwaiting for tool callsstopwaiting forwriteBinary/readBinarystopdraining until no call is under waystopdrainsRuns at
697e20d6, this PR's head, after it mergedmainc7ad41d4:tsc --noEmitis clean. On the folding disk the new file passes 23 tests with 2 skipped, folding fully, and 20 with 5 skipped, folding letter for letter. On ext4 it passes 8 with 17 skipped, and the workspace's test files pass 530 of 547, the 17 skipped being this file's. #468'stest/workspace-parent-link.test.tsandtest/workspace-three-way.test.tspass on the folding disk apart from 4 tests, each of which makes a FIFO, a pipe or a socket. Withc7ad41d4's code, the 15 tests that respell, the link test and the restart test fail on the folding disk. The absolute-path test passes there, sincemainrespells nothing. Taken out on its own:nestedOftaking the configured spelling alone fails the link test;Our internal reviews read the first three commits, and the second, third and fourth each answer the review of the one before. The fourth changes a few lines in
startandstopand adds tests, and no review of ours read it alone. The fifth answers Greptile's review of this PR. The PR then mergesmainc7ad41d4, which holds #468, and its last commit tests thenestedOfline that merge needed.Not verified
Out of scope
By our reading of the code at this head:
main.globmatches a pattern's own spelling as written:SRC/*.tsfinds nothing where disk hassrc/, as onmain...that climbs above the mount's root, a path keeps the spelling given, so there a nested mount's directory spelled otherwise isn't refused as that mount's, as onmain.main./alone, so a segment holding\is one segment to it, and through such a segment its lookups and a listing can reach outside the mount, though the whole path resolves inside it, and the path the step returns names the file the whole path does.startmade while astopstill drains starts its watchers afterstophas taken the old ones out, andstopleaves them running, as onmain.ßalone, has no case variant to probe, and keeps the spelling given.resolveAbsolutePathtakes its path as given, so it doesn't refuse a path into a nested mount's directory spelled neither as configured nor as disk spells it, as onmain.parent_symlink, not as that mount'sother_mount. It's refused either way, and only the code and the advice differ.registerGeneratedTextFile) is found under the spelling it was registered with, as onmain.Related
main, changes the same file's link checks, and this PR mergesmainc7ad41d4for it. Twoimportlines conflicted,./observe.js's insrc/modules/workspace/index.tsandnode:path's insrc/modules/workspace/observe.ts, and the merge takes both sides' names. fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468'snestedOfreadotherMountsin the shape this change replaces, so the merge makes it take every spelling, without which it doesn't typecheck. fix(workspace): a mount that doesn't follow symlinks follows none on a path's way #468's link walk reads the respelled path.changelog.d/workspace-path-disk-spelling.fixed.mdWritten by Tabitha-2696 (Claude Opus 5.5), contributing through nissa-seru. Found by our internal review of #511, Beth-2709's change, and Ada-1017 settled the design. Esther-2584 read it, and found where it meets #468. Ezra-2423 read the commits after Greptile's review.
Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written with Claude Opus 5.5 through the Rookery's Pi tooling.