Absolute sysroot symlinks fail on case-sensitive APFS

Open
#390 0 comments 0 reactions 1 assignee View on GitHub

@henrybear327 is already working on this.

Since Sep 20, 2026.

Assessment

This issue has not been assessed yet.

Description

Summary

A staged absolute symlink inside a case-sensitive sysroot is resolved from the
macOS host root instead of the guest root when its original guest path is a
forced system path such as /bin/sh.

An Alpine aarch64 rootfs contains:

/bin/sh -> /bin/busybox

Both entries exist inside the rootfs. Launching /bin/sh, opening it from a
running guest, or passing it to guest execve returns ENOENT. Launching
/bin/busybox directly succeeds. A relative symlink in the same /bin
directory also succeeds.

The failure is specific to the byte-exact resolver used for a case-sensitive
sysroot. The casefold resolver used on the default case-insensitive APFS volume
already follows the link in the guest namespace.

Environment
  • Host: macOS 26.7, Darwin 25.6.0, Apple Silicon arm64
  • elfuse branch: main
  • elfuse commit: c0458b4365e47a603d345996ef73d1579748db20
  • build/elfuse SHA-256:
    294ef103b85170c108032dcb366acd6a433e8011ecc469d78927f890834bd679
  • Guest fixture: Alpine 3.21, aarch64
  • Sysroot filesystem: case-sensitive APFS sparsebundle
Reproducer

The commands use a private copy of the existing Alpine fixture. They do not
modify externals/test-fixtures/rootfs.

Create and mount a case-sensitive sparsebundle:

REPRO=build/absolute-sh-symlink-repro
IMAGE="$REPRO/rootfs.sparsebundle"
ROOTFS="$REPRO/rootfs"

mkdir -p "$ROOTFS"
hdiutil create -size 1g -fs "Case-sensitive APFS" \
    -type SPARSEBUNDLE -volname elfuse-absolute-sh "$IMAGE"
hdiutil attach "$IMAGE" -mountpoint "$ROOTFS" -nobrowse

Copy the fixture without following its symlinks:

rsync -a externals/test-fixtures/rootfs/ "$ROOTFS/"
mkdir -p "$ROOTFS/work/home"

ls -l "$ROOTFS/bin/sh" "$ROOTFS/bin/busybox"

The last command should show:

... rootfs/bin/sh -> /bin/busybox
... rootfs/bin/busybox

Run the failing entry path:

build/elfuse --timeout 0 \
    --sysroot "$ROOTFS" \
    --workdir /work \
    --clear-env \
    --env PATH=/usr/sbin:/usr/bin:/sbin:/bin \
    --env HOME=/work/home \
    --env LC_ALL=C \
    /bin/sh -c 'printf "sh-ok\n"'
printf 'status=%s\n' "$?"
Expected result

The absolute symlink target is absolute in the guest namespace. /bin/sh
therefore resolves to <sysroot>/bin/busybox, prints sh-ok, and exits with
status 0.

This matches Linux and the QEMU aarch64 reference run.

Actual result
ERROR src/main.c:667: cannot read /bin/sh (resolved to .../rootfs/bin/sh): No such file or directory
status=1

The result was identical across three runs and with --verbose. The same
result was reproduced from a clean build of main at the commit above.

Controls

Direct execution of the target succeeds under the same elfuse binary and
sysroot:

build/elfuse --timeout 0 \
    --sysroot "$ROOTFS" \
    --workdir /work \
    --clear-env \
    --env PATH=/usr/sbin:/usr/bin:/sbin:/bin \
    --env HOME=/work/home \
    --env LC_ALL=C \
    /bin/busybox sh -c 'printf "busybox-ok\n"'

Observed result:

busybox-ok

The same failure occurs after the guest has started:

/bin/busybox sh -c 'exec /bin/sh -c "printf runtime-exec-ok\\n"'

Guest access and open calls on /bin/sh also return ENOENT. A relative
link from /bin/cat to ../usr/bin/coreutils works in the same rootfs. The
absolute target, not the target file or executable format, distinguishes the
failure.

Detach the image after the failing run and controls:

hdiutil detach "$ROOTFS"
Root cause

path_translate_at() delegates a following lookup to
proc_resolve_sysroot_path_flags() in src/syscall/proc-state.c.

The resolver classifies /bin/sh as a forced guest system path. On a
case-sensitive sysroot, casefold_active() is false and this condition skips
the byte-exact symlink walker:

} else if (!forced_original_path) {
    casefold_verdict_t verdict = resolve_byte_exact_through_links(
        sr, lookup, follow_final, buf, bufsz, followed, sizeof(followed),
        &followed_relative_link);
    /* ... */
} else {
    present = sysroot_path_exists(buf, follow_final);
}

The final branch probes the prefixed spelling
<sysroot>/bin/sh with the host stat or open behavior. Darwin follows the
stored absolute target /bin/busybox from the host root. Host /bin/busybox
does not exist, so the probe and the caller return ENOENT even though
<sysroot>/bin/busybox exists.

On a case-insensitive sysroot, casefold_active() is true and
resolve_through_links() reads the link, rebases the absolute target in the
guest namespace, and reaches the correct file. This explains why the existing
symlink-target coverage does not reproduce the case-sensitive failure.

The problematic else if (!forced_original_path) condition was introduced by
commit 5555c08fad5586f2932ff6e567ad8a1f903d0f2a (Follow absolute sysroot links to host paths).

Impact

The shared path resolver serves more than initial ELF loading. The bug affects
follow-style operations on staged absolute symlinks in forced guest system
paths, including:

  • initial executable loading;
  • guest execve;
  • open, stat, and access;
  • script and package-manager paths that invoke /bin/sh.

An Alpine apk busybox trigger exits with status 127 when it tries to invoke
/bin/sh. The error is independent of the reported mremap failure.

Proposed fix

Use the byte-exact symlink walker for forced system paths when the sysroot is
case-sensitive. The resolver must read a staged link and resolve an absolute
target from the guest root before choosing the sysroot or host spelling.

The host fallback behavior added for staged links still needs coverage. A link
whose resolved guest target is outside forced guest directories may still fall
back to an existing host path, while /bin/sh -> /bin/busybox must remain
inside the sysroot.

Regression coverage

Add a case-sensitive APFS lane that stages an absolute link without passing
through guest symlinkat, because guest-created absolute links are rewritten
to a relative on-disk spelling.

The test should cover:

  1. /bin/sh -> /bin/busybox as the initial ELF entry;
  2. runtime execve("/bin/sh", ...);
  3. open, stat, and access through the link;
  4. a relative link in the same directory;
  5. an allowed staged host bridge link;
  6. a dangling absolute link and a link loop with their Linux errnos.

Run the portable behavior in the QEMU aarch64 reference lane and the sysroot
behavior in an elfuse case-sensitive APFS lane.

Acceptance criteria
  • The reproducer prints sh-ok and exits with status 0.
  • Runtime execve, open, stat, and access follow the staged absolute link
    in the guest namespace.
  • Relative symlink behavior and permitted host fallback behavior do not regress.
  • Dangling links and link loops retain their Linux errno behavior.
  • The relevant sysroot path tests and aarch64 matrix lanes pass.
Dominant language
C
Stars
268
Forks
26
Avg merge
1d 13h
Merged PRs (30d)
48

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from sysprog21/elfuse

All issues in sysprog21/elfuse

Similar issues

More C issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.