From 9dc286fd62bb71d1152ba6eb0c71f009a29c189a Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Thu, 6 Aug 2026 11:05:29 +0200 Subject: [PATCH 1/8] Stop computing resolver outputs no caller reads Two computations ran on every translation that reached them, and nothing read their results. path_check_relative_sysroot_containment() filled a 4 KiB buffer with the reconstructed absolute guest path for callers that need it, but one caller passes NULL and the other never reads the buffer again. Both byte-exact resolver arms then re-concatenated the sysroot and the lookup into a buffer the seed had already spelled from the same operands, nothing on those arms having rebased the lookup. The two spellings differ in one case: under --sysroot /, the snprintf produced a doubled separator, the prefix and the clamped lookup each carrying a slash. Every consumer either passes the buffer through realpath() or special-cases the one-byte prefix, so no behavior depends on that extra byte. --- src/syscall/path.c | 41 ++++++++++------------------------------ src/syscall/proc-state.c | 22 ++------------------- 2 files changed, 12 insertions(+), 51 deletions(-) diff --git a/src/syscall/path.c b/src/syscall/path.c index 4567e8f0..c3e77175 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -195,8 +195,6 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd, const char *path, unsigned int flags, bool *in_sysroot, - char *abs_out, - size_t abs_outsz, char *host_out, size_t host_outsz); @@ -297,13 +295,11 @@ int path_translate_at(guest_fd_t dirfd, */ bool climbed_root = false; bool relative_in_sysroot = false; - char relative_abs[LINUX_PATH_MAX]; char relative_host[LINUX_PATH_MAX]; if (tx->host_path && tx->guest_path[0] != '/' && proc_get_sysroot()) { int recheck = path_check_relative_sysroot_containment( dirfd, tx->guest_path, lookup_flags, &relative_in_sysroot, - relative_abs, sizeof(relative_abs), relative_host, - sizeof(relative_host)); + relative_host, sizeof(relative_host)); if (recheck < 0) { tx->host_path = NULL; if (errno == 0) @@ -641,7 +637,7 @@ int sys_path_has_symlink(guest_fd_t dirfd, const char *path) bool in_sysroot = false; int recheck = path_check_relative_sysroot_containment( - dirfd, path, PATH_TR_NOFOLLOW, &in_sysroot, NULL, 0, NULL, 0); + dirfd, path, PATH_TR_NOFOLLOW, &in_sysroot, NULL, 0); if (recheck < 0) { rc = -1; goto out; @@ -1427,20 +1423,15 @@ static int dirfd_symlink_chain_reaches_absolute_target(guest_fd_t dirfd, return rc; } -/* Returns 1 when the reconstruction climbed the guest root, so the caller opens - * the resolved absolute host path instead of walking from dirfd; 0 when the - * walk stays beneath it; or -1 with errno set. @in_sysroot reports whether the - * sysroot claims the reconstructed path. @abs_out (the reconstructed absolute - * guest path) and @host_out (the resolved host spelling, filled for a climbed - * or in-sysroot path) hand the work back to callers that need it; a NULL - * pointer opts out of either. +/* Returns 1 when the reconstruction climbed the guest root, so the caller + * opens @host_out instead of walking from dirfd; 0 when it stays beneath; -1 + * with errno set. @in_sysroot reports whether the sysroot claims the path; + * @host_out is filled for a climbed or in-sysroot path, NULL to opt out. */ static int path_check_relative_sysroot_containment(guest_fd_t dirfd, const char *path, unsigned int flags, bool *in_sysroot, - char *abs_out, - size_t abs_outsz, char *host_out, size_t host_outsz) { @@ -1504,22 +1495,10 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd, * prefix of its own to make that call from. */ *in_sysroot = checked != abs_path; - - /* The reconstruction is not free, and a caller that has to follow a symlink - * needs the same absolute path to do it, so hand it back rather than make - * it build one of its own that could differ. - */ - if (abs_out && str_copy_trunc(abs_out, abs_path, abs_outsz) >= abs_outsz) { - errno = ENAMETOOLONG; - return -1; - } - - /* The resolution itself is not free either. A caller whose own walk stops - * at a link needs exactly this host path (resolved with the same flag - * mapping), and re-deriving it invites the two mappings to drift. A path - * that clamped at the guest root needs it whichever way it resolved: both - * spellings are absolute, so either lands where the guest's own resolution - * would, and the descriptor drops out of the walk. + /* Handing the resolution back rather than letting a caller re-derive it + * keeps the two flag mappings from drifting. A path that clamped at the + * guest root needs it either way: both spellings are absolute, so the + * descriptor drops out of the walk. */ if (host_out && (*in_sysroot || climbed) && str_copy_trunc(host_out, checked, host_outsz) >= host_outsz) { diff --git a/src/syscall/proc-state.c b/src/syscall/proc-state.c index 4024730b..4d7920db 100644 --- a/src/syscall/proc-state.c +++ b/src/syscall/proc-state.c @@ -1106,16 +1106,7 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, if (!present && errno == ENOTDIR) return buf; } else { - int n = snprintf(buf, bufsz, "%s%s", sr, lookup); - if (n < 0) { - if (errno == 0) - errno = EINVAL; - return NULL; - } - if ((size_t) n >= bufsz) { - errno = ENAMETOOLONG; - return NULL; - } + /* @buf already holds sr + the clamped lookup, spelled by the seed. */ present = sysroot_path_exists(buf, follow_final); if (!present && errno == ENOTDIR) return buf; @@ -1282,16 +1273,7 @@ const char *proc_resolve_sysroot_create_path(const char *path, return NULL; } } else { - int n = snprintf(buf, bufsz, "%s%s", sr, lookup); - if (n < 0) { - if (errno == 0) - errno = EINVAL; - return NULL; - } - if ((size_t) n >= bufsz) { - errno = ENAMETOOLONG; - return NULL; - } + /* @buf already holds sr + the clamped lookup, spelled by the seed. */ /* An all-slash guest path ("/", "///") names the root, which always * exists and has no parent to check; trimming it would walk strrchr From 8d2911a77d19a57eccb35dc43dff6610473def13 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Thu, 6 Aug 2026 11:08:01 +0200 Subject: [PATCH 2/8] Remove unread probe outputs and unreachable arms Four things in the walk's per-component loop had no reader or no reachable caller. resolve_component() reported presence through @present beside its verdict, and the two never diverged: presence was set on exactly the paths that return PROBE_EXACT, so callers test the verdict instead. probe_exact() guarded its @is_link out-param against a NULL that no caller passes. The escape fallback in resolve_component() cannot run: path_component_copy() delivers a non-empty, slash-free name of at most CASEFOLD_GUEST_NAME_MAX bytes into a statically sized buffer that casefold.h proves large enough, and its retry could only repeat the failure it handled. That arm now returns PROBE_ERROR rather than guess a spelling. casefold_resolve_at() required a walk report, so four of its six callers declared a casefold_walk_t only to satisfy the signature. The report becomes optional; three of those callers pass NULL here, and the fourth keeps its struct because a later commit reads it. --- src/syscall/casefold-walk.c | 43 ++++++++++++++++--------------------- src/syscall/casefold-walk.h | 2 ++ src/syscall/path.c | 12 ++++------- 3 files changed, 24 insertions(+), 33 deletions(-) diff --git a/src/syscall/casefold-walk.c b/src/syscall/casefold-walk.c index bfee3a2e..88f4cdeb 100644 --- a/src/syscall/casefold-walk.c +++ b/src/syscall/casefold-walk.c @@ -183,8 +183,7 @@ static probe_result_t probe_exact(host_fd_t base_fd, char name[CASEFOLD_STORED_NAME_MAX]; } __attribute__((aligned(4), packed)) attr_buf; - if (is_link) - *is_link = false; + *is_link = false; if (getattrlistat(base_fd, path, &al, &attr_buf, sizeof(attr_buf), FSOPT_NOFOLLOW) == 0) { @@ -208,7 +207,7 @@ static probe_result_t probe_exact(host_fd_t base_fd, size_t obj_end = (size_t) ((const char *) &attr_buf.obj_type - (const char *) &attr_buf) + sizeof(attr_buf.obj_type); - if (is_link && (attr_buf.returned.commonattr & ATTR_CMN_OBJTYPE) && + if ((attr_buf.returned.commonattr & ATTR_CMN_OBJTYPE) && obj_end <= usable) *is_link = attr_buf.obj_type == VLNK; if (!strcmp(stored, leaf)) @@ -323,9 +322,9 @@ static int name_by_rule(const char *guest, char *out, size_t outsz) return casefold_escape(guest, out, outsz); } -/* Spell one component, given the parent already spelled in @out. Reports - * through @present whether the entry is there, and writes the host spelling - * into @host. +/* Spell one component, given the parent already spelled in @out. The entry is + * there exactly when the verdict is PROBE_EXACT; the host spelling goes to + * @host either way. */ static probe_result_t resolve_component(host_fd_t base_fd, const char *out, @@ -333,15 +332,12 @@ static probe_result_t resolve_component(host_fd_t base_fd, const char *guest, char *host, size_t hostsz, - bool *present, bool *is_link) { char probe_path[LINUX_PATH_MAX]; size_t probe_len = len; probe_result_t verdict; - *present = false; - /* An escape-shaped guest name is stored escaped unconditionally, so it can * never be mistaken for the encoding of a different name. Probing its * literal spelling would find some unrelated file. @@ -364,7 +360,6 @@ static probe_result_t resolve_component(host_fd_t base_fd, errno = ENAMETOOLONG; return PROBE_ERROR; } - *present = true; return PROBE_EXACT; } } else { @@ -380,15 +375,13 @@ static probe_result_t resolve_component(host_fd_t base_fd, * differently-spelled sibling in the slot, a name the volume refuses, or * simply nothing there), the escape is the only other place the name can * live, so ask whether it does. + * + * The escape cannot fail: casefold.h sizes @host by + * CASEFOLD_HOST_NAME_MAX for any name path_component_copy delivers, so a + * failure is a broken precondition and fails closed. */ - if (casefold_escape(guest, host, hostsz) < 0) { - if (errno != ENAMETOOLONG && errno != EINVAL) - return PROBE_ERROR; - /* Cannot be escaped, so the literal spelling is the only candidate and - * the probe already answered for it. - */ - return name_by_rule(guest, host, hostsz) < 0 ? PROBE_ERROR : verdict; - } + if (casefold_escape(guest, host, hostsz) < 0) + return PROBE_ERROR; probe_len = len; if (str_copy_trunc(probe_path, out, sizeof(probe_path)) >= @@ -401,7 +394,6 @@ static probe_result_t resolve_component(host_fd_t base_fd, switch (probe_exact(base_fd, probe_path, host, is_link)) { case PROBE_EXACT: - *present = true; return PROBE_EXACT; case PROBE_ERROR: return PROBE_ERROR; @@ -443,7 +435,10 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, size_t comp_len; size_t len; bool absent = false; + casefold_walk_t local; + if (!walk) + walk = &local; walk->parent_found = true; walk->parent_offset = 0; walk->link_rest_offset = 0; @@ -461,7 +456,6 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, while (path_next_component(&scan, &comp, &comp_len)) { char guest[CASEFOLD_GUEST_NAME_MAX + 1]; char host[CASEFOLD_HOST_NAME_MAX + 1]; - bool present = false; if (path_component_copy(guest, sizeof(guest), comp, comp_len) < 0) return CASEFOLD_ERROR; @@ -487,9 +481,8 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, return CASEFOLD_ERROR; } else { bool is_link = false; - probe_result_t verdict = - resolve_component(base_fd, out, len, guest, host, sizeof(host), - &present, &is_link); + probe_result_t verdict = resolve_component( + base_fd, out, len, guest, host, sizeof(host), &is_link); if (verdict == PROBE_ERROR) return CASEFOLD_ERROR; @@ -505,7 +498,7 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, * sysroot rather than at the host root. Handing them to the kernel * looks somewhere else entirely. */ - if (present && is_link) { + if (verdict == PROBE_EXACT && is_link) { const char *rest = scan; while (*rest == '/') @@ -533,7 +526,7 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, */ if (verdict == PROBE_NOTDIR) walk->notdir = true; - absent = !present; + absent = verdict != PROBE_EXACT; } if (append_leaf(out, outsz, &len, host, walk) < 0) diff --git a/src/syscall/casefold-walk.h b/src/syscall/casefold-walk.h index 2115ff95..d9276b1f 100644 --- a/src/syscall/casefold-walk.h +++ b/src/syscall/casefold-walk.h @@ -113,6 +113,8 @@ typedef struct { * * @follow_final rechecks the resolved object through symlinks, so a dangling * link reports absent, matching what an access(2) existence probe would say. + * + * @walk may be NULL when the caller needs only the verdict and @out. */ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, const char *base_host_prefix, diff --git a/src/syscall/path.c b/src/syscall/path.c index c3e77175..e5efc7a9 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -346,16 +346,14 @@ int path_translate_at(guest_fd_t dirfd, bool follow_final = !(lookup_flags & (PATH_TR_NOFOLLOW | PATH_TR_CREATE)); host_fd_ref_t ref; - casefold_walk_t walk; casefold_verdict_t verdict; if (host_dirfd_ref_open(dirfd, &ref) < 0) { errno = EBADF; return -1; } - verdict = - casefold_resolve_at(ref.fd, "", tx->guest_path, follow_final, - tx->host_buf, sizeof(tx->host_buf), &walk); + verdict = casefold_resolve_at(ref.fd, "", tx->guest_path, follow_final, + tx->host_buf, sizeof(tx->host_buf), NULL); host_fd_ref_close(&ref); if (verdict == CASEFOLD_ERROR) return -1; @@ -560,10 +558,9 @@ int sys_path_has_symlink(guest_fd_t dirfd, const char *path) char sr[LINUX_PATH_MAX]; if (proc_sysroot_snapshot(sr, sizeof(sr))) { - casefold_walk_t walk; casefold_verdict_t verdict = casefold_resolve_at(AT_FDCWD, sr, path, false, sysroot_buf, - sizeof(sysroot_buf), &walk); + sizeof(sysroot_buf), NULL); if (verdict == CASEFOLD_ERROR) return -1; @@ -649,10 +646,9 @@ int sys_path_has_symlink(guest_fd_t dirfd, const char *path) * through its !climbed_root gate. */ if (in_sysroot && recheck == 0) { - casefold_walk_t walk; casefold_verdict_t verdict = casefold_resolve_at(current_fd, "", path, false, sysroot_buf, - sizeof(sysroot_buf), &walk); + sizeof(sysroot_buf), NULL); if (verdict == CASEFOLD_ERROR) { rc = -1; From 42d8e2c39602720efa4c75b8f86e9d061530c97c Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Tue, 11 Aug 2026 13:32:45 +0200 Subject: [PATCH 3/8] Move the UTF-16 unit oracle into its test casefold_utf16_units, and the UTF-8 validator standing behind it, have no production caller: the escape decision already covers ill-formed UTF-8 through its byte >= 0x80 rule, and the unit budgets are enforced by static assert. The host lane's budget check is the only consumer. Move both there. Inside the codec, that check compares the production arithmetic against itself. In the test the counter is a second implementation, so a codec change that breaks the unit budget shows up as a disagreement between the two instead of being mirrored into both sides at once. --- src/syscall/casefold.c | 86 ------------------------------------ src/syscall/casefold.h | 6 --- tests/test-casefold-host.c | 90 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 92 deletions(-) diff --git a/src/syscall/casefold.c b/src/syscall/casefold.c index 93be0874..f2ada138 100644 --- a/src/syscall/casefold.c +++ b/src/syscall/casefold.c @@ -45,92 +45,6 @@ static unsigned hex_value(char c) return c <= '9' ? (unsigned) (c - '0') : (unsigned) (c - 'a' + 10); } -/* True when @s is well-formed UTF-8: no overlong forms, no surrogates, nothing - * above U+10FFFF. APFS refuses to create a name that is not, so an ill-formed - * name has to be escaped rather than stored. Not exported: the judgment is - * observable through casefold_utf16_units, which reports no unit cost for a - * name the volume will not hold literally. - */ -static bool casefold_utf8_valid(const char *s) -{ - const unsigned char *p = (const unsigned char *) s; - - while (*p) { - unsigned char c = *p; - unsigned extra; - uint32_t cp; - - if (c < 0x80) { - p++; - continue; - } - if (c >= 0xC2 && c <= 0xDF) { - extra = 1; - cp = c & 0x1Fu; - } else if (c >= 0xE0 && c <= 0xEF) { - extra = 2; - cp = c & 0x0Fu; - } else if (c >= 0xF0 && c <= 0xF4) { - extra = 3; - cp = c & 0x07u; - } else { - /* 0x80-0xC1 is a stray continuation or an overlong two-byte lead; - * 0xF5-0xFF encodes above U+10FFFF. - */ - return false; - } - - for (unsigned i = 0; i < extra; i++) { - unsigned char cc = p[1 + i]; - if ((cc & 0xC0u) != 0x80u) - return false; - cp = (cp << 6) | (cc & 0x3Fu); - } - - /* Reject the forms that encode a code point in more bytes than needed, - * the UTF-16 surrogate range, and anything past the Unicode maximum. - * Each has more than one byte sequence otherwise, which would break the - * one-spelling-per-name property. - */ - if (extra == 2 && cp < 0x800u) - return false; - if (extra == 3 && cp < 0x10000u) - return false; - if (cp >= 0xD800u && cp <= 0xDFFFu) - return false; - if (cp > 0x10FFFFu) - return false; - - p += 1 + extra; - } - return true; -} - -size_t casefold_utf16_units(const char *s) -{ - const unsigned char *p = (const unsigned char *) s; - size_t units = 0; - - if (!casefold_utf8_valid(s)) - return 0; - - while (*p) { - if (*p < 0x80) - p += 1; - else if (*p < 0xE0) - p += 2; - else if (*p < 0xF0) - p += 3; - else { - /* Above the BMP: encoded as a surrogate pair, so two units. */ - p += 4; - units++; - } - units++; - } - return units; -} - bool casefold_needs_escape(const char *name) { if (!name || name[0] == '\0') diff --git a/src/syscall/casefold.h b/src/syscall/casefold.h index 37311c68..a52184ba 100644 --- a/src/syscall/casefold.h +++ b/src/syscall/casefold.h @@ -84,12 +84,6 @@ _Static_assert(CASEFOLD_PREFIX_LEN + CASEFOLD_UNIT_MAX, "long-tier escape exceeds the per-name UTF-16 unit limit"); -/* UTF-16 code units @s occupies, which is what the host counts against its - * per-name limit: one per code point below U+10000, two above. Returns 0 when - * @s is not valid UTF-8, which callers treat as "cannot be stored literally". - */ -size_t casefold_utf16_units(const char *s); - /* True when @name cannot be stored under its own spelling. That is any name * carrying an uppercase ASCII letter or a byte >= 0x80, plus any name that is * itself escape-shaped so it cannot be confused with the escape of another diff --git a/tests/test-casefold-host.c b/tests/test-casefold-host.c index c2709cac..033ddaed 100644 --- a/tests/test-casefold-host.c +++ b/tests/test-casefold-host.c @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,95 @@ #include "host-test-util.h" #include "syscall/casefold.h" +/* True when @s is well-formed UTF-8: no overlong forms, no surrogates, + * nothing above U+10FFFF. APFS refuses to create a name that is not, so an + * ill-formed name has to be escaped rather than stored. + */ +static bool casefold_utf8_valid(const char *s) +{ + const unsigned char *p = (const unsigned char *) s; + + while (*p) { + unsigned char c = *p; + unsigned extra; + uint32_t cp; + + if (c < 0x80) { + p++; + continue; + } + if (c >= 0xC2 && c <= 0xDF) { + extra = 1; + cp = c & 0x1Fu; + } else if (c >= 0xE0 && c <= 0xEF) { + extra = 2; + cp = c & 0x0Fu; + } else if (c >= 0xF0 && c <= 0xF4) { + extra = 3; + cp = c & 0x07u; + } else { + /* 0x80-0xC1 is a stray continuation or an overlong two-byte lead; + * 0xF5-0xFF encodes above U+10FFFF. + */ + return false; + } + + for (unsigned i = 0; i < extra; i++) { + unsigned char cc = p[1 + i]; + if ((cc & 0xC0u) != 0x80u) + return false; + cp = (cp << 6) | (cc & 0x3Fu); + } + + /* Reject the forms that encode a code point in more bytes than + * needed, the UTF-16 surrogate range, and anything past the Unicode + * maximum. Each has more than one byte sequence otherwise, which + * would break the one-spelling-per-name property. + */ + if (extra == 2 && cp < 0x800u) + return false; + if (extra == 3 && cp < 0x10000u) + return false; + if (cp >= 0xD800u && cp <= 0xDFFFu) + return false; + if (cp > 0x10FFFFu) + return false; + + p += 1 + extra; + } + return true; +} + +/* The UTF-16 code units @s occupies, which is what the volume counts against + * its per-name limit. A second implementation of the codec's arithmetic, so a + * change breaking the unit budget shows up as a disagreement here rather than + * in both sides at once. 0 means @s is not well-formed UTF-8. + */ +static size_t casefold_utf16_units(const char *s) +{ + const unsigned char *p = (const unsigned char *) s; + size_t units = 0; + + if (!casefold_utf8_valid(s)) + return 0; + + while (*p) { + if (*p < 0x80) + p += 1; + else if (*p < 0xE0) + p += 2; + else if (*p < 0xF0) + p += 3; + else { + /* Above the BMP: encoded as a surrogate pair, so two units. */ + p += 4; + units++; + } + units++; + } + return units; +} + /* Print a name so a failure is diagnosable when the bytes are not printable. */ static void dump(const char *label, const char *s) { From 887aabc0fbe595b45c547366cfe212af8de4271a Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Thu, 6 Aug 2026 11:58:33 +0200 Subject: [PATCH 4/8] Read the fold flag without the sysroot lock proc_sysroot_casefold_enabled(), which backs every casefold_active() call, took sysroot_lock several times per translation to read a flag decided once at startup, and re-published to a forked child, before any vCPU thread issues a syscall. No other state is kept consistent with it, so the lock provided ordering that nothing requires. Make the flag _Atomic and state the published-once invariant where it is set. --- src/syscall/proc-state.c | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/syscall/proc-state.c b/src/syscall/proc-state.c index 4d7920db..e4fce440 100644 --- a/src/syscall/proc-state.c +++ b/src/syscall/proc-state.c @@ -5,6 +5,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include #include #include #include @@ -55,7 +56,11 @@ static char sysroot_path[LINUX_PATH_MAX] = {0}; * the snprintf input buffer underneath that thread. */ static pthread_mutex_t sysroot_lock = PTHREAD_MUTEX_INITIALIZER; -static bool sysroot_casefold = false; +/* Atomic, not guarded by sysroot_lock: published once at startup and to a + * forked child before any vCPU thread issues a syscall, and no other state is + * kept consistent with it. + */ +static _Atomic bool sysroot_casefold = false; /* Cached current working directory for getcwd() and /proc/self/cwd. */ static pthread_mutex_t cwd_lock = PTHREAD_MUTEX_INITIALIZER; @@ -427,18 +432,12 @@ bool proc_sysroot_snapshot(char *out, size_t outsz) void proc_set_sysroot_casefold(bool enabled) { - pthread_mutex_lock(&sysroot_lock); - sysroot_casefold = enabled; - pthread_mutex_unlock(&sysroot_lock); + atomic_store(&sysroot_casefold, enabled); } bool proc_sysroot_casefold_enabled(void) { - bool enabled; - pthread_mutex_lock(&sysroot_lock); - enabled = sysroot_casefold; - pthread_mutex_unlock(&sysroot_lock); - return enabled; + return atomic_load(&sysroot_casefold); } /* True when realpath(3) failed because the path stopped resolving rather than From a803546d1bdb48e1957f5a35fb1599456cae9401 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Thu, 6 Aug 2026 11:09:47 +0200 Subject: [PATCH 5/8] Stop recomputing process constants per call Two per-call computations re-derived values fixed at configuration time. sysroot_seed_host_path() took the sysroot snapshot, and with it a mutex acquisition, before testing whether the path is even absolute, so every relative-path syscall performed a snapshot it then discarded; the absolute-path test now runs first. sysroot_path_is_contained() re-derived realpath(sysroot) although proc_set_sysroot() canonicalizes the sysroot once; it now copies that snapshot, and falls back to the per-call derivation only for a sysroot that did not resolve then. The flag recording which of those two cases holds is read without the lock, so it is _Atomic, matching the fold flag beside it. --- src/syscall/proc-state.c | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/syscall/proc-state.c b/src/syscall/proc-state.c index e4fce440..f2b3a996 100644 --- a/src/syscall/proc-state.c +++ b/src/syscall/proc-state.c @@ -61,6 +61,11 @@ static pthread_mutex_t sysroot_lock = PTHREAD_MUTEX_INITIALIZER; * kept consistent with it. */ static _Atomic bool sysroot_casefold = false; +/* True when proc_set_sysroot's realpath succeeded, so containment checks can + * copy sysroot_path instead of re-deriving it; false leaves them the per-call + * realpath. Atomic for the same reason as the fold flag above. + */ +static _Atomic bool sysroot_canonical = false; /* Cached current working directory for getcwd() and /proc/self/cwd. */ static pthread_mutex_t cwd_lock = PTHREAD_MUTEX_INITIALIZER; @@ -79,6 +84,7 @@ void proc_state_init(void) auxv_len = 0; sysroot_path[0] = '\0'; sysroot_casefold = false; + sysroot_canonical = false; pthread_mutex_lock(&cwd_lock); cwd_path[0] = '\0'; @@ -387,14 +393,17 @@ const void *proc_get_auxv(size_t *len_out) void proc_set_sysroot(const char *path) { pthread_mutex_lock(&sysroot_lock); + sysroot_canonical = false; if (path && path[0]) { str_copy_trunc(sysroot_path, path, sizeof(sysroot_path)); size_t len = strlen(sysroot_path); while (len > 1 && sysroot_path[len - 1] == '/') sysroot_path[--len] = '\0'; char resolved[LINUX_PATH_MAX]; - if (realpath(sysroot_path, resolved)) + if (realpath(sysroot_path, resolved)) { str_copy_trunc(sysroot_path, resolved, sizeof(sysroot_path)); + sysroot_canonical = true; + } } else { sysroot_path[0] = '\0'; } @@ -475,7 +484,12 @@ static bool sysroot_path_is_contained(const char *resolved_path, { char real_sysroot[LINUX_PATH_MAX], real_path[LINUX_PATH_MAX]; - if (!realpath(sysroot, real_sysroot)) + /* proc_set_sysroot canonicalized @sysroot at configuration time, so + * re-deriving it here would realpath() a process constant per check. + */ + if (sysroot_canonical) + str_copy_trunc(real_sysroot, sysroot, sizeof(real_sysroot)); + else if (!realpath(sysroot, real_sysroot)) return realpath_vanished(); if (follow_final) { @@ -681,6 +695,8 @@ static bool clamp_dotdot_at_guest_root(char *dest, /* Snapshot the sysroot, clamp @path, and spell the host path it names. * Returns 1 with @buf, @sr, and @clamped filled, 0 when sysroot resolution * does not apply and the caller owes its input back, or -1 with errno set. + * On 0 the outputs are indeterminate: a relative path opts out before the + * snapshot runs, so @sr is not even the empty string. */ static int sysroot_seed_host_path(const char *path, char *buf, @@ -689,7 +705,8 @@ static int sysroot_seed_host_path(const char *path, char clamped[LINUX_PATH_MAX], bool follow_final) { - if (!proc_sysroot_snapshot(sr, LINUX_PATH_MAX) || !path || path[0] != '/') + /* Ordered so a relative path opts out before the snapshot locks. */ + if (!path || path[0] != '/' || !proc_sysroot_snapshot(sr, LINUX_PATH_MAX)) return 0; char real_path[LINUX_PATH_MAX]; From df4db669857685b6490e3d2f17c8c4f45f5c1cf4 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Thu, 6 Aug 2026 11:24:35 +0200 Subject: [PATCH 6/8] Probe components in place in the walk Each component copied the whole accumulated prefix into a private 4 KiB buffer before appending its candidate, twice when the literal probe missed, which is the rescan the running length exists to avoid. Append into the output buffer and restore the terminator instead: the per-component cost drops from a copy of the prefix to a copy of the component bytes. A candidate that does not fit still reports ENAMETOOLONG, an escape never being shorter than the literal it stands for. The restore also runs when the append itself fails. The separator is written before the length check that rejects the candidate, so without the restore an over-long component would leave a trailing slash in the caller's buffer. --- src/syscall/casefold-walk.c | 58 +++++++++++++++++++++---------------- src/syscall/casefold-walk.h | 4 +++ 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/syscall/casefold-walk.c b/src/syscall/casefold-walk.c index 88f4cdeb..11db414d 100644 --- a/src/syscall/casefold-walk.c +++ b/src/syscall/casefold-walk.c @@ -322,20 +322,44 @@ static int name_by_rule(const char *guest, char *out, size_t outsz) return casefold_escape(guest, out, outsz); } +/* Probe @cand appended to the parent in @out[0..len), restoring @out to that + * prefix on every exit. append_component writes the separator before the + * length check, so an unrestored buffer would keep a trailing '/'. + */ +static probe_result_t probe_candidate(host_fd_t base_fd, + char *out, + size_t outsz, + size_t len, + const char *cand, + bool *is_link) +{ + size_t probe_len = len; + probe_result_t verdict; + + if (append_component(out, outsz, &probe_len, cand) < 0) { + out[len] = '\0'; + return PROBE_ERROR; + } + verdict = probe_exact(base_fd, out, cand, is_link); + out[len] = '\0'; + return verdict; +} + /* Spell one component, given the parent already spelled in @out. The entry is * there exactly when the verdict is PROBE_EXACT; the host spelling goes to - * @host either way. + * @host either way. @out doubles as the probe buffer. A candidate that does + * not fit reports ENAMETOOLONG exactly as the final spelling would, an escape + * never being shorter than the literal it stands for. */ static probe_result_t resolve_component(host_fd_t base_fd, - const char *out, + char *out, + size_t outsz, size_t len, const char *guest, char *host, size_t hostsz, bool *is_link) { - char probe_path[LINUX_PATH_MAX]; - size_t probe_len = len; probe_result_t verdict; /* An escape-shaped guest name is stored escaped unconditionally, so it can @@ -343,16 +367,7 @@ static probe_result_t resolve_component(host_fd_t base_fd, * literal spelling would find some unrelated file. */ if (!casefold_is_escaped(guest)) { - if (str_copy_trunc(probe_path, out, sizeof(probe_path)) >= - sizeof(probe_path)) { - errno = ENAMETOOLONG; - return PROBE_ERROR; - } - if (append_component(probe_path, sizeof(probe_path), &probe_len, - guest) < 0) - return PROBE_ERROR; - - verdict = probe_exact(base_fd, probe_path, guest, is_link); + verdict = probe_candidate(base_fd, out, outsz, len, guest, is_link); if (verdict == PROBE_ERROR) return PROBE_ERROR; if (verdict == PROBE_EXACT) { @@ -383,16 +398,9 @@ static probe_result_t resolve_component(host_fd_t base_fd, if (casefold_escape(guest, host, hostsz) < 0) return PROBE_ERROR; - probe_len = len; - if (str_copy_trunc(probe_path, out, sizeof(probe_path)) >= - sizeof(probe_path)) { - errno = ENAMETOOLONG; - return PROBE_ERROR; - } - if (append_component(probe_path, sizeof(probe_path), &probe_len, host) < 0) - return PROBE_ERROR; - - switch (probe_exact(base_fd, probe_path, host, is_link)) { + probe_result_t escape_verdict = + probe_candidate(base_fd, out, outsz, len, host, is_link); + switch (escape_verdict) { case PROBE_EXACT: return PROBE_EXACT; case PROBE_ERROR: @@ -482,7 +490,7 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, } else { bool is_link = false; probe_result_t verdict = resolve_component( - base_fd, out, len, guest, host, sizeof(host), &is_link); + base_fd, out, outsz, len, guest, host, sizeof(host), &is_link); if (verdict == PROBE_ERROR) return CASEFOLD_ERROR; diff --git a/src/syscall/casefold-walk.h b/src/syscall/casefold-walk.h index d9276b1f..8515575a 100644 --- a/src/syscall/casefold-walk.h +++ b/src/syscall/casefold-walk.h @@ -115,6 +115,10 @@ typedef struct { * link reports absent, matching what an access(2) existence probe would say. * * @walk may be NULL when the caller needs only the verdict and @out. + * + * @out doubles as the walk's probe scratch, so it must not overlap + * @guest_path or @base_host_prefix, and it holds nothing meaningful once + * CASEFOLD_ERROR is returned. */ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, const char *base_host_prefix, From 21bc700afbfd490e9c6195a9e8a59b32596b0d4f Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Fri, 7 Aug 2026 15:31:59 +0200 Subject: [PATCH 7/8] Reuse the object type the component probe returns Every component probe requests ATTR_CMN_OBJTYPE, and the answer was discarded and then derived again: the NO_XDEV walker re-asked with fstatat per component, and the walk re-confirmed a resolved leaf with faccessat. Record what the probe established in the walk report and gate both calls on it. The recheck stays wherever the walk cannot answer: the readdir fallback, whose listing carries no type and so withdraws whatever the probe before it learned, and the byte-exact arm, which runs no walk. The surviving fstatat in the NO_XDEV walker now runs only where the walk left link-ness unknown, and the restructure carries its errno rule across unchanged: ENOENT is itself an answer, nothing there being able to be a link, while any other errno leaves link-ness undecided and fails the walk. Mount classification depends on that walk, so it must not cross a component it could not type. --- src/syscall/casefold-walk.c | 49 +++++++++--- src/syscall/casefold-walk.h | 6 ++ src/syscall/path.c | 137 +++++++++++++++++++------------- tests/test-casefold-walk-host.c | 13 +++ 4 files changed, 136 insertions(+), 69 deletions(-) diff --git a/src/syscall/casefold-walk.c b/src/syscall/casefold-walk.c index 11db414d..6582a4f6 100644 --- a/src/syscall/casefold-walk.c +++ b/src/syscall/casefold-walk.c @@ -155,7 +155,8 @@ const char *casefold_attr_stored_name(const void *reply, static probe_result_t probe_exact(host_fd_t base_fd, const char *path, const char *leaf, - bool *is_link) + bool *is_link, + bool *type_known) { /* ATTR_CMN_OBJTYPE rides along on a request already being made, so knowing * whether the entry is a symlink costs nothing beyond the byte comparison @@ -184,6 +185,7 @@ static probe_result_t probe_exact(host_fd_t base_fd, } __attribute__((aligned(4), packed)) attr_buf; *is_link = false; + *type_known = false; if (getattrlistat(base_fd, path, &al, &attr_buf, sizeof(attr_buf), FSOPT_NOFOLLOW) == 0) { @@ -208,8 +210,10 @@ static probe_result_t probe_exact(host_fd_t base_fd, (const char *) &attr_buf) + sizeof(attr_buf.obj_type); if ((attr_buf.returned.commonattr & ATTR_CMN_OBJTYPE) && - obj_end <= usable) + obj_end <= usable) { *is_link = attr_buf.obj_type == VLNK; + *type_known = true; + } if (!strcmp(stored, leaf)) return PROBE_EXACT; /* A mismatch is not yet a fold. For a second hard link to a @@ -219,7 +223,11 @@ static probe_result_t probe_exact(host_fd_t base_fd, * asked can still come back under another name. Only the listing * tells an aliased name from a genuinely folded one, and only a * mismatch pays for the scan. + * + * The listing carries no type, and this call typed the entry the + * volume named, not the one the listing finds, so withdraw it. */ + *type_known = false; return probe_by_readdir(base_fd, path, leaf); } /* The call succeeded but the volume withheld the name or handed back @@ -331,7 +339,8 @@ static probe_result_t probe_candidate(host_fd_t base_fd, size_t outsz, size_t len, const char *cand, - bool *is_link) + bool *is_link, + bool *type_known) { size_t probe_len = len; probe_result_t verdict; @@ -340,7 +349,7 @@ static probe_result_t probe_candidate(host_fd_t base_fd, out[len] = '\0'; return PROBE_ERROR; } - verdict = probe_exact(base_fd, out, cand, is_link); + verdict = probe_exact(base_fd, out, cand, is_link, type_known); out[len] = '\0'; return verdict; } @@ -358,7 +367,8 @@ static probe_result_t resolve_component(host_fd_t base_fd, const char *guest, char *host, size_t hostsz, - bool *is_link) + bool *is_link, + bool *type_known) { probe_result_t verdict; @@ -367,7 +377,8 @@ static probe_result_t resolve_component(host_fd_t base_fd, * literal spelling would find some unrelated file. */ if (!casefold_is_escaped(guest)) { - verdict = probe_candidate(base_fd, out, outsz, len, guest, is_link); + verdict = probe_candidate(base_fd, out, outsz, len, guest, is_link, + type_known); if (verdict == PROBE_ERROR) return PROBE_ERROR; if (verdict == PROBE_EXACT) { @@ -384,6 +395,7 @@ static probe_result_t resolve_component(host_fd_t base_fd, * below says otherwise. */ verdict = PROBE_ABSENT; + *type_known = false; } /* The literal spelling is not what is stored. Whatever the reason (a @@ -399,7 +411,7 @@ static probe_result_t resolve_component(host_fd_t base_fd, return PROBE_ERROR; probe_result_t escape_verdict = - probe_candidate(base_fd, out, outsz, len, host, is_link); + probe_candidate(base_fd, out, outsz, len, host, is_link, type_known); switch (escape_verdict) { case PROBE_EXACT: return PROBE_EXACT; @@ -454,6 +466,8 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, walk->leaf_offset = 0; walk->folded = false; walk->notdir = false; + walk->leaf_type_known = false; + walk->leaf_is_link = false; len = str_copy_trunc(out, base_host_prefix ? base_host_prefix : "", outsz); if (len >= outsz) { @@ -475,6 +489,8 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, * the host kernel resolves those against the real descriptor. */ if (!strcmp(guest, ".") || !strcmp(guest, "..")) { + walk->leaf_type_known = false; + walk->leaf_is_link = false; if (append_leaf(out, outsz, &len, guest, walk) < 0) return CASEFOLD_ERROR; continue; @@ -485,15 +501,21 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, * nothing needs to be: the spelling follows from the name. */ walk->parent_found = false; + walk->leaf_type_known = false; + walk->leaf_is_link = false; if (name_by_rule(guest, host, sizeof(host)) < 0) return CASEFOLD_ERROR; } else { bool is_link = false; - probe_result_t verdict = resolve_component( - base_fd, out, outsz, len, guest, host, sizeof(host), &is_link); + bool type_known = false; + probe_result_t verdict = + resolve_component(base_fd, out, outsz, len, guest, host, + sizeof(host), &is_link, &type_known); if (verdict == PROBE_ERROR) return CASEFOLD_ERROR; + walk->leaf_type_known = verdict == PROBE_EXACT && type_known; + walk->leaf_is_link = walk->leaf_type_known && is_link; /* A link the walk has to pass through stops it. That is every * intermediate component, and the final one only when the caller @@ -545,10 +567,13 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, return CASEFOLD_ABSENT; /* The probe deliberately stops at a symlink rather than following it, so a - * caller that asked about the target has to say so. A link pointing nowhere - * is absent for that caller, which is what an access(2) probe would report. + * caller that asked about the target has to say so. A link pointing + * nowhere is absent for that caller, which is what an access(2) probe + * would report. A typed leaf is a known non-link (a known link returned + * CASEFOLD_SYMLINK above), so the probe adds nothing and is skipped. */ - if (follow_final && faccessat(base_fd, out, F_OK, 0) < 0) + if (follow_final && !walk->leaf_type_known && + faccessat(base_fd, out, F_OK, 0) < 0) return CASEFOLD_ABSENT; return CASEFOLD_FOUND; } diff --git a/src/syscall/casefold-walk.h b/src/syscall/casefold-walk.h index 8515575a..e4627d8e 100644 --- a/src/syscall/casefold-walk.h +++ b/src/syscall/casefold-walk.h @@ -85,6 +85,12 @@ typedef struct { * rebuild the directory a relative target is measured from. */ size_t link_guest_offset; + /* Object type the leaf's probe answered. False when the readdir fallback + * answered, whose listing carries no type, and for dot components, which + * are never probed. + */ + bool leaf_type_known; + bool leaf_is_link; } casefold_walk_t; /* Resolve @guest_path, interpreted relative to @base_fd, into its host spelling diff --git a/src/syscall/path.c b/src/syscall/path.c index e5efc7a9..3348ff4e 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -1641,14 +1641,20 @@ static int reset_walk_fd(host_fd_t *current_fd, host_fd_t root_fd) * the walker would then report the absence rather than what is actually there. * Outside a sysroot, and for any name needing no escape, this is the name * itself. + * + * @is_link: -1 unknown (the caller must stat), 0 not a symlink or not there, + * 1 a symlink. Only the readdir fallback, whose listing carries no type, + * leaves it unknown. */ static int host_component_spelling(host_fd_t dirfd, const char *guest, char *out, - size_t outsz) + size_t outsz, + int *is_link) { casefold_walk_t walk; + *is_link = -1; if (!casefold_active()) { if (str_copy_trunc(out, guest, outsz) >= outsz) { errno = ENAMETOOLONG; @@ -1656,10 +1662,19 @@ static int host_component_spelling(host_fd_t dirfd, } return 0; } - return casefold_resolve_at(dirfd, "", guest, false, out, outsz, &walk) == - CASEFOLD_ERROR - ? -1 - : 0; + casefold_verdict_t verdict = + casefold_resolve_at(dirfd, "", guest, false, out, outsz, &walk); + if (verdict == CASEFOLD_ERROR) + return -1; + /* Any non-FOUND verdict here means not there: @guest is one component + * with follow_final false, so CASEFOLD_SYMLINK, which the walk returns + * only for a link it must pass through, cannot come back. + */ + if (verdict != CASEFOLD_FOUND) + *is_link = 0; + else if (walk.leaf_type_known) + *is_link = walk.leaf_is_link; + return 0; } int path_openat2_crosses_mount(guest_fd_t dirfd, @@ -1772,68 +1787,76 @@ int path_openat2_crosses_mount(guest_fd_t dirfd, goto out; } + int leaf_link = -1; if (host_walk && host_component_spelling(current_fd, name, host_name, - sizeof(host_name)) < 0) + sizeof(host_name), &leaf_link) < 0) goto out; - struct stat st; - if (host_walk && - fstatat(current_fd, host_name, &st, AT_SYMLINK_NOFOLLOW) == 0) { - if (S_ISLNK(st.st_mode)) { - if (guest_path_append(current, sizeof(current), comp, len) < - 0) - goto out; + /* ENOENT still yields a verdict: nothing there can be a link. + * Any other errno leaves link-ness undecided, and an undecided + * component must not be walked through as a directory. + */ + if (host_walk && leaf_link < 0) { + struct stat st; - int cls = classify_guest_path_mount(current); - if (cls < 0) { - errno = EINVAL; - goto out; - } - if (cls != start_class) { - rc = 1; - goto out; - } - str_copy_trunc(current, parent, sizeof(current)); + if (fstatat(current_fd, host_name, &st, AT_SYMLINK_NOFOLLOW) == + 0) + leaf_link = S_ISLNK(st.st_mode) ? 1 : 0; + else if (errno != ENOENT) + goto out; + else + leaf_link = 0; + } + if (host_walk && leaf_link == 1) { + if (guest_path_append(current, sizeof(current), comp, len) < 0) + goto out; - char target[LINUX_PATH_MAX]; - ssize_t target_len = readlinkat(current_fd, host_name, - target, sizeof(target) - 1); - if (target_len < 0) - goto out; - if (++symlink_count > MAXSYMLINKS) { - errno = ELOOP; - goto out; - } - target[target_len] = '\0'; - - /* No prefix: an absolute target re-anchors the walk fd - * below, and a relative one continues from current_fd, - * which already names the link's directory. - */ - if (path_splice_link_target(NULL, 0, target, walk, pending, - sizeof(pending)) < 0) - goto out; - walk = pending; + int cls = classify_guest_path_mount(current); + if (cls < 0) { + errno = EINVAL; + goto out; + } + if (cls != start_class) { + rc = 1; + goto out; + } + str_copy_trunc(current, parent, sizeof(current)); - if (target[0] == '/') { - host_fd_t reset_fd = - in_root ? root_fd : absolute_root_fd; - if (reset_walk_fd(¤t_fd, reset_fd) < 0) + char target[LINUX_PATH_MAX]; + ssize_t target_len = readlinkat(current_fd, host_name, target, + sizeof(target) - 1); + if (target_len < 0) + goto out; + if (++symlink_count > MAXSYMLINKS) { + errno = ELOOP; + goto out; + } + target[target_len] = '\0'; + + /* No prefix: an absolute target re-anchors the walk fd + * below, and a relative one continues from current_fd, + * which already names the link's directory. + */ + if (path_splice_link_target(NULL, 0, target, walk, pending, + sizeof(pending)) < 0) + goto out; + walk = pending; + + if (target[0] == '/') { + host_fd_t reset_fd = in_root ? root_fd : absolute_root_fd; + if (reset_walk_fd(¤t_fd, reset_fd) < 0) + goto out; + if (in_root) { + if (dirfd_guest_base_path(dirfd, current, + sizeof(current)) < 0) goto out; - if (in_root) { - if (dirfd_guest_base_path(dirfd, current, - sizeof(current)) < 0) - goto out; - } else { - current[0] = '/'; - current[1] = '\0'; - } + } else { + current[0] = '/'; + current[1] = '\0'; } - continue; } - } else if (host_walk && errno != ENOENT) { - goto out; + continue; } if (guest_path_append(current, sizeof(current), comp, len) < 0) diff --git a/tests/test-casefold-walk-host.c b/tests/test-casefold-walk-host.c index d8e478f9..ca7a9dc0 100644 --- a/tests/test-casefold-walk-host.c +++ b/tests/test-casefold-walk-host.c @@ -298,6 +298,19 @@ static void section_symlink(void) CASEFOLD_FOUND, "second-link"); check("escaped second link to a symlink", "/Hard.Link", CASEFOLD_FOUND, esc("Hard.Link")); + + /* Same entry, asked about the report rather than the spelling. A directory + * listing carries no object type, so a walk the fallback answered cannot + * claim to know the leaf's, and the NO_XDEV walker skips its own fstatat + * on the strength of that flag. + */ + if (casefold_resolve_at(AT_FDCWD, root, "/second-link", false, out, + sizeof(out), &walk) == CASEFOLD_FOUND && + !walk.leaf_type_known) + host_ok(); + else + host_fail("a listing-answered walk reports no leaf type", + "expected leaf_type_known false when readdir answered"); } static void section_limits(void) From 2c1a26c4072072f4fb84b7c9f4c45896f419c366 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Fri, 7 Aug 2026 15:32:21 +0200 Subject: [PATCH 8/8] Skip the containment recheck for a fully typed walk Every successful in-sysroot lookup called realpath() on a path the walk had just spelled, to prove the result lands inside the sysroot. A walk that answered every component's object type has already proven it: no symlink can hide in a path where every component was typed, so the host spelling cannot resolve anywhere but under the prefix it was built from. Record that condition in the walk report as all_types_known and skip the re-derivation when it holds. It does not hold for the readdir fallback, whose listing carries no type, or for the byte-exact arm, which runs no walk, and the create resolver's parent keeps its unconditional check: a mkdir may have just materialized it. The host lane pins the flag from both sides as a regression guard: the behavior arrives correct, so the checks cannot be observed red against this commit, but deleting the fallback's withdrawal makes the listing-answered case fail, which is the wrong answer this guard exists to catch. --- src/syscall/casefold-walk.c | 7 ++++++- src/syscall/casefold-walk.h | 8 ++++++++ src/syscall/proc-state.c | 10 ++++++++++ tests/test-casefold-walk-host.c | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/syscall/casefold-walk.c b/src/syscall/casefold-walk.c index 6582a4f6..a7c84198 100644 --- a/src/syscall/casefold-walk.c +++ b/src/syscall/casefold-walk.c @@ -225,7 +225,9 @@ static probe_result_t probe_exact(host_fd_t base_fd, * mismatch pays for the scan. * * The listing carries no type, and this call typed the entry the - * volume named, not the one the listing finds, so withdraw it. + * volume named, not the one the listing finds, so withdraw it: + * all_types_known below is what lets a caller skip the canonical + * containment recheck. */ *type_known = false; return probe_by_readdir(base_fd, path, leaf); @@ -468,6 +470,7 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, walk->notdir = false; walk->leaf_type_known = false; walk->leaf_is_link = false; + walk->all_types_known = true; len = str_copy_trunc(out, base_host_prefix ? base_host_prefix : "", outsz); if (len >= outsz) { @@ -516,6 +519,8 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, return CASEFOLD_ERROR; walk->leaf_type_known = verdict == PROBE_EXACT && type_known; walk->leaf_is_link = walk->leaf_type_known && is_link; + if (verdict == PROBE_EXACT && !type_known) + walk->all_types_known = false; /* A link the walk has to pass through stops it. That is every * intermediate component, and the final one only when the caller diff --git a/src/syscall/casefold-walk.h b/src/syscall/casefold-walk.h index e4627d8e..337a9c5f 100644 --- a/src/syscall/casefold-walk.h +++ b/src/syscall/casefold-walk.h @@ -91,6 +91,14 @@ typedef struct { */ bool leaf_type_known; bool leaf_is_link; + /* True when every component that resolved was typed by its probe, so no + * symlink can hide in the resolved path; only the readdir fallback, + * whose listing carries no type, can pass one unseen. Dot components + * navigate rather than name an entry, and components below an absent one + * name nothing, so neither withholds a type. A FOUND path with this set + * cannot resolve outside the prefix it was built under. + */ + bool all_types_known; } casefold_walk_t; /* Resolve @guest_path, interpreted relative to @base_fd, into its host spelling diff --git a/src/syscall/proc-state.c b/src/syscall/proc-state.c index f2b3a996..9ce1b758 100644 --- a/src/syscall/proc-state.c +++ b/src/syscall/proc-state.c @@ -1064,6 +1064,7 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, bool folded = false; bool followed_link = false; bool followed_relative_link = false; + bool walk_typed_all = false; char followed[LINUX_PATH_MAX]; if (casefold_active()) { casefold_walk_t walk; @@ -1075,6 +1076,7 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, return NULL; present = verdict == CASEFOLD_FOUND; folded = walk.folded; + walk_typed_all = present && walk.all_types_known; /* The walk stopped at a component that is not a directory, which is * the answer the byte-exact branch below reads off ENOTDIR: resolution * fails there (path_resolution(7)) and the host fallback must not run, @@ -1129,6 +1131,14 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, } if (present) { + /* A walk that typed every component has proven containment: '..' was + * clamped before it ran and no unseen symlink can hide in a fully + * typed path, so realpath() would re-derive the same bytes. The + * recheck remains for a readdir-answered component and for the + * byte-exact arm, which runs no walk. + */ + if (walk_typed_all) + return buf; if (!sysroot_path_is_contained(buf, sr, follow_final)) { errno = ELOOP; return NULL; diff --git a/tests/test-casefold-walk-host.c b/tests/test-casefold-walk-host.c index ca7a9dc0..7534f39f 100644 --- a/tests/test-casefold-walk-host.c +++ b/tests/test-casefold-walk-host.c @@ -311,6 +311,25 @@ static void section_symlink(void) else host_fail("a listing-answered walk reports no leaf type", "expected leaf_type_known false when readdir answered"); + + /* The walk-wide form of the same withdrawal. all_types_known is what lets + * the resolver skip its canonical containment recheck, so a component the + * listing answered has to clear it. + */ + if (casefold_resolve_at(AT_FDCWD, root, "/lowdir/inner.txt", false, out, + sizeof(out), &walk) == CASEFOLD_FOUND && + walk.all_types_known) + host_ok(); + else + host_fail("a fully probed walk sets all_types_known", + "expected all_types_known true"); + if (casefold_resolve_at(AT_FDCWD, root, "/second-link", false, out, + sizeof(out), &walk) == CASEFOLD_FOUND && + !walk.all_types_known) + host_ok(); + else + host_fail("a listing-answered walk clears all_types_known", + "expected all_types_known false when readdir answered"); } static void section_limits(void)