Repository navigation
Workspace: on two kinds of folding disk, listedName keeps a spelling disk doesn't list, so one file gets two workspace entries #662
Description
diskSpelling gives each segment of a path the name disk lists for it, through listedName (src/modules/workspace/observe.ts at main 97ca7eba). When the segment as given isn't listed, listedName looks up its variants: its letters' case flipped, NFC and NFD. If a variant finds the same file as the segment (sameFile, by device and inode), the directory folds. It then takes a listed name equal to the segment in lower-case NFC, or failing that one equal by foldedLoosely (NFC, then lower, upper and lower case) that is the same file. Otherwise the segment keeps the spelling it was given, and the workspace holds the file under that spelling beside the one disk lists. Two kinds of folding disk get there, each shown by a run on main's sources at 97ca7eba (Linux, Node 24.22.0). The disks are FUSE passthroughs that list each name as their backing directory spells it.
1. A disk that drops default-ignorable code points. These are characters such as a soft hyphen (U+00AD), a zero-width joiner (U+200D) or a variation selector (U+FE0F). Linux's casefold, which ext4 and f2fs set per directory, drops them. The kernel kept that in 231825b2, which reverts 5c26d2f1, because on-disk name hashes depend on it, and stable kernels have it from v6.12.5. HFS Plus's case-insensitive comparison skips some of them too (Apple TN1150). Neither of listedName's comparisons drops them.
Run: the passthrough's lookups fold as utf8_casefold does by the kernel's description (default-ignorables dropped, NFD, full case folding, NFD). A mount on it holds gate.txt.
write outer/ga\u00adte.txt(a soft hyphen afterga) succeeds.materializewrites nothing, and skips it: "ga\u00adte.txt: disk holds a different copy, and nothing records which is newer (conflict: store-origin-collision)".read outer/gate.txtgives the file's old content, andread outer/ga\u00adte.txtgives the new content.ls outerlists bothgate.txtandga\u00adte.txt. Disk lists onlygate.txt, with its old content.
2. A disk that folds case but numbers a file anew for each spelling it's looked up by. One example is a filesystem on libfuse's high-level API mounted without use_ino. There, the flipped variant finds the same file under another inode number, so listedName reads the directory as one that tells case apart.
Run: the passthrough folds case and Unicode normalization (Python's casefold, NFC), and is mounted without use_ino. One directory is inner at inode 29 and INNER at inode 32. A mount on it holds gate.txt.
write outer/GATE.TXTsucceeds.materializeskips it with the same conflict.read outer/gate.txtgives the old content, andread outer/GATE.TXTthe new.ls outerlists bothgate.txtandGATE.TXT.
Likely fixes, by our reading, unrun:
- For 1, compare by a fold that drops default-ignorable code points in the loose pass, such as
foldedFully, which fix(workspace): compare a path with a nested mount's directory as disk compares names, level by level #624 adds for the nested-mount comparison. The same-file check after the fold would still keep it from naming another file. - For 2, the same-file test can't settle a variant on its own on such a disk. fix(workspace): compare a path with a nested mount's directory as disk compares names, level by level #624 settles the same question for a level's folding by looking the name up once more with only its first ASCII letter's case flipped.
judgedAt's use offoldedLooselyis a separate question, since there a wider fold changes which spelling the include judges, and we haven't followed that through.
What would test it: CI has no disk of either kind, and we haven't seen whether APFS drops default-ignorables. So a test would need readdir and lstat replaced, as workspace-path-spelling.test.ts's withLstat replaces lstat.
Found in review of #624, which compares a path with a nested mount's directory in these ways and leaves listedName as it is.
Written by Nell-1783.
Activity