Redesign sidecar using byte-exact guest filenames without a sidecar index
The design is being refined as the testing and simplification of the code are carried out continuously
A Linux guest names files by exact bytes. The default APFS volume matches names
case- and normalization-blind, so Foo and foo cannot coexist and a wrong-case
lookup succeeds where Linux owes ENOENT.
The previous answer, src/syscall/sidecar.c (2217 lines), stored such a name
under an opaque random token (.ef_ plus 64 bits from arc4random, retried on
EEXIST) and recorded what the token meant in a per-directory
.elfuse_case_index file. The mapping lived outside the name, and everything
below follows from that one decision.
A name was two objects, so no mutation was atomic
Creating, removing, or renaming a name had to touch the token and the index
file, which no host syscall can do together. renameat therefore ran a
hand-rolled two-phase commit: clone both directory indices as rollback
snapshots, write the index or indices to disk, call renameat, and on failure
write the saved snapshots back. The comment conceded the gap: "A failed
rollback write is the best-effort case." A crash between the index write and
the host rename left a mapping pointing at a token that had moved or did not
exist, and nothing repaired it on the next mount. Cross-directory rename took
two index locks in a fixed order, with a swapped flag deciding which went
first, so the ordinary rename path carried deadlock-avoidance logic.
It forced serialization that the filesystem does not need
Two lock layers, because POSIX advisory locks are per-process and every vCPU
thread would share one: a process-wide pthread_mutex_t across all vCPU
threads, plus an fcntl write lock on a .elfuse_case_index.lock sentinel to
serialize against other elfuse processes on the same sysroot. Two guests
creating unrelated files in one directory contended, and the process mutex
serialized name creation across every directory at once.
The index was advisory, so anything else desynchronized it
fcntl locks bind only the processes that take them. Any writer that is not
elfuse (tar, rsync, git, Finder) could add or delete entries without
touching the index, leaving rows describing tokens that were gone and files
with no row at all. In the other direction the tokens are opaque, so a sysroot
was unreadable and unmaintainable outside elfuse, and copying one preserved
the mapping only if the index files came along intact.
Reads paid for it, and the cache was itself a correctness surface
The walker visited every parent directory of every translated path, probing
each for an index. That cost dominated startup, measured at "openat 61% of
getent's 7.5 ms warm path", so it needed a 64-slot open-addressed cache
keyed on (dev, ino, mtime, ctime), last writer wins on collision. Ordering
then became load-bearing: the publish path had to invalidate the cache
before the rename, or a concurrent walker could cache "no index here" and
miss one another process had just published.
Leftovers
Three reserved names per directory (index, .tmp, .lock) had to be filtered
out of every listing and refused as guest paths. Each mutation parsed the whole
index into a heap row array and deep-cloned it for rollback, so a create in a
large directory cost work proportional to the directory. And the sidecar
resolved paths independently of path_translate_at, so two walks disagreed
about when a guest path falls through to the host.
Approach
Decide a name's on-disk spelling from the name alone. Nothing is persisted, so
nothing can desynchronize, and no lock is needed because there is no second
object to keep consistent.
- Codec (
src/syscall/casefold.c): a name containing an uppercase ASCII
letter, a byte above0x7F, or an escape-shaped prefix is stored as
.ef=<payload>. What is left literal is lowercase ASCII, a fixed point of
every transformation the volume applies, so two literal names cannot collide
however the folding table changes. The payload has two tiers because the
per-name limit is 255 UTF-16 code units rather than bytes: hex to 125 bytes,
then twelve bits per symbol from 4096 CJK ideographs at U+4E00, a block that
neither folds nor normalizes. Guest names keep all 255 bytes Linux allows. - Walk (
src/syscall/casefold-walk.c): resolve a path one component at a
time, asking the volume for each name as stored (getattrlistat) and
comparing bytes, the only way to separate "exists as spelled" from "exists
under a spelling that folded onto it". Absence is three answers, not one:
unclaimed falls through to the host, a slot held by a differently-spelled
sibling isENOENT, and a non-directory component isENOTDIR
(path_resolution(7)). - One resolver:
proc_resolve_sysroot_path_flagsandpath_translate_at
fold into that single walk, which also backs theopenat2
RESOLVE_NO_SYMLINKSandRESOLVE_NO_XDEVwalkers. Directory entries and
the cwd decode on the way back out. - Sidecar deleted: index, parsers, both locks, the snapshots, both caches,
and its five private syscall handlers, which now get the ordinary/proc,
FUSE, and/dev/shmhandling. Colliding creates never contend, because the
spelling is a function of the name. Two elfuse processes sharing a sysroot
need no coordination, because no shared mutable state remains.
Prior art
The same question ("the host filesystem cannot hold this name") has three
standard answers, and this branch moves elfuse from the worst to the best of
them.
- Ask the filesystem. Cygwin took this route:
managedmounts, its
encoding scheme, were removed in 1.7 in favor of real NTFS case sensitivity
via theobcaseinsensitivekernel flag, with per-mountposix=[0|1].
elfuse's equivalent is--create-sysroot, which builds a case-sensitive APFS
sparsebundle, and Docker Desktop and Lima sidestep it entirely with a Linux
VM over ext4. It is the cleanest answer when available, which is why the
check suite re-runs the whole name suite on that volume as its oracle. It is
not always available: the sparsebundle is opt-in and a user may point
--sysrootat any directory. - Derive the stored name from the guest name. Where Cygwin still must
encode, for characters Windows disallows, it transposes them into the Unicode
private use area by adding0xf000: a pure per-character function with no
side table. gocryptfs, EncFS, and eCryptfs are the same family, deriving the
on-disk name from the plaintext name and a key. This branch is that answer. - Keep a mapping. The sidecar was here, and so is CryFS, which stores names
inside encrypted blocks. Even gocryptfs falls back to it, but only at the
boundary the derivation cannot cross: when an encrypted name would exceed
255 bytes it writes agocryptfs.longname.<hash>.namesupport file. That is
precisely what elfuse's second tier avoids. Packing twelve bits per CJK
symbol keeps the derived property across the full 255-byte guest range, so
there is no length at which a side file reappears.
Compatibility
A sysroot written by the old encoding must be recreated. Its .ef_<token>
entries and .elfuse_case_index files decode to nothing and surface under
their literal host names. docs/usage.md says so and gives the recipe.
Tests
Two host unit lanes (codec, resolver) link exactly the code under test.
tests/casefold-vectors.h freezes the on-disk spellings in both directions,
making a format change a deliberate migration. Guest lanes cover edge shapes
(one-character sysroot, no sysroot, chdir), i18n pairs the volume folds, name
and path length budgets, host-staged spellings, a corpus read back by guest
name, and concurrency (a fork race run ten times in check, plus a manual
soak).
Two lanes are oracles rather than expectations:
check-name-caseexactre-runs the name suite on a case-sensitive APFS
sparsebundle, where the volume itself enforces what the tests assert. It is
what surfaced theENOTDIRand vanished-path fixes.test-sysroot-path-matrixenumerates addressing mode x operation x path
shape x name class and holds each cell topath_resolution(7): absolute,
cwd-relative, and dirfd-relative spellings of one file must agree on result,
errno, and object. On first run it caught two bugs nobody predicted. It runs
on the folding tmpdir, the byte-exact volume, and the qemu reference kernel.
The matrix gains ELFUSE_SKIP as the inverse of QEMU_SKIP, with
.ci/check-matrix-lists.sh rejecting a label naming no registered test or
sitting in both lists.
Summary by cubic
Redesigns filename handling with a stateless .ef= codec and a case‑exact path resolver, replacing the sidecar so Linux byte‑exact names work on case‑folding APFS without locks or per‑dir files. All path surfaces resolve and report guest bytes (symlink targets, exec, inotify, pathname AF_UNIX, directory listings, cwd); temp roots (/tmp, /var/tmp, ~/.ccache) are consistently redirected through the sysroot for creates and lookups. .. now clamps at the guest root, and openat2(RESOLVE_NO_SYMLINKS) handles deep link‑free paths correctly.
-
Bug Fixes
- Symlinks: follow targets in the guest namespace; absolute targets resolve against the sysroot;
openat2(RESOLVE_NO_SYMLINKS)returnsELOOPwhen a link is present. - Exec: translate dirfd‑relative paths; resolve
PT_INTERPthrough the same walk with/lib/<basename>fallback;/proc/self/exeand/proc/self/fd/Nreport guest bytes; shm probes honorO_NOFOLLOW. - AF_UNIX (pathname) sockets: bind/connect/send* resolve through the sysroot with case‑exact names; over‑long paths shorten via a private namespace symlink with correct cleanup across fork/exit; getsockname/getpeername/accept/recv* decode guest paths and fix msg name lengths.
- Inotify: watches translate through the resolver; event names decode to guest bytes;
/dev/shmleaves are watched without following. - Path correctness: clean
ENAMETOOLONGat the host path ceiling (no truncation); trailing/keepsENOTDIR;--sysroot /parent split fixed; directory entry decoding is scoped to sysroot‑owned directories (host fallback and no‑sysroot lists stay literal);openat2(RESOLVE_NO_XDEV)re‑checks mount class after open;..is clamped at the guest root for absolute and dirfd‑relative paths;RESOLVE_NO_SYMLINKSno longer caps components, so deep link‑free paths resolve. - Cwd:
getcwdand/proc/self/cwdreport guest bytes so the path is chdir‑able.
- Symlinks: follow targets in the guest namespace; absolute targets resolve against the sysroot;
-
Migration
- Sysroots written by the old sidecar format must be recreated. Existing
.ef_<token>and.elfuse_case_indexfiles appear as literal host entries. See docs/filenames.md and docs/usage.md.
- Sysroots written by the old sidecar format must be recreated. Existing