Skip to content

Redesign sidecar using byte-exact guest filenames without a sidecar index - #256

Open
henrybear327 wants to merge 12 commits into
sysprog21:mainfrom
henrybear327:sysroot/casefold-redesign
Open

Redesign sidecar using byte-exact guest filenames without a sidecar index#256
henrybear327 wants to merge 12 commits into
sysprog21:mainfrom
henrybear327:sysroot/casefold-redesign

Conversation

@henrybear327

@henrybear327 henrybear327 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

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 above 0x7F, 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 is ENOENT, and a non-directory component is ENOTDIR
    (path_resolution(7)).
  • One resolver: proc_resolve_sysroot_path_flags and path_translate_at
    fold into that single walk, which also backs the openat2
    RESOLVE_NO_SYMLINKS and RESOLVE_NO_XDEV walkers. 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/shm handling. 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: managed mounts, its
    encoding scheme, were removed in 1.7 in favor of real NTFS case sensitivity
    via the obcaseinsensitive kernel flag, with per-mount posix=[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
    --sysroot at 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 adding 0xf000: 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 a gocryptfs.longname.<hash>.name support 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-caseexact re-runs the name suite on a case-sensitive APFS
    sparsebundle, where the volume itself enforces what the tests assert. It is
    what surfaced the ENOTDIR and vanished-path fixes.
  • test-sysroot-path-matrix enumerates addressing mode x operation x path
    shape x name class and holds each cell to path_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; temp roots (/tmp, /var/tmp, ~/.ccache) are consistently redirected through the sysroot for creates and lookups.

  • Bug Fixes

    • Fixed mismatched /tmp behavior: lookups now resolve via the sysroot too, so listings don’t expose host entries that won’t unlink/rename; rm -rf works end to end.
  • Migration

    • Sysroots written by the old sidecar format must be recreated. Existing .ef_<token> and .elfuse_case_index files appear as literal host entries. See docs/filenames.md and docs/usage.md.

Written for commit 4ae7542. Summary will update on new commits.

Review in cubic

@henrybear327
henrybear327 requested a review from jserv July 31, 2026 12:17

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 59 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/copy-arg.c">

<violation number="1" location="tests/copy-arg.c:33">
P2: Short write (fewer bytes than requested) causes an immediate failure instead of retrying the remaining data. When this runs as a guest on a FUSE volume, partial writes are possible and should be retried.</violation>
</file>

<file name="docs/internals.md">

<violation number="1" location="docs/internals.md:879">
P3: The Dynamic Linking section says the initial-process bootstrap "cannot call into the syscall translator and keeps elf_resolve_interp()", but bootstrap.c's load_interpreter calls path_translate_at() as a fallback when elf_resolve_interp returns the path unchanged. Correct the statement to reflect the actual two-tier strategy: prefer elf_resolve_interp (literal sysroot concat + /lib/basename), then fall through to path_translate_at().</violation>
</file>

<file name="mk/tests.mk">

<violation number="1" location="mk/tests.mk:441">
P3: `make check` can fail on hosts that already have `/symlink-target`, independent of elfuse behavior. Add an isolated/preflighted host-path fixture before launching the guest, or skip this host-leak assertion when that global path is occupied.</violation>

<violation number="2" location="mk/tests.mk:464">
P3: `make check` can fail on hosts that already have `/sockdir`, even when no socket path escaped the sysroot. Reserve or preflight this global fixture before execution, and avoid treating an existing unrelated path as test output.</violation>
</file>

<file name="src/syscall/casefold.h">

<violation number="1" location="src/syscall/casefold.h:52">
P3: Changing the encoder's symbol width will not make this capacity assertion fail: `CASEFOLD_SYMBOLS` is hard-coded for 12-bit symbols while `CASEFOLD_SYM_BITS` is defined independently in the implementation. Derive the symbol count from one shared bit-width constant so a packing change cannot silently invalidate the name-size/collision guarantees.</violation>
</file>

<file name="src/syscall/fs.c">

<violation number="1" location="src/syscall/fs.c:1613">
P1: Host directories outside a folding sysroot can list a literal `.ef=<valid-payload>` entry as its decoded guest name. Keep directory/sysroot ownership in dirent translation (as the removed `fd` argument enabled), so listings remain consistent with outside-sysroot path resolution.</violation>
</file>

<file name="tests/test-sysroot-name-relative.c">

<violation number="1" location="tests/test-sysroot-name-relative.c:211">
P2: The 255-byte escape test is nested inside the else branch of the 126-byte creation check, so a write_at failure for the shorter name silently skips the longer-name test without any output. Make the two tests independent sibling blocks so each one runs (and reports its pass/fail state) regardless of the other.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/syscall/fs.c

char guest_name[NAME_MAX + 1];
int name_rc = path_translate_dirent_name(fd, de->d_name, guest_name,
int name_rc = path_translate_dirent_name(de->d_name, guest_name,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Host directories outside a folding sysroot can list a literal .ef=<valid-payload> entry as its decoded guest name. Keep directory/sysroot ownership in dirent translation (as the removed fd argument enabled), so listings remain consistent with outside-sysroot path resolution.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/fs.c, line 1613:

<comment>Host directories outside a folding sysroot can list a literal `.ef=<valid-payload>` entry as its decoded guest name. Keep directory/sysroot ownership in dirent translation (as the removed `fd` argument enabled), so listings remain consistent with outside-sysroot path resolution.</comment>

<file context>
@@ -1632,10 +1610,8 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count)
 
         char guest_name[NAME_MAX + 1];
-        int name_rc = path_translate_dirent_name(fd, de->d_name, guest_name,
+        int name_rc = path_translate_dirent_name(de->d_name, guest_name,
                                                  sizeof(guest_name));
-        if (name_rc > 0)
</file context>

Comment thread src/syscall/proc-state.c
Comment thread src/syscall/casefold-walk.c
Comment thread tests/copy-arg.c
if (out < 0)
return perror(argv[2]), 1;
while ((n = read(in, buf, sizeof(buf))) > 0)
if (write(out, buf, (size_t) n) != n)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Short write (fewer bytes than requested) causes an immediate failure instead of retrying the remaining data. When this runs as a guest on a FUSE volume, partial writes are possible and should be retried.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/copy-arg.c, line 33:

<comment>Short write (fewer bytes than requested) causes an immediate failure instead of retrying the remaining data. When this runs as a guest on a FUSE volume, partial writes are possible and should be retried.</comment>

<file context>
@@ -0,0 +1,38 @@
+    if (out < 0)
+        return perror(argv[2]), 1;
+    while ((n = read(in, buf, sizeof(buf))) > 0)
+        if (write(out, buf, (size_t) n) != n)
+            return perror("write"), 1;
+    close(in);
</file context>

Comment thread src/syscall/inotify.c
Comment thread src/syscall/casefold.h
* length, then one per 12 bits of payload. Computed in size_t because the
* result sizes buffers.
*/
#define CASEFOLD_SYMBOLS(n) ((size_t) 1 + ((size_t) 2 * (n) + 2) / 3)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Changing the encoder's symbol width will not make this capacity assertion fail: CASEFOLD_SYMBOLS is hard-coded for 12-bit symbols while CASEFOLD_SYM_BITS is defined independently in the implementation. Derive the symbol count from one shared bit-width constant so a packing change cannot silently invalidate the name-size/collision guarantees.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/casefold.h, line 52:

<comment>Changing the encoder's symbol width will not make this capacity assertion fail: `CASEFOLD_SYMBOLS` is hard-coded for 12-bit symbols while `CASEFOLD_SYM_BITS` is defined independently in the implementation. Derive the symbol count from one shared bit-width constant so a packing change cannot silently invalidate the name-size/collision guarantees.</comment>

<file context>
@@ -0,0 +1,111 @@
+ * length, then one per 12 bits of payload. Computed in size_t because the
+ * result sizes buffers.
+ */
+#define CASEFOLD_SYMBOLS(n) ((size_t) 1 + ((size_t) 2 * (n) + 2) / 3)
+
+/* Longest host spelling any escaped name can take, in bytes. Each symbol is a
</file context>

Comment thread docs/internals.md
Comment on lines +879 to +883
guest-issued execs route it through `path_translate_at()` like any other
guest path, while the initial process is loaded by the core bootstrap,
which cannot call into the syscall translator and keeps
`elf_resolve_interp()` (`src/core/elf.c`), a literal sysroot
concatenation plus a `/lib/<basename>` fallback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The Dynamic Linking section says the initial-process bootstrap "cannot call into the syscall translator and keeps elf_resolve_interp()", but bootstrap.c's load_interpreter calls path_translate_at() as a fallback when elf_resolve_interp returns the path unchanged. Correct the statement to reflect the actual two-tier strategy: prefer elf_resolve_interp (literal sysroot concat + /lib/basename), then fall through to path_translate_at().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/internals.md, line 879:

<comment>The Dynamic Linking section says the initial-process bootstrap "cannot call into the syscall translator and keeps elf_resolve_interp()", but bootstrap.c's load_interpreter calls path_translate_at() as a fallback when elf_resolve_interp returns the path unchanged. Correct the statement to reflect the actual two-tier strategy: prefer elf_resolve_interp (literal sysroot concat + /lib/basename), then fall through to path_translate_at().</comment>

<file context>
@@ -865,18 +866,27 @@ How it works:
-correctly. `elf_resolve_interp()` in `src/core/elf.c` is shared between
-`src/main.c` and `src/syscall/exec.c`.
+correctly. The two loaders resolve `PT_INTERP` differently by design:
+guest-issued execs route it through `path_translate_at()` like any other
+guest path, while the initial process is loaded by the core bootstrap,
+which cannot call into the syscall translator and keeps
</file context>
Suggested change
guest-issued execs route it through `path_translate_at()` like any other
guest path, while the initial process is loaded by the core bootstrap,
which cannot call into the syscall translator and keeps
`elf_resolve_interp()` (`src/core/elf.c`), a literal sysroot
concatenation plus a `/lib/<basename>` fallback.
guest-issued execs route it through `path_translate_at()` like any other
guest path, while the initial process is loaded by the core bootstrap,
which first tries `elf_resolve_interp()` (`src/core/elf.c`), a literal sysroot
concatenation plus a `/lib/<basename>` fallback, then falls through to
`path_translate_at()` when those strategies leave the path unchanged.

Comment thread .ci/check-matrix-lists.sh
Comment thread mk/tests.mk
before=$$(ls -d /tmp/elfuse-absock-* 2>/dev/null | wc -l | tr -d ' '); \
$(ELFUSE_BIN) --sysroot "$$tmpdir" \
$(BUILD_DIR)/test-sysroot-absock-names; \
if [ -e "/sockdir" ]; then \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: make check can fail on hosts that already have /sockdir, even when no socket path escaped the sysroot. Reserve or preflight this global fixture before execution, and avoid treating an existing unrelated path as test output.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mk/tests.mk, line 464:

<comment>`make check` can fail on hosts that already have `/sockdir`, even when no socket path escaped the sysroot. Reserve or preflight this global fixture before execution, and avoid treating an existing unrelated path as test output.</comment>

<file context>
@@ -301,6 +376,492 @@ test-sysroot-case-exact: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-case-exact
+	before=$$(ls -d /tmp/elfuse-absock-* 2>/dev/null | wc -l | tr -d ' '); \
+	$(ELFUSE_BIN) --sysroot "$$tmpdir" \
+	    $(BUILD_DIR)/test-sysroot-absock-names; \
+	if [ -e "/sockdir" ]; then \
+		printf "$(RED)FAIL$(RESET) a socket path escaped to the host root\n"; \
+		exit 1; \
</file context>

Comment thread mk/tests.mk
trap 'rm -rf "$$tmpdir"' EXIT; \
$(ELFUSE_BIN) --sysroot "$$tmpdir" \
$(BUILD_DIR)/test-sysroot-symlink-target; \
if [ -e "/symlink-target" ]; then \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: make check can fail on hosts that already have /symlink-target, independent of elfuse behavior. Add an isolated/preflighted host-path fixture before launching the guest, or skip this host-leak assertion when that global path is occupied.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mk/tests.mk, line 441:

<comment>`make check` can fail on hosts that already have `/symlink-target`, independent of elfuse behavior. Add an isolated/preflighted host-path fixture before launching the guest, or skip this host-leak assertion when that global path is occupied.</comment>

<file context>
@@ -301,6 +376,492 @@ test-sysroot-case-exact: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-case-exact
+	trap 'rm -rf "$$tmpdir"' EXIT; \
+	$(ELFUSE_BIN) --sysroot "$$tmpdir" \
+	    $(BUILD_DIR)/test-sysroot-symlink-target; \
+	if [ -e "/symlink-target" ]; then \
+		printf "$(RED)FAIL$(RESET) a create through a link escaped to the host root\n"; \
+		exit 1; \
</file context>

@henrybear327
henrybear327 force-pushed the sysroot/casefold-redesign branch 2 times, most recently from 0ed57be to c199dbc Compare August 2, 2026 05:51
Guest paths under /tmp, /var/tmp, and ~/.ccache are force-redirected
into the sysroot when a file is created there, so a guest's temp files
avoid collisions on the host's case-insensitive /tmp. Lookups were not
redirected: an absolute guest path absent from the sysroot falls back to
the literal host path, so a /tmp entry present on the host but not in
the sysroot was visible to stat and open.

The two disagreed. unlink, rmdir, and rename translate through the
create resolver and so addressed the sysroot, where the entry does not
exist, while stat and open addressed the host, where it does. A guest
could stat, open, and read such an entry but got ENOENT on every attempt
to remove or rename it: rm -rf of a host-visible /tmp directory failed
midway with "No such file or directory" despite -f, because readdir
listed a child that the following unlink could not resolve.

Give both resolvers one predicate for the temp roots, so the rule cannot
drift between them again, and match the roots as directories as well as
prefixes of their children. Matching only the children leaves the same
disagreement one level up, where a listing of /tmp enumerates host
entries whose names no longer resolve. ccache matches by path component
because HOME may be a macOS home directory that no other rule covers.

A host entry the guest never created under those roots is no longer
visible, which is what the create-side redirect already implied. A guest
program or interpreter stored there can no longer be loaded either,
since the ELF path resolves the same way; pass one from anywhere else.
The host fallback is unchanged for every other path, so guests still
reach host resources outside these temp roots.

test-sysroot-tmp-remove pins the regression. It stages a directory under
the runner's own /tmp, so the guest reaches it by the redirect, and
asserts that whatever the guest can stat there it can also unlink,
rmdir, and rename, that every name a /tmp listing reports resolves, and
that a guest-created /tmp file reads back. The three consistency lanes
fail with ENOENT without the change, and the listing lane fails when
only the children are redirected. The harness then confirms from the
host side that the staging directory is untouched and that the guest's
own write landed in the sysroot.

test-sysroot-create-paths asserted the absence of a TOCTOU escape by
stat'ing a host /tmp path from inside the guest, which this change makes
impossible to observe. That check moves to the harness, which runs on
the host and can still see it.
A Linux guest names files by exact bytes; the default APFS volume
matches names case- and normalization-blind, so "Foo" and "foo" cannot
coexist in one directory. Introduce the representation that lets them:
a name the volume cannot store as itself is stored escaped, and the
escape is a pure function of the name, so nothing has to be persisted
in order to reverse it.

Escaping keys on the name alone: any uppercase ASCII letter, any byte
above 0x7F, or a name already shaped like an escape. That is
deliberately conservative, because the volume's matching is far more
aggressive than case plus NFD (sharp s folds onto "ss", final sigma
folds by position, compatibility mappings apply) and nothing short of
full Unicode tables predicts it. What the rule leaves literal is
lowercase ASCII, a fixed point of every transformation the volume
applies, so two literal names cannot collide however the folding table
changes. Keying on the name rather than on directory contents also
means colliding creates never contend.

The payload has two tiers because the per-name limit is 255 UTF-16
code units, not bytes: hex up to a 125-byte guest name (readable back
with xxd -r -p), and above that twelve bits per symbol from 4096 CJK
Unified Ideographs at U+4E00, a block that neither normalizes nor
folds. A _Static_assert holds both tiers to the unit limit.

The codec is a leaf translation unit so the unit test links exactly
the code under test; probe-volume-naming regenerates the measurements
docs/filenames.md tabulates. tests/casefold-vectors.h freezes the
on-disk spellings byte-for-byte in both directions, making a format
change a deliberate migration rather than a silent test update.
Resolve a guest path to its host spelling one component at a time,
applying the escape where a name cannot be stored as itself. This is
the half of the model that has to touch the filesystem, kept apart
from the codec so the codec stays a leaf translation unit.

Each component is decided by asking the volume for the name as stored
and comparing bytes, the only way to tell "exists as spelled" from
"exists under a spelling that folded onto it": a plain stat reports
success for both, while Linux resolution is byte-exact and owes ENOENT
for the second. The two answers stay distinct all the way out: folded
is not absent, because a caller deciding whether the sysroot has a
claim on the path needs the difference. The whole path is probed
prefix by prefix, since the volume validates only the last component
and a wrong-case parent folds away silently.

The walk is seeded from a descriptor and a prefix, so one
implementation serves an absolute path measured from the sysroot and a
relative one measured from a dirfd. Along the main path nothing is
opened (each probe is a getattrlistat), so the walk holds no
descriptors and has no cleanup path. A volume that cannot report a
stored spelling falls back to reading the directory, finished with an
fstatat because a name missing from the listing may still be present
under a spelling that folded onto it.

The unit test drives staged literal names, staged escapes, symlinks,
and a dangling link; its length arm grows a path a component at a time
and requires a clean ENAMETOOLONG boundary, because a host path is
roughly twice its guest path while macOS allows a quarter the length
Linux does, and a truncated path names a different file.
Guest paths were resolved twice: proc_resolve_sysroot_path_flags
concatenated the sysroot prefix and probed existence, then
path_translate_at ran a second, independent walk that read the sidecar
index and could override the first answer. The two disagreed about when
a path falls through to the host. Fold them into one: the case-exact
walk decides each component's stored spelling and reports whether the
path resolved, so its verdict is the existence answer and its output is
the host path. A relative name resolves through the same walk seeded
from the descriptor it is measured from, and the openat2
RESOLVE_NO_SYMLINKS and RESOLVE_NO_XDEV walkers, which spelled
components the guest's way and missed every escape, resolve the same
way.

Absence is not one answer but three, and only the first may look at the
host. A path nothing claims falls through. A path whose slot is held by
a differently-spelled sibling is absent to a byte-exact reader, but the
sysroot holds something there, so the caller's own syscall reports
ENOENT; treating it as unclaimed would send a wrong-case lookup out to
an unrelated host file. A path stopped at a component that is not a
directory is the same: resolution fails there (path_resolution(7)), so
the walk records it and the sysroot spelling is returned, or "file/tail"
would be answered by whatever host path shares those bytes, ENOENT where
Linux owes ENOTDIR.

Creates ask where an absent leaf would go, so the containment flag
ladder tests create before nofollow (a renameat destination carries
both), and the walk reports the parent separately from the leaf,
replacing an access(2) probe that a folding volume answered wrongly in
both directions. Directory entries and the cwd decode on the way back
out, so getcwd never leaks an on-disk spelling.

With the mapping derived from the name, the sidecar has nothing left to
do. Its five private syscall handlers return to the ordinary
translation, and stop bypassing the /proc, FUSE, and /dev/shm handling;
the index, its parsers, the process mutex, the fcntl lock, the rollback
snapshots, and both caches are removed. Two elfuse processes sharing a
sysroot need no coordination, because there is no shared mutable state
left. The internals module table and the usage notes described that
machinery, so both are rewritten rather than left naming a deleted file;
the notes also record that a sysroot written by the old encoding has to
be recreated, the one guest-visible consequence of this commit.

Smaller corrections ride on the single walk: a trailing separator keeps
Linux's ENOTDIR by asserting directory-ness on the host path; a sysroot
mounted at "/" no longer turns a leaf's parent into an empty string; and
a path unlinked by a peer between probe and realpath reports ENOENT
rather than a containment veto's ELOOP, since neither the path nor a
vanished sysroot leaves anything reachable once it stops resolving.

The contract lands in two lanes, name-unique and name-relative, which
also run against the qemu reference kernel, where a real Linux kernel
measures what they assert. Neither can run in the elfuse lane, which has
no sysroot, so the matrix gains ELFUSE_SKIP: the inverse of QEMU_SKIP,
with one membership helper and a check-format gate rejecting a label
that names no registered test or sits in both lists.
The naming design rests on claims a green build does not exercise;
give each its own lane.

Edge shapes: test-sysroot-root drives the degenerate one-character
prefix over the read-only macOS root; test-nosysroot-literal-names
pins that without a sysroot nothing decodes and an escape-shaped host
file means itself; test-sysroot-chdir requires getcwd and
/proc/self/cwd to report spellings the guest can hand back to chdir.

Names: test-sysroot-name-i18n pins the pairs the volume considers
equal that the guest must see as two files (sharp s, positional
sigma, ligatures, normalization families, no-case scripts, invalid
UTF-8), and in csapfs mode pins the two documented divergences of
case-sensitive APFS instead of skipping. test-sysroot-name-length
pins that a guest keeps all 255 bytes Linux allows across both tier
boundaries of the escape, and that 256 is refused.

Lengths are two separate budgets, and the second one is the host's:
test-sysroot-pathmax pins ENAMETOOLONG where macOS's 1024-byte
PATH_MAX undercuts the guest's 4096, since a host path runs to roughly
twice its guest path and a truncated one names a different file. It
stays out of the qemu matrix because a real Linux kernel has no such
ceiling and correctly builds every path the lane expects refused.

Reading back what an older build wrote: test-sysroot-corpus stages a
tree of on-disk spellings byte-for-byte from tests/casefold-vectors.h
and opens each strictly by guest name, which is the direction an
existing sysroot exercises after an upgrade. Staging happens on the
host, so nothing it asserts derives from the codec under test.

Host-staged names: a sysroot is an ordinary directory anything may
write into, so test-sysroot-name-staged fixes the meaning of names
elfuse never produces: a well-formed escape decodes no matter who
wrote it, anything merely resembling one means itself, and when both
spellings of one name are staged the literal wins the lookup while
the listing reports both.

Concurrency: nothing serializes name creation, because the on-disk
spelling is a function of the name alone, the load-bearing claim.
test-sysroot-name-race forks children that create colliding members
and race O_EXCL for a single kernel-picked winner, and runs in check
ten times over, because one round is cheap and a single round can
miss the window. test-sysroot-name-soak churns creates, renames,
unlinks, and listing scans from threads and forked children against a
deadline; it is registered as a manual target and kept out of check
for its runtime. Neither proves the absence of a race, and both
headers say so.

The name lanes also run against the qemu reference kernel, where a
real kernel confirms Linux keeps each pair apart, and are skipped in
the elfuse lane for want of a sysroot.
The representation half of docs/filenames.md landed with the codec.
Fill in the resolution half: how a path is resolved a component at a
time, what each answer from the volume means, and the two properties
that fall out of deciding a name's spelling from the name alone:
that a guest name is reachable through exactly one on-disk entry, and
that nothing in the subsystem takes a lock, since each operation is
one atomic syscall and no second object records what a name means.

A section on symlink targets records the one thing the model cannot
represent: a link stores the bytes the guest gave it, because readlink
has to return them, so a target naming something stored escaped cannot
be followed by the host. The pure-function claim is narrowed to what
is true: two rows of the resolution table do consult the directory,
both only for a fold-stable name whose escape an outsider staged.

The dynamic-linker walkthrough still described a sysroot-first openat
rather than the one forward resolver every path-taking handler now
uses, so it is rewritten to point at path_translate_at, and its
limitations section now names this document instead of claiming
nothing is tracked: what the sysroot volume cannot represent is a
real limitation of that path, recorded here.
A symlink records the bytes the guest wrote, and it has to: readlink(2)
owes those bytes back, so a target cannot be translated on the way in.
But those bytes name a guest path while the disk holds host spellings,
so handing them to the host kernel looks somewhere else: a guest could
not follow its own symlink when a target component was stored escaped,
and an absolute target resolved from the host root instead of the
sysroot.

Follow targets in the guest namespace instead. The walk stops at a link
it must pass through and reports where; the resolver reads the target,
joins a relative one to the link's directory or lets an absolute one
replace the path, appends the remainder, and resolves that as an
ordinary guest path. The loop is iterative, since a chain may run to
MAXSYMLINKS and each step needs a whole path buffer. Detecting the link
is free: ATTR_CMN_OBJTYPE rides along on the probe the walk already
makes.

POSIX fixes which components are followed: every intermediate one, and
the last only without nofollow. All four resolvers learn the rule:
lookup, create, the descriptor-relative translation, and the
RESOLVE_NO_SYMLINKS precheck, for which a stopped-at-a-link verdict is
the ELOOP it exists to report. The precheck asks the walk directly,
because splicing a link into its target hides it from any later scan;
the descriptor-relative translation reuses the host path its containment
check already resolved, keeping one flag mapping rather than a second
copy that can drift.

An absolute target resolves against the sysroot but does not inherit the
host fallback a typed path gets: anything able to write a symlink into
the tree could otherwise hand the guest a file from outside it. The
splice shares the existing truncation-checked concatenation, and
MAXSYMLINKS moves to path.h so the path layer and the resolvers cannot
disagree about when a chain has run too long.
A watch is backed by kqueue on a host fd, but the path it is added
under is a guest path: opened raw, an absolute watch lands in the
host's namespace and reports ENOENT for a directory the guest can
chdir into. Route it through path_translate_at like every other
path-taking syscall, refusing FUSE and synthetic /proc objects with
ENOSYS since kqueue cannot observe them.

Those two are the whole refusal. The open path's
path_might_use_open_intercept is not reused for it: that predicate is
a prefilter, true for every name beginning "/dev" (four bytes, so
"/development" too), for the sysfs CPU tree, and for /etc/passwd
whenever the sysroot carries no copy. Each of those has a real host
vnode behind it (a /dev/shm leaf is redirected to one by the
translation itself), and Linux grants a watch on all of them, so
gating on it would answer ENOSYS where a watch is owed. A lane pins
the /dev/shm leaf against that.

That redirect makes the leaf a real host file, so the watch is opened
nofollow. A guest may write a symlink into the /dev/shm backing
directory, and following one would report the existence of, and every
change to, whatever it names, including a host path
is_guest_system_path() exists to keep the guest from addressing at
all. Linux follows here, but the redirect is elfuse's own and every
other consumer of a shm leaf already departs the same way. A second
lane pins that beside the one granting the watch.

The snapshots feeding named IN_CREATE/IN_DELETE events read raw
directory entries, so on a folding sysroot an event named the stored
.ef= spelling, bytes the guest never wrote and cannot stat. Decode
each entry through path_translate_dirent_name, the choke point
getdents64 uses; an over-long name is skipped exactly as getdents64
skips it, and any other failure keeps the previous baseline rather
than diffing every decoded child as deleted. The decode helper drops
a dirfd parameter it never used (decoding depends on nothing but
the name), so this caller, which holds no guest descriptor, does not
have to invent one.

The lane's recipe asserts host-side that the fixture survives only
under its escape, so a passing guest lane proves the events were
decoded. Events are collected by reading a nonblocking fd: the
emulation pumps its queue on read, and poll-only readiness is a
pre-existing gap this change does not touch.
execveat with a dirfd-relative name opened the raw guest bytes: under
a casefold sysroot a guest-created binary exists on disk only under
its escaped spelling, so the open reported ENOENT for a file execve
runs fine, and could have resolved a case-colliding host-literal
file instead. Translate the name like every other *at handler,
refusing FUSE and synthetic /proc objects, and open the translated
spelling with O_CLOEXEC so a concurrent execve on another vCPU thread
cannot inherit the descriptor.

The identity that resolution publishes needs the reverse map: left as
the resolved host path, /proc/self/exe reported the sysroot prefix
and .ef= spellings, bytes the guest never wrote and cannot exec,
which is how a self-re-exec stops working. sys_execve derives the
guest-visible identity with path_host_to_guest while keeping the host
path for the actual open; proc_readlink_self_exe replaces its bare
prefix strip with the same reverse map; and /proc/self/fd/N runs its
F_GETPATH result through it too.

The lane's recipe asserts host-side that the staged binary sits on
disk only under its escape, so the passing exec lanes prove
resolution actually crossed the boundary.
The interpreter resolver kept its own three-strategy probe from the core
loader: a literal sysroot concatenation checked with access(2), then
/lib/<basename>, then the guest bytes. The concat probe answers with the
volume's folding lookup, so a wrong-case interpreter spelling was
accepted where Linux resolution is byte-exact, and a hit skipped the
containment, /dev/shm, and FUSE handling every other exec path gets.
Route the decision through the ordinary translation: a usable translated
path wins, and the /lib/<basename> fallback still catches store-style
interpreter paths. elf_resolve_interp stays with the core bootstrap,
which cannot call into the syscall translator.

Usability is judged under the rule the open then uses. A shm redirect is
opened O_NOFOLLOW, so its leaf is probed without following: access(2)
follows, and a symlink in the shm backing directory would answer usable
for a path the open refuses with ELOOP, skipping the fallback that would
have found the loader. Everywhere else the probe still follows, since an
interpreter is routinely a symlink.

On this tree the escaped-spelling case already reached the translator
through the old code's literal fallthrough, so the two lanes are
regression guards rather than observed-red fixes: one pins the /lib
fallback for an absent store path, the other an interpreter staged under
an escaped directory and exec'd from inside the guest. Both skip when
the musl fixtures are absent.
A pathname socket's address is a filesystem path, but every sockaddr
crossed the boundary through a raw byte copy: bind created the socket
file at the host-literal path (outside the sysroot for any guest
path not mirrored there), and connect, sendto, and sendmsg aimed at
the same wrong namespace, so two guest processes could not rendezvous
through a socket in a guest-created directory. Route pathname
addresses through path_translate_at: bind uses create semantics, so
colliding socket names coexist and bind, stat, connect, and unlink
agree on which file a name means, with EADDRINUSE for an occupied
name. getsockname, getpeername, accept, recvfrom, and recvmsg decode
returned addresses through path_host_to_guest, so the guest reads
back the spelling it bound.

A translated host path routinely overflows the 104-byte macOS
sun_path (the sysroot prefix plus an escape more than doubling a
component), so over-long paths divert through a short symlink in the
private absock namespace dir; bind through a dangling symlink creates
the socket at the target and connect follows it (probed on macOS 15).

Two details of that readback come with it. recvmsg reported the macOS
address length beside the translated bytes it wrote; the two were
interchangeable until translation made them differ, and a guest sizing
sun_path from msg_namelen then read past the address into its own
buffer. And the namespace dir is recognized from the namespace id
rather than from having created it, so a forked child (a fresh
process that inherits the id) undoes the shortening symlink too,
matched on a whole path component so one namespace cannot claim
another's by prefix.

A /dev/shm leaf carries the never-follow rule that redirect exists
for, and bind and connect take a sockaddr rather than a dirfd and
at_flags, so the rule cannot ride on an open flag here and is checked
outright. Following a guest-planted link there would bind the socket
at the link's target, outside the tree, and would answer connect with
ENOTSOCK for a host file that exists against ENOENT for one that does
not, telling the guest whether any path exists. Both reach what
is_guest_system_path() denies the guest by name.

The exit sweep for that shared namespace dir arms in
absock_ensure_dir_locked, the one point where on-disk state first
appears, rather than in sys_bind's abstract-socket branch alone. The
sweep learns the shared exit orders: each process unlinks its own
table-tracked sockets, only the namespace's creator retires untracked
symlinks, any participant may rmdir, and only symlinks are swept:
the abstract-socket backing files in the same directory belong to
whichever process bound them, including children that outlive the
owner. The names lane asserts host-side that a socket survives only
under its escape; the lifecycle lane covers both fork exit orders.

With socket addresses decoded, every surface that hands names back to
the guest now participates; docs/filenames.md names the full decode
boundary in one place.
Two lanes join the suite, both of them oracles rather than
expectations. check-name-caseexact re-runs the name suite on a
case-sensitive APFS sparsebundle, where the volume itself enforces the
byte-exact matching the tests assert, so an expectation that fails
there disagrees with a real Linux filesystem whatever the folding lane
says of it; this lane is what surfaced the ENOTDIR and vanished-path
fixes in the resolver.

test-sysroot-path-matrix closes the class the hand-written tests
sampled: every late bug on this branch sat in one cell of a cross
product (addressing mode x operation x path shape x name class)
that no one had thought to visit. The matrix enumerates the product
and holds each cell to path_resolution(7)'s oracle: an absolute, a
cwd-relative, and a dirfd-relative spelling of one file must agree on
result, errno, and object, with creates verified through the
canonical absolute spelling. On first run it caught an absolute
openat2(RESOLVE_NO_SYMLINKS) missing an in-sysroot intermediate link
and a relative rename below an escaped directory failing where the
absolute spelling succeeds. It runs on the folding tmpdir, again on
the case-sensitive volume, and against the qemu reference kernel,
where agreement is a measurement rather than an assertion.

Putting the matrix on the byte-exact volume needs the oracle's closing
sweep narrowed. That sweep fails the lane if any entry is stored
escaped, which is the right invariant for the name tests but not for
this one: an escape-shaped literal is one of the matrix's name
classes, so a byte-exact volume owes that name back unchanged and the
sweep read correct behavior as a violation. It now prunes the matrix
subtree and checks that subtree positively instead (a name needing
an escape on a folding volume has to be stored literally here), which
keeps the coverage the prune would otherwise drop.

The testing guide describes the lanes by family and states the shared
recipe contract (a self-provisioned sysroot with a host-side
on-disk assertion after the guest exits) instead of enumerating
targets that drift.
@henrybear327
henrybear327 force-pushed the sysroot/casefold-redesign branch from c199dbc to 4ae7542 Compare August 2, 2026 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant