From 60c9ba0159684cb1b59a17651bb2d367f042010e Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Sat, 25 Jul 2026 22:01:42 +0200 Subject: [PATCH 01/11] Add the case-escape name codec 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. The symbol width is a single constant from which the alphabet size and the per-name symbol count both derive, so a packing change moves every consumer together, and 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. --- Makefile | 16 + docs/filenames.md | 206 +++++++++ mk/config.mk | 4 +- mk/tests.mk | 25 +- src/syscall/casefold.c | 396 +++++++++++++++++ src/syscall/casefold.h | 118 +++++ tests/casefold-vectors.h | 129 ++++++ tests/host-test-util.h | 112 +++++ tests/probe-volume-naming.c | 388 ++++++++++++++++ tests/test-casefold-host.c | 861 ++++++++++++++++++++++++++++++++++++ 10 files changed, 2251 insertions(+), 4 deletions(-) create mode 100644 docs/filenames.md create mode 100644 src/syscall/casefold.c create mode 100644 src/syscall/casefold.h create mode 100644 tests/casefold-vectors.h create mode 100644 tests/host-test-util.h create mode 100644 tests/probe-volume-naming.c create mode 100644 tests/test-casefold-host.c diff --git a/Makefile b/Makefile index a5ab62fc..16768430 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ SRCS := \ syscall/path.c \ syscall/fuse.c \ syscall/sidecar.c \ + syscall/casefold.c \ syscall/chown-overlay.c \ syscall/fs.c \ syscall/fs-stat.c \ @@ -192,6 +193,21 @@ $(BUILD_DIR)/test-teardown-live-vcpu-host: \ @echo " LD $@" $(Q)$(CC) $(CFLAGS) -o $@ $^ $(HVF_LDFLAGS) +## Build the volume naming probe (native macOS binary) +# Standalone: it measures the filesystem, so it links nothing from the project. +$(BUILD_DIR)/probe-volume-naming: $(BUILD_DIR)/probe-volume-naming.o \ + | $(BUILD_DIR) + @echo " LD $@" + $(Q)$(CC) $(CFLAGS) -o $@ $^ + +## Build the filename codec host test (native macOS binary) +# casefold.o is a leaf translation unit with no syscall-layer dependencies, so +# the test links exactly the code under test and nothing else. +$(BUILD_DIR)/test-casefold-host: $(BUILD_DIR)/test-casefold-host.o \ + $(BUILD_DIR)/syscall/casefold.o | $(BUILD_DIR) + @echo " LD $@" + $(Q)$(CC) $(CFLAGS) -o $@ $^ + # Guest test binaries (cross-compiled, aarch64-linux) # Only used when GUEST_TEST_BINARIES is not set. diff --git a/docs/filenames.md b/docs/filenames.md new file mode 100644 index 00000000..554c49a8 --- /dev/null +++ b/docs/filenames.md @@ -0,0 +1,206 @@ +# Filenames + +A Linux guest names files by exact bytes. The macOS volume underneath usually +does not. This document describes how a guest filename becomes a name on disk +and back again, and why the mechanism is shaped the way it is. + +It is about *representation* only: which bytes end up in a directory entry, and +how they are read back. Which tree a path is resolved against (the sysroot or +the host filesystem) is a separate question, decided on existence by +`proc_resolve_sysroot_path_flags` (`src/syscall/proc-state.c`). + +## The problem + +A filesystem has to answer one question about two names: are they the same name? + +``` +Linux ext4 compares bytes "Foo" != "foo" != "FOO" three files +macOS APFS case- and "Foo" == "foo" == "FOO" one file +(default volume) normalization-blind +``` + +APFS is case-*preserving*: it stores exactly the bytes it is given, so `ls` +shows `Foo`. It only *matches* loosely. A guest running against a sysroot on +such a volume therefore needs two things that do not come for free: + +1. `open("foo")` must fail when the directory holds `Foo`. The volume would + happily open it. +2. `Foo` and `foo` must be able to coexist. The volume cannot hold both. + +The first is answered by asking the volume for a name's stored spelling and +comparing bytes. The second needs one of the two names to be stored on disk +under a different spelling, and that is what the rest of this document is about. + +A volume that compares byte-exactly (one made by `--create-sysroot`, or any +volume that probes case-sensitive) needs neither, and nothing here applies to +it. + +## What the folding table actually does + +Choosing which names are safe to store as themselves requires knowing what the +volume considers equal. It is considerably more than upper versus lower case. +Measured on macOS 15, APFS: + +``` +ascii Foo / foo COLLIDE +french cafe NFC / NFD COLLIDE composed vs combining accent +german strasse / straße COLLIDE the sharp s folds onto "ss", so a + fold can change a name's length +greek σoς / σoσ COLLIDE final sigma folds onto medial, so a + fold can depend on position +ligature fib / fib COLLIDE compatibility mapping, not just NFD +ohm Ωa / Ωa (U+2126 / U+03A9) COLLIDE singleton normalization +korean 한 NFC / jamo NFD COLLIDE +deseret 𐐀y / 𐐨y COLLIDE case folding reaches beyond the BMP +chinese 文档 / 文件 distinct CJK has no case +turkish ıd / id distinct +cherokee Ꭰx / ᏸx distinct cased in Unicode 8, but not folded here +``` + +No rule short of full Unicode tables predicts that list, and elfuse carries no +Unicode tables and should not acquire any. + +## Which names are stored as themselves + +A name is stored under its own spelling when it is **fold-stable**: no uppercase +ASCII letter, no byte above `0x7F`, and not already shaped like an escape. +Everything else is escaped. + +This is deliberately conservative: a Chinese filename has no case and would +usually be safe as itself. The rule still has to be correct for reasons +stronger than the table above. What it leaves stored literally is +lowercase ASCII, and lowercase ASCII is a **fixed point of every transformation +the volume applies**: case folding does nothing to it, canonical decomposition +does nothing to it, and compatibility decomposition maps *into* ASCII but never +between two distinct ASCII strings. Two literally stored names therefore cannot +collide, whatever is in the folding table and however it changes in a future +macOS release. That is a proof rather than an enumeration, which is what makes +it safe to rely on. + +Escaping keys on the name alone and never on what the directory already holds. +That is what makes the on-disk name a pure function of the guest name, which in +turn is what lets two processes create colliding names in the same directory +without coordinating: they are writing different names. + +Looking a name up still tries its literal spelling first, because a sysroot +staged from a tarball is full of names like `Makefile`, `README` and +`Documentation/` that were written by something other than elfuse and keep their +real spelling. + +## The escape + +``` +.ef=524541444d45 is the guest name README +.ef=e69687e6a1a32e747874 文档.txt +``` + +An escaped name is the four-byte prefix `.ef=` followed by a payload encoding +the guest name's bytes. Decoding is a pure function of the name: no file, no +side table, nothing to keep in step. + +Every character of the prefix earns its place. The leading `.` keeps escaped +names out of a casual host-side listing. `ef` identifies what produced them. +Four characters is as short as that can be while staying recognizable, and +length matters because the prefix is charged against the same per-name budget as +the payload. `=` is the separator rather than `^` because host tooling matches +this prefix constantly and `^` is a regular-expression anchor, so +`grep -E '.ef\^'` silently matches nothing. + +A guest name that is itself escape-shaped is escaped too, so it can never be +confused with the encoding of another name: + +``` +guest name .ef=464f4f is stored as .ef=2e65663d343634663466 + and reads back as .ef=464f4f, not FOO +``` + +A name that is not a well-formed escape means itself. `.ef=464F4F` (uppercase +hex), `.ef=464f4` (odd length), `.ef=2f` (decodes to `/`) and `.ef=` (empty) are +all ordinary files. + +## Name length + +Both platforms limit a name, but they do not count the same thing, and that +mismatch is what makes the encoding possible. + +**Linux** limits a path component to 255 **bytes**, and bakes the limit into +`struct dirent`'s `d_name[256]`. No guest can hand over or receive a longer +name, so 255 bytes is a hard ceiling on the input. + +**APFS** limits a component to 255 **UTF-16 code units**. `pathconf` reports 255 +for both, which looks like a match and is not: + +``` +ascii U+0061 max 255 chars = 255 bytes = 255 utf16 units +latin-1 U+00E9 max 255 chars = 510 bytes = 255 utf16 units +BMP/CJK U+6587 max 255 chars = 765 bytes = 255 utf16 units +non-BMP U+1F680 max 127 chars = 508 bytes = 254 utf16 units +``` + +Every alphabet fails at 256 units regardless of byte count. So the host budget +in bytes swings by a factor of three with the alphabet, and an encoding that +spends units frugally can carry far more than an ASCII one. + +That gives the payload two tiers: + +| Guest name | Payload | Cost | +|---|---|---| +| up to 125 bytes | lowercase hex, 2 characters per byte | `4 + 2n` units | +| longer | 4096 CJK Unified Ideographs from U+4E00, 12 bits per character | `4 + 1 + ceil(2n/3)` units | + +Hex caps out at 125 bytes because `4 + 2 * 126` is 256, one unit over. The short +tier exists anyway because an escaped name is then readable by eye (`xxd -r -p` +decodes it), and almost every escaped name a person ever sees is a short one. + +The long tier carries the rest. Its first symbol holds the guest name's length, +so decoding knows exactly how many bytes the payload stands for; the remainder +packs three input bytes into two symbols. The largest name Linux can express +costs 175 of the 255 available units: + +``` +255-byte guest name -> .ef= + 171 symbols = 175 units, 517 bytes +``` + +There are 80 units to spare, and that margin is not an estimate: 255 bytes is +the largest input that can exist, so no name gets closer. Both tiers are held +to the limit by a `_Static_assert` in `src/syscall/casefold.h`, so widening the +prefix or raising the guest-name ceiling fails the build rather than producing +names the volume quietly refuses. + +The payload block matters. CJK Unified Ideographs have no case mappings and no +decompositions, so no two payloads can fold together. Neighboring blocks are +not interchangeable: CJK **Compatibility** Ideographs normalize (U+F900 +collides with U+8C48), Hangul syllables and dakuten kana decompose under NFD, +and Cherokee gained case in Unicode 8. + +### Whole paths + +Component length is solved; total path length is a separate budget, and here +macOS is the stricter of the two: + +``` +macOS PATH_MAX 1024 +Linux PATH_MAX 4096 +``` + +A guest may legitimately build a path more than three times longer than the host +can accept, and an escaped component is roughly twice the bytes of the name it +stands for, so a deep tree of escaped names reaches the host limit sooner. An +over-long host path reports `ENAMETOOLONG`; it is never truncated, because a +truncated path names a different file. + +## Reproducing the measurements + +Every table above is a measurement, not a specification, and can be re-run: + +```sh +make probe-volume-naming # against a temp directory +build/probe-volume-naming /Volumes/cs-image # against any other volume +``` + +The probe reports what a volume does, including behavior elfuse is immune to. +The facts the design actually depends on are asserted separately by +`make test-casefold-host`, which fails the build if a future macOS release +changes them: that the payload alphabet cannot fold, that everything the encoder +emits can be created, and that the per-name budget is counted in UTF-16 units. +That test also takes a directory, so it can be pointed at another volume. diff --git a/mk/config.mk b/mk/config.mk index 969a39fc..6bff81cd 100644 --- a/mk/config.mk +++ b/mk/config.mk @@ -21,7 +21,9 @@ endif # Exclude native macOS test files from cross-compilation NATIVE_TESTS := tests/test-multi-vcpu.c tests/test-rwx.c \ tests/test-tlbi-encoder-host.c \ - tests/test-fork-ipc-protocol-host.c + tests/test-fork-ipc-protocol-host.c \ + tests/test-casefold-host.c \ + tests/probe-volume-naming.c SPECIAL_TEST_SRCS := tests/test-lowbase-mem.c SPECIAL_TEST_BINS := $(BUILD_DIR)/test-lowbase-mem-200000 $(BUILD_DIR)/test-lowbase-mem-300000 diff --git a/mk/tests.mk b/mk/tests.mk index 0e9ab6eb..71eaac5b 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -17,7 +17,8 @@ test-proctitle-host test-proctitle-low-stack \ test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \ test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \ - test-linkat-symlink-fallback perf + test-linkat-symlink-fallback test-casefold-host \ + probe-volume-naming perf ## Build and run the assembly hello world test test-hello: $(ELFUSE_BIN) $(TEST_HELLO_DEP) @@ -80,7 +81,8 @@ check-sanitizer: $(ELFUSE_BIN) $(TEST_DEPS) \ $(BUILD_DIR)/test-fork-ipc-protocol-host \ $(BUILD_DIR)/test-vcpu-run-hooks-host \ $(BUILD_DIR)/test-identity-override-host \ - $(BUILD_DIR)/test-teardown-live-vcpu-host + $(BUILD_DIR)/test-teardown-live-vcpu-host \ + $(BUILD_DIR)/test-casefold-host @bash tests/driver.sh -e $(ELFUSE_BIN) -d $(TEST_DIR) -v -s '$(SANITIZER_SECTIONS)' @printf "\n$(BLUE)━━━ TLBI RVAE1IS encoder unit test ━━━$(RESET)\n" @$(BUILD_DIR)/test-tlbi-encoder-host @@ -92,6 +94,8 @@ check-sanitizer: $(ELFUSE_BIN) $(TEST_DEPS) \ @$(BUILD_DIR)/test-identity-override-host @printf "\n$(BLUE)━━━ teardown live-worker accounting unit test ━━━$(RESET)\n" @$(BUILD_DIR)/test-teardown-live-vcpu-host + @printf "\n$(BLUE)━━━ filename codec unit test ━━━$(RESET)\n" + @$(BUILD_DIR)/test-casefold-host ## Run the unit test suite plus busybox applet validation check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @@ -99,7 +103,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ $(BUILD_DIR)/test-fork-ipc-protocol-host \ $(BUILD_DIR)/test-vcpu-run-hooks-host \ $(BUILD_DIR)/test-identity-override-host \ - $(BUILD_DIR)/test-teardown-live-vcpu-host + $(BUILD_DIR)/test-teardown-live-vcpu-host \ + $(BUILD_DIR)/test-casefold-host @bash tests/driver.sh -e $(ELFUSE_BIN) -d $(TEST_DIR) -v @printf "\n$(BLUE)━━━ TLBI RVAE1IS encoder unit test ━━━$(RESET)\n" @$(BUILD_DIR)/test-tlbi-encoder-host @@ -111,6 +116,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(BUILD_DIR)/test-identity-override-host @printf "\n$(BLUE)━━━ teardown live-worker accounting unit test ━━━$(RESET)\n" @$(BUILD_DIR)/test-teardown-live-vcpu-host + @printf "\n$(BLUE)━━━ filename codec unit test ━━━$(RESET)\n" + @$(BUILD_DIR)/test-casefold-host @printf "\n$(BLUE)━━━ shebang parser unit test ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-shebang-host @printf "\n$(BLUE)━━━ proctitle argv-tail regression ━━━$(RESET)\n" @@ -812,6 +819,18 @@ test-vcpu-run-hooks-host: $(BUILD_DIR)/test-vcpu-run-hooks-host test-proctitle-host: $(BUILD_DIR)/test-proctitle-host $(BUILD_DIR)/test-proctitle-host +# Filename codec unit test. The binary takes a directory, so the same test can +# be pointed at another volume: +# build/test-casefold-host /Volumes/case-sensitive-image +## Run the filename codec unit tests against a scratch directory +test-casefold-host: $(BUILD_DIR)/test-casefold-host + $(BUILD_DIR)/test-casefold-host + +# Volume naming probe +## Report how the filesystem treats filenames (regenerates docs/filenames.md tables) +probe-volume-naming: $(BUILD_DIR)/probe-volume-naming + $(BUILD_DIR)/probe-volume-naming + # Shebang parser unit test ## Run shebang parsing unit tests test-shebang-host: $(BUILD_DIR)/test-shebang-host diff --git a/src/syscall/casefold.c b/src/syscall/casefold.c new file mode 100644 index 00000000..93be0874 --- /dev/null +++ b/src/syscall/casefold.c @@ -0,0 +1,396 @@ +/* + * Case-folding filename representation + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The encoding itself: which guest names have to be escaped, what their escaped + * spelling is, and how to read one back. Deliberately free of project + * dependencies so it links on its own; casefold.h states the contract. + */ + +#include +#include +#include +#include +#include + +#include "syscall/casefold.h" + +/* Payload alphabet for the long tier: one symbol per CASEFOLD_SYM_BITS value, + * drawn from the CJK Unified Ideographs starting at U+4E00. The block is + * chosen because these code points have no case mappings and no canonical or + * compatibility decompositions, so no two payloads can fold onto each other + * however aggressive the volume's matching is. Neighboring blocks are not + * interchangeable: CJK Compatibility Ideographs (U+F900) do normalize, Hangul + * syllables and dakuten kana decompose, and Cherokee gained case in Unicode 8. + */ +#define CASEFOLD_SYM_BASE 0x4E00u +#define CASEFOLD_SYM_COUNT (1u << CASEFOLD_SYM_BITS) + +/* The alphabet must stay inside the ideograph block's uniform run; growing + * CASEFOLD_SYM_BITS past it would spill symbols into code points with the + * folding behavior the comment above excludes. + */ +_Static_assert(CASEFOLD_SYM_BASE + CASEFOLD_SYM_COUNT <= 0x9FFFu + 1u, + "payload alphabet exceeds the CJK Unified Ideographs block"); + +static bool is_hex_digit(char c) +{ + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); +} + +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') + return false; + + for (const unsigned char *p = (const unsigned char *) name; *p; p++) { + if (*p >= 0x80 || (*p >= 'A' && *p <= 'Z')) + return true; + } + + /* A name that already looks like an escape must be escaped too, or reading + * it back would yield whatever it decodes to instead of itself. + */ + return casefold_is_escaped(name); +} + +/* Read one payload symbol at @p. Returns its value, or -1 when @p does not + * begin a three-byte sequence inside the payload block. + */ +static int symbol_read(const unsigned char *p) +{ + uint32_t cp; + + if ((p[0] & 0xF0u) != 0xE0u || (p[1] & 0xC0u) != 0x80u || + (p[2] & 0xC0u) != 0x80u) + return -1; + cp = ((uint32_t) (p[0] & 0x0Fu) << 12) | ((uint32_t) (p[1] & 0x3Fu) << 6) | + (uint32_t) (p[2] & 0x3Fu); + if (cp < CASEFOLD_SYM_BASE || cp >= CASEFOLD_SYM_BASE + CASEFOLD_SYM_COUNT) + return -1; + return (int) (cp - CASEFOLD_SYM_BASE); +} + +static void symbol_write(unsigned char *p, unsigned value) +{ + uint32_t cp = CASEFOLD_SYM_BASE + value; + + p[0] = (unsigned char) (0xE0u | (cp >> 12)); + p[1] = (unsigned char) (0x80u | ((cp >> 6) & 0x3Fu)); + p[2] = (unsigned char) (0x80u | (cp & 0x3Fu)); +} + +/* One bit of @bytes, MSB first, or 0 past the end. */ +static unsigned bit_at(const unsigned char *bytes, size_t len, size_t pos) +{ + if (pos / 8 >= len) + return 0; + return (bytes[pos / 8] >> (7 - pos % 8)) & 1u; +} + +/* Decode an escaped host name. Returns the decoded length in bytes, or -1 when + * @host is not a well-formed escape. @out must hold CASEFOLD_GUEST_NAME_MAX + * bytes; the result is not NUL-terminated here because a decoded name may not + * be a legal component, which the caller checks. + */ +static int decode_payload(const char *host, unsigned char *out) +{ + const unsigned char *tail; + + if (strncmp(host, CASEFOLD_PREFIX, CASEFOLD_PREFIX_LEN)) + return -1; + tail = (const unsigned char *) host + CASEFOLD_PREFIX_LEN; + if (*tail == '\0') + return -1; + + if (is_hex_digit((char) *tail)) { + size_t len = strlen((const char *) tail); + size_t n = len / 2; + + /* Lowercase only, and even length, so each name has exactly one hex + * spelling. A name long enough for the symbol tier never appears here, + * which is the other half of that uniqueness. + */ + if (len % 2 != 0 || n == 0 || n > CASEFOLD_HEX_MAX) + return -1; + for (size_t i = 0; i < len; i++) { + if (!is_hex_digit((char) tail[i])) + return -1; + } + for (size_t i = 0; i < n; i++) { + out[i] = (unsigned char) ((hex_value((char) tail[i * 2]) << 4) | + hex_value((char) tail[i * 2 + 1])); + } + return (int) n; + } + + /* Symbol tier: the first symbol carries the guest length, so the decode + * knows exactly how many bytes the payload stands for and an even symbol + * count is not ambiguous between two lengths. + */ + uint16_t sym[CASEFOLD_SYMBOLS(CASEFOLD_GUEST_NAME_MAX)]; + size_t nsym = 0; + const unsigned char *p = tail; + + while (*p) { + int v; + if (nsym >= sizeof(sym) / sizeof(sym[0])) + return -1; + if (!p[1] || !p[2]) + return -1; + v = symbol_read(p); + if (v < 0) + return -1; + sym[nsym++] = (uint16_t) v; + p += 3; + } + + size_t n = sym[0]; + if (n <= CASEFOLD_HEX_MAX || n > CASEFOLD_GUEST_NAME_MAX) + return -1; + if (nsym != CASEFOLD_SYMBOLS(n)) + return -1; + + /* Drain the symbols into bytes, most significant bit first, mirroring the + * order casefold_escape packs them in. Iterating over symbols rather than + * over bit positions keeps the read bounded by nsym by construction. + */ + size_t produced = 0; + uint32_t acc = 0; + unsigned acc_bits = 0; + + for (size_t i = 1; i < nsym; i++) { + acc = (acc << CASEFOLD_SYM_BITS) | sym[i]; + acc_bits += CASEFOLD_SYM_BITS; + while (acc_bits >= 8 && produced < n) { + acc_bits -= 8; + out[produced++] = (unsigned char) ((acc >> acc_bits) & 0xFFu); + } + acc &= (1u << acc_bits) - 1u; + } + if (produced != n) + return -1; + /* Padding past the last byte must be zero, or one name would have several + * spellings and a listing could show the same file twice. + */ + if (acc != 0) + return -1; + return (int) n; +} + +/* A decoded payload only counts as an escape when it names something a + * directory could actually hold. Otherwise the host name means itself. + */ +static bool decoded_is_legal(const unsigned char *name, size_t len) +{ + if (len == 0 || len > CASEFOLD_GUEST_NAME_MAX) + return false; + for (size_t i = 0; i < len; i++) { + if (name[i] == '/' || name[i] == '\0') + return false; + } + if (len == 1 && name[0] == '.') + return false; + if (len == 2 && name[0] == '.' && name[1] == '.') + return false; + return true; +} + +bool casefold_is_escaped(const char *host) +{ + unsigned char buf[CASEFOLD_GUEST_NAME_MAX]; + int len; + + if (!host) + return false; + len = decode_payload(host, buf); + return len > 0 && decoded_is_legal(buf, (size_t) len); +} + +int casefold_escape(const char *guest, char *out, size_t outsz) +{ + static const char hex[] = "0123456789abcdef"; + const unsigned char *g = (const unsigned char *) guest; + size_t n; + + if (!guest || !out) { + errno = EINVAL; + return -1; + } + n = strlen(guest); + if (n == 0 || n > CASEFOLD_GUEST_NAME_MAX || memchr(guest, '/', n)) { + errno = EINVAL; + return -1; + } + /* "." and ".." navigate rather than name an entry, so no directory can hold + * one and an escape standing for one could never be created. Refusing here + * keeps the codec total: everything it accepts has a real slot to live in. + */ + if (!strcmp(guest, ".") || !strcmp(guest, "..")) { + errno = EINVAL; + return -1; + } + + if (n <= CASEFOLD_HEX_MAX) { + size_t need = CASEFOLD_PREFIX_LEN + 2 * n; + + if (need + 1 > outsz) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(out, CASEFOLD_PREFIX, CASEFOLD_PREFIX_LEN); + for (size_t i = 0; i < n; i++) { + out[CASEFOLD_PREFIX_LEN + i * 2] = hex[g[i] >> 4]; + out[CASEFOLD_PREFIX_LEN + i * 2 + 1] = hex[g[i] & 0x0Fu]; + } + out[need] = '\0'; + return 0; + } + + size_t nsym = CASEFOLD_SYMBOLS(n); + size_t need = CASEFOLD_PREFIX_LEN + 3 * nsym; + + if (need + 1 > outsz) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(out, CASEFOLD_PREFIX, CASEFOLD_PREFIX_LEN); + unsigned char *w = (unsigned char *) out + CASEFOLD_PREFIX_LEN; + symbol_write(w, (unsigned) n); + w += 3; + for (size_t i = 1; i < nsym; i++) { + unsigned value = 0; + for (unsigned k = 0; k < CASEFOLD_SYM_BITS; k++) { + value = + (value << 1) | bit_at(g, n, (i - 1) * CASEFOLD_SYM_BITS + k); + } + symbol_write(w, value); + w += 3; + } + out[need] = '\0'; + return 0; +} + +int casefold_to_guest(const char *host, char *out, size_t outsz) +{ + unsigned char buf[CASEFOLD_GUEST_NAME_MAX]; + int len; + size_t plain; + + if (!host || !out || outsz == 0) { + errno = EINVAL; + return -1; + } + + len = decode_payload(host, buf); + if (len > 0 && decoded_is_legal(buf, (size_t) len)) { + if ((size_t) len + 1 > outsz) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(out, buf, (size_t) len); + out[len] = '\0'; + return 0; + } + + plain = strlen(host); + if (plain + 1 > outsz) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(out, host, plain + 1); + return 0; +} diff --git a/src/syscall/casefold.h b/src/syscall/casefold.h new file mode 100644 index 00000000..e5d07a63 --- /dev/null +++ b/src/syscall/casefold.h @@ -0,0 +1,118 @@ +/* + * Case-folding filename representation + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A Linux guest names files by exact bytes; the default APFS volume matches + * names case- and normalization-blind, so "Foo" and "foo" cannot coexist there. + * A name that cannot be stored under its own spelling is stored escaped, and + * the escape is a pure function of the name: no side table is needed to reverse + * it, which is what keeps the whole mechanism lock-free and stateless. + * + * This header is the codec alone: no syscalls, no project dependencies, so it + * links into a host-side unit test on its own. Resolving a path through the + * escape lives in casefold-walk.c. See docs/filenames.md for the model. + */ + +#pragma once + +#include +#include + +/* Marks an escaped name. Four ASCII bytes, charged against the same per-name + * budget as the payload, so it stays short. The leading '.' keeps escaped names + * out of a casual host-side listing. The separator is '=' and not '^' because + * host-side tooling matches this prefix constantly and '^' is an anchor in both + * basic and extended regular expressions, so grep -E '.ef\^' matches nothing + * while reading as though it should. + */ +#define CASEFOLD_PREFIX ".ef=" +#define CASEFOLD_PREFIX_LEN (sizeof(CASEFOLD_PREFIX) - 1) + +/* Linux caps a path component at 255 bytes and bakes that into d_name[256], so + * no guest can hand over or receive a longer name. + */ +#define CASEFOLD_GUEST_NAME_MAX 255 + +/* APFS caps a component at 255 UTF-16 code units, not bytes. An ASCII payload + * therefore spends one unit per character while a BMP payload spends one unit + * per character but carries 12 bits, which is what lets the long tier hold a + * full-length guest name. See docs/filenames.md. + */ +#define CASEFOLD_UNIT_MAX 255 + +/* Longest guest name the hex tier can hold: CASEFOLD_PREFIX_LEN + 2n <= 255. */ +#define CASEFOLD_HEX_MAX ((CASEFOLD_UNIT_MAX - CASEFOLD_PREFIX_LEN) / 2) + +/* Bits each long-tier payload symbol carries: twelve packs three input bytes + * into two symbols, so a 255-byte name spends 170 payload symbols and stays + * far inside the 255-unit budget. The alphabet size and the symbol-count + * formula both derive from this one constant, so changing the packing moves + * every consumer together or trips the asserts below. + */ +#define CASEFOLD_SYM_BITS 12u + +/* Symbols the long tier spends on a guest name of n bytes: one carrying the + * length, then one per CASEFOLD_SYM_BITS of payload. + */ +#define CASEFOLD_SYMBOLS(n) \ + (1u + (8u * (n) + CASEFOLD_SYM_BITS - 1u) / CASEFOLD_SYM_BITS) + +/* Longest host spelling any escaped name can take, in bytes. Each symbol is a + * BMP code point, so three UTF-8 bytes. Buffers holding a host component must + * be sized by this and not by NAME_MAX, which is a guest-side limit. + */ +#define CASEFOLD_HOST_NAME_MAX \ + (CASEFOLD_PREFIX_LEN + 3u * CASEFOLD_SYMBOLS(CASEFOLD_GUEST_NAME_MAX)) + +/* Every escape has to fit the volume's per-name limit, and both tiers spend + * exactly one unit per output character: the hex tier emits ASCII digits, the + * long tier BMP code points. The margin is wide today, so the arithmetic is + * easy to disturb without noticing: raising CASEFOLD_GUEST_NAME_MAX, + * widening CASEFOLD_PREFIX or shrinking CASEFOLD_SYM_BITS would each push the + * long tier over, and the volume would then refuse names the guest is entitled + * to create. Fail the build instead. + */ +_Static_assert(CASEFOLD_PREFIX_LEN + 2 * CASEFOLD_HEX_MAX <= CASEFOLD_UNIT_MAX, + "hex-tier escape exceeds the per-name UTF-16 unit limit"); +_Static_assert(CASEFOLD_PREFIX_LEN + + CASEFOLD_SYMBOLS(CASEFOLD_GUEST_NAME_MAX) <= + 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 + * name. What is left (lowercase ASCII) is a fixed point of every transformation + * the volume applies, so two such names never collide. + */ +bool casefold_needs_escape(const char *name); + +/* True when @host is a well-formed escape: the prefix, then either an even run + * of lowercase hex or a run of payload symbols, decoding to a legal component. + * Total predicate: anything else is an ordinary name that means itself. + */ +bool casefold_is_escaped(const char *host); + +/* Write the escaped spelling of @guest into @out. + * + * Returns 0, or -1 with errno set: EINVAL for an empty or over-long guest name + * or one containing '/', ENAMETOOLONG when @out cannot hold the result. Every + * name Linux can express has an escaped spelling, so ENAMETOOLONG here means + * the caller's buffer is too small, never that the name cannot be represented. + */ +int casefold_escape(const char *guest, char *out, size_t outsz); + +/* Write the guest spelling of the on-disk name @host into @out: the decoded + * name when @host is escaped, otherwise @host unchanged. Pure and total over + * real names; the failures are ENAMETOOLONG when @out is too small and EINVAL + * for a missing argument. + */ +int casefold_to_guest(const char *host, char *out, size_t outsz); diff --git a/tests/casefold-vectors.h b/tests/casefold-vectors.h new file mode 100644 index 00000000..ef4238fe --- /dev/null +++ b/tests/casefold-vectors.h @@ -0,0 +1,129 @@ +/* + * Frozen on-disk spellings for the casefold escape + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Each row pairs a guest filename with the exact bytes the codec stores it + * under. The values are the on-disk format: every sysroot ever written holds + * names spelled this way, so a row may change only together with a deliberate + * format migration, never to make a test pass. The codec's own tests all read + * through the codec and therefore stay green across any self-consistent format + * change; only a comparison against these frozen literals fails when the + * format moves. test-casefold-host.c asserts every row in both directions, + * and the mk/tests.mk corpus recipe stages a subset host-side for the guest + * corpus test to read back. + */ + +#pragma once + +/* Doubling blocks compose the repeated-payload rows as compile-time literals, + * so even the full-length spellings are frozen bytes rather than something a + * loop derives from the codec under test. + */ +#define CFV_X2 "XX" +#define CFV_X4 CFV_X2 CFV_X2 +#define CFV_X8 CFV_X4 CFV_X4 +#define CFV_X16 CFV_X8 CFV_X8 +#define CFV_X32 CFV_X16 CFV_X16 +#define CFV_X64 CFV_X32 CFV_X32 +#define CFV_X125 CFV_X64 CFV_X32 CFV_X16 CFV_X8 CFV_X4 "X" +#define CFV_X126 CFV_X64 CFV_X32 CFV_X16 CFV_X8 CFV_X4 CFV_X2 +#define CFV_X255 \ + CFV_X64 CFV_X64 CFV_X64 CFV_X32 CFV_X16 CFV_X8 CFV_X4 CFV_X2 "X" + +#define CFV_B2 "bb" +#define CFV_B4 CFV_B2 CFV_B2 +#define CFV_B8 CFV_B4 CFV_B4 +#define CFV_B16 CFV_B8 CFV_B8 +#define CFV_B32 CFV_B16 CFV_B16 +#define CFV_B64 CFV_B32 CFV_B32 +#define CFV_B125 CFV_B64 CFV_B32 CFV_B16 CFV_B8 CFV_B4 "b" + +/* "58" is hex for 'X'; 125 repetitions spell the hex-tier maximum. */ +#define CFV_H2 "5858" +#define CFV_H4 CFV_H2 CFV_H2 +#define CFV_H8 CFV_H4 CFV_H4 +#define CFV_H16 CFV_H8 CFV_H8 +#define CFV_H32 CFV_H16 CFV_H16 +#define CFV_H64 CFV_H32 CFV_H32 +#define CFV_H125 CFV_H64 CFV_H32 CFV_H16 CFV_H8 CFV_H4 "58" + +/* Long-tier symbols, one UTF-8 literal per code point. The tier packs the + * name MSB-first into 12-bit groups and adds U+4E00 to each; a leading symbol + * carries the byte length. 'X' = 0x58 repeated gives the periodic groups + * 0x585/0x858 -> U+5385/U+5658, so one two-symbol pair covers three payload + * bytes: 126 bytes = 1008 bits = exactly 42 pairs, 255 bytes = 2040 bits = + * exactly 85 pairs. Length symbols: 126 -> U+4E7E, 255 -> U+4EFF. + */ +#define CFV_LEN126 "\xe4\xb9\xbe" /* U+4E7E */ +#define CFV_LEN255 "\xe4\xbb\xbf" /* U+4EFF */ +#define CFV_PAIR "\xe5\x8e\x85\xe5\x99\x98" /* U+5385 U+5658 */ +#define CFV_PAIR2 CFV_PAIR CFV_PAIR +#define CFV_PAIR4 CFV_PAIR2 CFV_PAIR2 +#define CFV_PAIR8 CFV_PAIR4 CFV_PAIR4 +#define CFV_PAIR16 CFV_PAIR8 CFV_PAIR8 +#define CFV_PAIR32 CFV_PAIR16 CFV_PAIR16 +#define CFV_PAIR64 CFV_PAIR32 CFV_PAIR32 +#define CFV_PAIR42 CFV_PAIR32 CFV_PAIR8 CFV_PAIR2 +#define CFV_PAIR85 CFV_PAIR64 CFV_PAIR16 CFV_PAIR4 CFV_PAIR + +/* The one non-periodic long-tier row, 'A' then 125 x 'b': groups 0x416, then + * 0x262/0x626 alternating, 84 symbols in all: 0x416 once, then 41 pairs of + * (0x262, 0x626), then a final 0x262. U+5216 / U+5062 / U+5426. + */ +#define CFV_AB_HEAD "\xe5\x88\x96" /* U+5216 */ +#define CFV_AB_PAIR "\xe5\x81\xa2\xe5\x90\xa6" /* U+5062 U+5426 */ +#define CFV_AB_PAIR2 CFV_AB_PAIR CFV_AB_PAIR +#define CFV_AB_PAIR4 CFV_AB_PAIR2 CFV_AB_PAIR2 +#define CFV_AB_PAIR8 CFV_AB_PAIR4 CFV_AB_PAIR4 +#define CFV_AB_PAIR16 CFV_AB_PAIR8 CFV_AB_PAIR8 +#define CFV_AB_PAIR32 CFV_AB_PAIR16 CFV_AB_PAIR16 +#define CFV_AB_PAIR41 CFV_AB_PAIR32 CFV_AB_PAIR8 CFV_AB_PAIR +#define CFV_AB_TAIL "\xe5\x81\xa2" /* U+5062 */ + +/* A row whose @host equals its @guest is stored literally; any other row is + * escaped, and the test asserts the escape byte-exact in both directions. + */ +struct casefold_vector { + const char *label; + const char *guest; + const char *host; +}; + +static const struct casefold_vector casefold_vectors[] = { + {"lowercase ascii", "config.json", "config.json"}, + {"old index name", ".elfuse_case_index", ".elfuse_case_index"}, + {"uppercase first", "Foo", ".ef=466f6f"}, + {"all uppercase", "README", ".ef=524541444d45"}, + {"cjk with suffix", "\xe6\x96\x87\xe6\xa1\xa3.txt", + ".ef=e69687e6a1a32e747874"}, + {"escape-shaped", ".ef=464f4f", ".ef=2e65663d343634663466"}, + {"nfc accent", "caf\xc3\xa9", ".ef=636166c3a9"}, + {"nfd accent", "cafe\xcc\x81", ".ef=63616665cc81"}, + {"eszett", + "stra\xc3\x9f" + "e", + ".ef=73747261c39f65"}, + {"eszett fold target", "strasse", "strasse"}, + {"final sigma", "\xcf\x83o\xcf\x82", ".ef=cf836fcf82"}, + {"deseret", "\xf0\x90\x90\x80y", ".ef=f090908079"}, + {"invalid utf-8", "bad\xff", ".ef=626164ff"}, + {"hex-tier max", CFV_X125, ".ef=" CFV_H125}, + {"long-tier min", CFV_X126, ".ef=" CFV_LEN126 CFV_PAIR42}, + {"linux name max", CFV_X255, ".ef=" CFV_LEN255 CFV_PAIR85}, + {"long-tier mixed", "A" CFV_B125, + ".ef=" CFV_LEN126 CFV_AB_HEAD CFV_AB_PAIR41 CFV_AB_TAIL}, +}; + +/* The frozen lengths follow from the packing arithmetic above; a literal that + * stops matching them was mis-composed, not a format change. + */ +_Static_assert(sizeof(CFV_X125) - 1 == 125, "hex-tier guest length"); +_Static_assert(sizeof(CFV_X126) - 1 == 126, "long-tier guest length"); +_Static_assert(sizeof(CFV_X255) - 1 == 255, "max guest length"); +_Static_assert(sizeof(".ef=" CFV_H125) - 1 == 254, "hex-tier host length"); +_Static_assert(sizeof(".ef=" CFV_LEN126 CFV_PAIR42) - 1 == 259, + "long-tier host length"); +_Static_assert(sizeof(".ef=" CFV_LEN255 CFV_PAIR85) - 1 == 517, + "max host length"); diff --git a/tests/host-test-util.h b/tests/host-test-util.h new file mode 100644 index 00000000..eb107dd7 --- /dev/null +++ b/tests/host-test-util.h @@ -0,0 +1,112 @@ +/* + * Shared utilities for the native host test binaries + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The filename tests that run as native macOS binaries (rather than as guest + * programs under elfuse) need to build UTF-8 by hand, ask the volume what + * spelling it actually stored, and clear a scratch tree afterwards. This is the + * host-side counterpart of test-util.h, which serves the guest tests and is + * built for a different target. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +/* Encode one code point as UTF-8 into @o, which needs four bytes of room. + * Returns the number written. Hand-rolled because the tests build names from + * code points chosen for their folding behavior, and iconv would drag a locale + * into an assertion about bytes. + */ +static inline int utf8_put(char *o, unsigned cp) +{ + if (cp < 0x80) { + o[0] = (char) cp; + return 1; + } + if (cp < 0x800) { + o[0] = (char) (0xC0 | cp >> 6); + o[1] = (char) (0x80 | (cp & 63)); + return 2; + } + if (cp < 0x10000) { + o[0] = (char) (0xE0 | cp >> 12); + o[1] = (char) (0x80 | ((cp >> 6) & 63)); + o[2] = (char) (0x80 | (cp & 63)); + return 3; + } + o[0] = (char) (0xF0 | cp >> 18); + o[1] = (char) (0x80 | ((cp >> 12) & 63)); + o[2] = (char) (0x80 | ((cp >> 6) & 63)); + o[3] = (char) (0x80 | (cp & 63)); + return 4; +} + +/* The spelling @dir/@name is stored under, or NULL if the volume will not say. + * + * This is the primitive the whole scheme rests on: stat(2) reports success for + * a spelling that is not what is stored, so only asking for the name back can + * tell "exists as spelled" from "exists under a spelling that folded onto it". + * FSOPT_NOFOLLOW because a symlink's own name is the question, not its + * target's. Returns a pointer to static storage, valid until the next call. + */ +static inline const char *disk_name(const char *dir, const char *name) +{ + static char out[1024]; + char path[8192]; + struct attrlist al = { + .bitmapcount = ATTR_BIT_MAP_COUNT, + .commonattr = ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_NAME, + }; + struct { + u_int32_t length; + attribute_set_t returned; + attrreference_t name_ref; + char name[1024]; + } __attribute__((aligned(4), packed)) buf; + + if (snprintf(path, sizeof(path), "%s/%s", dir, name) >= (int) sizeof(path)) + return NULL; + if (getattrlistat(AT_FDCWD, path, &al, &buf, sizeof(buf), FSOPT_NOFOLLOW) < + 0) + return NULL; + if (!(buf.returned.commonattr & ATTR_CMN_NAME)) + return NULL; + snprintf(out, sizeof(out), "%s", + (const char *) &buf.name_ref + buf.name_ref.attr_dataoffset); + return out; +} + +static inline int remove_entry(const char *path, + const struct stat *st, + int flag, + struct FTW *ftw) +{ + (void) st; + (void) flag; + (void) ftw; + return remove(path); +} + +/* Clear a scratch tree. nftw(3) rather than system("rm -rf"), which + * .ci/check-security.sh rejects and which would build a shell command from a + * path the test composed. + */ +static inline void remove_tree(const char *path) +{ + /* Depth-first and physical, so the walk removes children before their + * parent and never follows a symlink fixture out of the scratch tree. + */ + if (nftw(path, remove_entry, 16, FTW_DEPTH | FTW_PHYS) < 0) + fprintf(stderr, "warning: could not remove %s: %s\n", path, + strerror(errno)); +} diff --git a/tests/probe-volume-naming.c b/tests/probe-volume-naming.c new file mode 100644 index 00000000..f2f644f5 --- /dev/null +++ b/tests/probe-volume-naming.c @@ -0,0 +1,388 @@ +/* + * Report how a volume treats filenames + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Every table in docs/filenames.md is a measurement of what the filesystem + * underneath actually does, and this program is what produces them. It is + * informational, not an assertion: it reports everything, including behavior + * elfuse is deliberately immune to, because that is what someone investigating + * a surprise wants to see. The assertions live in tests/test-casefold-host.c, + * which pins only the facts the design depends on. + * + * make probe-volume-naming # a temp dir + * build/probe-volume-naming /Volumes/cs-image # any other volume + * + * Native macOS binary; no HVF entitlement needed. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "host-test-util.h" + +static const char *root; + +static int mk(const char *dir, const char *name) +{ + char p[9000]; + int fd; + + if (snprintf(p, sizeof(p), "%s/%s", dir, name) >= (int) sizeof(p)) + return -ENAMETOOLONG; + fd = open(p, O_CREAT | O_EXCL | O_WRONLY, 0644); + if (fd < 0) + return -errno; + close(fd); + return 0; +} + +static const char *verdict(const char *dir, const char *name) +{ + const char *d = disk_name(dir, name); + const char *leaf = strrchr(name, '/'); + + if (!d) + return errno == ENOENT || errno == ENOTDIR ? "ABSENT" : "ERROR"; + /* The probe reports the leaf's stored spelling, so compare against the + * leaf of what was asked for and not against the whole relative path. + */ + return !strcmp(d, leaf ? leaf + 1 : name) ? "EXACT" : "FOLDED"; +} + +/* ------------------------------------------------------- 1. byte-exactness */ + +static void section_exactness(void) +{ + char dir[4096], p[8192], real[PATH_MAX]; + int fd; + + puts("1. case preservation and byte-exactness"); + snprintf(dir, sizeof(dir), "%s/exact", root); + mkdir(dir, 0755); + snprintf(p, sizeof(p), "%s/Alpha", dir); + mkdir(p, 0755); + snprintf(p, sizeof(p), "%s/Alpha/Beta.txt", dir); + fd = open(p, O_CREAT | O_WRONLY, 0644); + if (fd >= 0) + close(fd); + + snprintf(p, sizeof(p), "%s/alpha/beta.TXT", dir); + if (realpath(p, real)) { + const char *b = strstr(real, "/exact/"); + printf(" realpath of a wrong-case spelling %s\n", b ? b : real); + } else { + printf(" realpath of a wrong-case spelling %s\n", strerror(errno)); + } + fd = open(p, O_RDONLY); + if (fd >= 0) { + char gp[PATH_MAX]; + if (fcntl(fd, F_GETPATH, gp) == 0) { + const char *b = strstr(gp, "/exact/"); + printf(" F_GETPATH of the same open %s\n", b ? b : gp); + } + close(fd); + } else { + printf(" open of a wrong-case spelling %s\n", strerror(errno)); + } + + snprintf(p, sizeof(p), "%s/Alpha/beta.txt", dir); + fd = open(p, O_CREAT | O_EXCL | O_WRONLY, 0644); + printf(" sibling differing only by case %s\n", + fd >= 0 ? "created (volume is case-sensitive)" : strerror(errno)); + if (fd >= 0) + close(fd); + + int bad = mk(dir, + "bad\xff\xfe" + "name"); + printf(" name that is not valid UTF-8 %s\n", + bad == 0 ? "created" : strerror(-bad)); + putchar('\n'); +} + +/* ------------------------------------------------------- 2. probe verdicts */ + +static void section_verdicts(void) +{ + char dir[4096], p[8192]; + int fd; + + puts("2. byte-exact probe verdicts (what the resolver sees)"); + snprintf(dir, sizeof(dir), "%s/walk", root); + mkdir(dir, 0755); + snprintf(p, sizeof(p), "%s/usr", dir); + mkdir(p, 0755); + snprintf(p, sizeof(p), "%s/usr/lib", dir); + mkdir(p, 0755); + snprintf(p, sizeof(p), "%s/usr/lib/Libc.So", dir); + fd = open(p, O_CREAT | O_WRONLY, 0644); + if (fd >= 0) + close(fd); + snprintf(p, sizeof(p), "%s/lib", dir); + if (symlink("usr/lib", p) < 0 && errno != EEXIST) + printf(" (symlink fixture failed: %s)\n", strerror(errno)); + + printf(" %-34s %s\n", "lib (a symlink)", verdict(dir, "lib")); + printf(" %-34s %s\n", "lib/Libc.So through the symlink", + verdict(dir, "lib/Libc.So")); + printf(" %-34s %s\n", "lib/libc.so wrong-case leaf", + verdict(dir, "lib/libc.so")); + printf(" %-34s %s <- a full-path probe validates\n", + "LIB/Libc.So wrong-case parent", verdict(dir, "LIB/Libc.So")); + printf(" %-34s %s only the LAST component, which is why\n", "", ""); + printf(" %-34s %s resolution probes every prefix\n", "", ""); + printf(" %-34s %s\n", "lib/Nope absent", verdict(dir, "lib/Nope")); + putchar('\n'); +} + +/* --------------------------------------------------------- 3. folding table */ + +static void pair(const char *dir, + const char *label, + const char *a, + const char *b) +{ + int ra = mk(dir, a); + int rb = mk(dir, b); + const char *da = disk_name(dir, a); + + printf(" %-34s %-9s stored as %s\n", label, + rb == -EEXIST ? "COLLIDE" : (rb == 0 ? "distinct" : strerror(-rb)), + da ? da : (ra == 0 ? "?" : strerror(-ra))); +} + +static void section_folding(void) +{ + char dir[4096]; + + puts("3. what the folding table does"); + snprintf(dir, sizeof(dir), "%s/fold", root); + mkdir(dir, 0755); + + pair(dir, "ascii Foo / foo", "Foo", "foo"); + pair(dir, "french cafe NFC / NFD", "caf\xc3\xa9", "cafe\xcc\x81"); + pair(dir, "french Ete / ete", "\xc3\x89t\xc3\xa9", "\xc3\xa9t\xc3\xa9"); + pair(dir, "german uber NFC / NFD", + "\xc3\xbc" + "ber", + "u\xcc\x88" + "ber"); + pair(dir, "german strasse / sharp-s", "strasse", + "stra\xc3\x9f" + "e"); + pair(dir, "german MASS / Mass", "MASS", "Mass"); + pair(dir, "turkish dotless i / ascii i", + "\xc4\xb1" + "d", + "id"); + pair(dir, "turkish I-dot / ascii i", + "\xc4\xb0" + "z", + "iz"); + pair(dir, "greek final / medial sigma", "\xcf\x83o\xcf\x82", + "\xcf\x83o\xcf\x83"); + pair(dir, "greek SIGMA / sigma", "\xce\xa3q\xcf\x82", "\xcf\x83q\xcf\x82"); + pair(dir, "cyrillic DA / da", "\xd0\x94\xd0\x90", "\xd0\xb4\xd0\xb0"); + pair(dir, "chinese doc / file", "\xe6\x96\x87\xe6\xa1\xa3.txt", + "\xe6\x96\x87\xe4\xbb\xb6.txt"); + pair(dir, "japanese ga NFC / NFD", "\xe3\x81\x8c", + "\xe3\x81\x8b\xe3\x82\x99"); + pair(dir, "korean han NFC / jamo", "\xed\x95\x9c", + "\xe1\x84\x92\xe1\x85\xa1\xe1\x86\xab"); + pair(dir, "ohm U+2126 / omega U+03A9", + "\xe2\x84\xa6" + "a", + "\xce\xa9" + "a"); + pair(dir, "angstrom U+212B / A-ring", + "\xe2\x84\xab" + "c", + "\xc3\x85" + "c"); + pair(dir, "ligature fi U+FB01 / 'fi'", + "\xef\xac\x81" + "b", + "fib"); + pair(dir, "vietnamese NFC / NFD", + "\xe1\xbb\x87" + "d", + "e\xcc\xa3\xcc\x82" + "d"); + pair(dir, "devanagari NFC / NFD", + "\xe0\xa4\xa9" + "e", + "\xe0\xa4\xa8\xe0\xa4\xbc" + "e"); + pair(dir, "hebrew U+FB2E / NFD", + "\xef\xac\xae" + "f", + "\xd7\x90\xd6\xb7" + "f"); + pair(dir, "deseret U+10400 / U+10428", + "\xf0\x90\x90\x80" + "y", + "\xf0\x90\x90\xa8" + "y"); + pair(dir, "cherokee U+13A0 / U+13F8", + "\xe1\x8e\xa0" + "x", + "\xe1\x8f\xb8" + "x"); + pair(dir, "emoji rocket / star", "\xf0\x9f\x9a\x80", "\xe2\xad\x90"); + pair(dir, "CJK unified U+4E00 / U+4E01", + "\xe4\xb8\x80" + "p", + "\xe4\xb8\x81" + "p"); + pair(dir, "CJK compat U+F900 / U+8C48", + "\xef\xa4\x80" + "g", + "\xe8\xb1\x88" + "g"); + putchar('\n'); +} + +/* ---------------------------------------------------- 4. name length limits */ + +static void sweep(const char *dir, + const char *label, + unsigned cp, + int units_per, + int bytes_per) +{ + int lo = 0; + + for (int n = 1; n <= 300; n++) { + char name[4096]; + int len = 0; + for (int i = 0; i < n; i++) + len += utf8_put(name + len, cp); + name[len] = '\0'; + if (mk(dir, name) == 0) + lo = n; + else + break; + } + printf(" %-22s max %3d chars = %4d bytes = %3d utf16 units\n", label, lo, + lo * bytes_per, lo * units_per); +} + +static void section_name_length(void) +{ + char dir[4096]; + + puts( + "4. component length limit (pathconf says NAME_MAX, but in what " + "unit?)"); + snprintf(dir, sizeof(dir), "%s/namelen", root); + mkdir(dir, 0755); + printf(" pathconf(_PC_NAME_MAX) = %ld, NAME_MAX macro = %d\n", + pathconf(dir, _PC_NAME_MAX), NAME_MAX); + sweep(dir, "ascii U+0061", 0x61, 1, 1); + sweep(dir, "latin-1 U+00E9", 0xE9, 1, 2); + sweep(dir, "BMP/CJK U+6587", 0x6587, 1, 3); + sweep(dir, "non-BMP U+1F680", 0x1F680, 2, 4); + puts(" -> the limit is constant in UTF-16 units, not in bytes"); + putchar('\n'); +} + +/* ---------------------------------------------------- 5. path length limit */ + +static void section_path_length(void) +{ + char dir[4096], p[16384]; + char comp[101]; + int n; + + puts("5. whole-path limit"); + snprintf(dir, sizeof(dir), "%s/pathlen", root); + mkdir(dir, 0755); + printf(" macOS PATH_MAX = %d, pathconf(_PC_PATH_MAX) = %ld\n", PATH_MAX, + pathconf(dir, _PC_PATH_MAX)); + puts(" Linux PATH_MAX = 4096 (what a guest may build)"); + + memset(comp, 'd', 100); + comp[100] = '\0'; + n = snprintf(p, sizeof(p), "%s", dir); + for (int i = 0; i < 60; i++) { + int add = snprintf(p + n, sizeof(p) - n, "/%s", comp); + if (mkdir(p, 0755) < 0) { + printf(" mkdir fails at depth %d, path length %d: %s\n", i + 1, + n + add, strerror(errno)); + break; + } + n += add; + } + putchar('\n'); +} + +/* -------------------------------------------------------- 6. payload alphabet + */ + +static void section_alphabet(void) +{ + char dir[4096], name[8192]; + int collisions = 0, made = 0, len = 0; + + puts("6. payload alphabet U+4E00..U+5DFF (4096 symbols)"); + snprintf(dir, sizeof(dir), "%s/alpha", root); + mkdir(dir, 0755); + for (unsigned v = 0; v < 4096; v++) { + char one[8]; + int l = utf8_put(one, 0x4E00 + v); + one[l] = '\0'; + int rc = mk(dir, one); + if (rc == 0) + made++; + else if (rc == -EEXIST) + collisions++; + } + printf(" %d of 4096 distinct, %d collided\n", made, collisions); + + len = snprintf(name, sizeof(name), ".ef="); + for (int i = 0; i < 171; i++) + len += utf8_put(name + len, 0x4E00 + (unsigned) (i * 24 % 4096)); + name[len] = '\0'; + printf(" worst-case escaped name (%d bytes, %d units): %s\n", len, + 4 + 171, mk(dir, name) == 0 ? "created" : strerror(errno)); + putchar('\n'); +} + +int main(int argc, char **argv) +{ + const char *base = argc > 1 ? argv[1] : getenv("TMPDIR"); + char tmpl[4096]; + + if (!base || base[0] == '\0') + base = "/tmp"; + snprintf(tmpl, sizeof(tmpl), "%s/elfuse-probe-XXXXXX", base); + if (!mkdtemp(tmpl)) { + fprintf(stderr, "cannot create a scratch directory in %s: %s\n", base, + strerror(errno)); + return 1; + } + root = tmpl; + printf("probing %s\n\n", root); + + section_exactness(); + section_verdicts(); + section_folding(); + section_name_length(); + section_path_length(); + section_alphabet(); + + remove_tree(root); + return 0; +} diff --git a/tests/test-casefold-host.c b/tests/test-casefold-host.c new file mode 100644 index 00000000..5d9809c2 --- /dev/null +++ b/tests/test-casefold-host.c @@ -0,0 +1,861 @@ +/* + * Native-host unit test for the case-folding filename codec + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Two arms. The pure arm drives the codec over a table of names (boundaries, + * malformed escapes, every ill-formed UTF-8 shape, and an internationalization + * corpus) and needs nothing but the CPU. The filesystem arm needs a directory + * and asserts the three things the design assumes about the volume underneath: + * that the payload alphabet cannot fold, that everything the encoder emits can + * actually be created, and that a byte-exact spelling probe works at all. + * + * The filesystem arm is why a change in a future macOS release surfaces here + * rather than as a mysterious guest failure. Point it at any directory to see + * how a different volume behaves: + * + * build/test-casefold-host /Volumes/case-sensitive-image + * + * Code under test: src/syscall/casefold.c. A regression shows up as a guest + * name that comes back as a different name, two distinct names sharing one + * encoding, or a name elfuse emits that the volume then refuses to create. + * Each of those would surface inside a guest much later as a missing or a + * wrong file. + * + * Native macOS binary; no HVF entitlement needed. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "casefold-vectors.h" +#include "host-test-util.h" +#include "syscall/casefold.h" + +static int passes; +static int fails; + +static void ok(void) +{ + passes++; +} + +static void fail(const char *label, const char *detail) +{ + fails++; + fprintf(stderr, "FAIL %s: %s\n", label, detail); +} + +/* Print a name so a failure is diagnosable when the bytes are not printable. */ +static void dump(const char *label, const char *s) +{ + fprintf(stderr, " %s = \"", label); + for (const unsigned char *p = (const unsigned char *) s; *p; p++) { + if (*p >= 0x20 && *p < 0x7F) + fputc(*p, stderr); + else + fprintf(stderr, "\\x%02x", *p); + } + fprintf(stderr, "\"\n"); +} + +/* ---------------------------------------------------------------- pure arm */ + +/* Escape, confirm the result is recognized, decode, and require the original + * bytes back. Also holds the encoded form to the host's per-name budget, since + * an encoding that does not fit is useless however well it round-trips. + */ +static void check_roundtrip(const char *label, const char *guest) +{ + char enc[CASEFOLD_HOST_NAME_MAX + 1]; + char dec[CASEFOLD_GUEST_NAME_MAX + 1]; + size_t units; + + if (casefold_escape(guest, enc, sizeof(enc)) < 0) { + fail(label, "escape failed"); + dump("guest", guest); + return; + } + if (!casefold_is_escaped(enc)) { + fail(label, "encoded form is not recognized as an escape"); + dump("encoded", enc); + return; + } + units = casefold_utf16_units(enc); + if (units == 0 || units > CASEFOLD_UNIT_MAX) { + fail(label, "encoded form exceeds the host per-name budget"); + fprintf(stderr, " units = %zu\n", units); + return; + } + if (casefold_to_guest(enc, dec, sizeof(dec)) < 0) { + fail(label, "decode failed"); + dump("encoded", enc); + return; + } + if (strcmp(dec, guest)) { + fail(label, "round trip changed the name"); + dump("guest", guest); + dump("decoded", dec); + return; + } + ok(); +} + +static void check_not_escaped(const char *label, const char *host) +{ + char out[CASEFOLD_HOST_NAME_MAX + 1]; + + if (casefold_is_escaped(host)) { + fail(label, "malformed escape was accepted"); + dump("host", host); + return; + } + /* An unrecognized shape must pass through as itself, or a listing would + * report a name the guest cannot open. + */ + if (casefold_to_guest(host, out, sizeof(out)) < 0 || strcmp(out, host)) { + fail(label, "unrecognized name did not pass through unchanged"); + return; + } + ok(); +} + +static void check_needs_escape(const char *label, const char *name, bool want) +{ + if (casefold_needs_escape(name) != want) { + fail(label, want ? "should need escaping" : "should not need escaping"); + dump("name", name); + return; + } + ok(); +} + +/* casefold_utf16_units spends no budget on a name that is not well-formed + * UTF-8, which is the observable form of that judgment: such a name cannot be + * stored literally and is going to be escaped instead. Every case below passes + * a non-empty name, so a nonzero count means well-formed. + */ +static void check_utf8(const char *label, const char *s, bool want) +{ + if ((casefold_utf16_units(s) > 0) != want) { + fail(label, want ? "should be valid UTF-8" : "should be invalid UTF-8"); + dump("name", s); + return; + } + ok(); +} + +static void check_units(const char *label, const char *s, size_t want) +{ + size_t got = casefold_utf16_units(s); + + if (got != want) { + fail(label, "wrong UTF-16 unit count"); + fprintf(stderr, " got %zu, expected %zu\n", got, want); + return; + } + ok(); +} + +static void fill(char *buf, size_t n, char c) +{ + memset(buf, c, n); + buf[n] = '\0'; +} + +/* The frozen table is the one check that fails when the format moves: every + * other section reads its expectations back through the codec, so a + * self-consistent format change keeps them green while orphaning every + * sysroot already on disk. Each escaped row is asserted byte-exact in both + * directions; a red row here means the on-disk format broke, and the remedy + * is a format migration, not a new literal. + */ +static void section_golden(void) +{ + char host[CASEFOLD_HOST_NAME_MAX + 1]; + char guest[CASEFOLD_GUEST_NAME_MAX + 1]; + size_t n = sizeof(casefold_vectors) / sizeof(casefold_vectors[0]); + + for (size_t i = 0; i < n; i++) { + const struct casefold_vector *v = &casefold_vectors[i]; + bool literal = !strcmp(v->guest, v->host); + + if (casefold_needs_escape(v->guest) == literal) { + fail(v->label, + literal ? "should be stored literally" : "should be escaped"); + dump("guest", v->guest); + continue; + } + if (!literal) { + if (casefold_escape(v->guest, host, sizeof(host)) < 0) { + fail(v->label, "escape failed"); + dump("guest", v->guest); + continue; + } + if (strcmp(host, v->host)) { + fail(v->label, "on-disk spelling moved off the frozen bytes"); + dump("expected", v->host); + dump("got", host); + continue; + } + if (!casefold_is_escaped(v->host)) { + fail(v->label, "frozen spelling not recognized as an escape"); + continue; + } + } + if (casefold_to_guest(v->host, guest, sizeof(guest)) < 0 || + strcmp(guest, v->guest)) { + fail(v->label, "frozen spelling did not decode to the guest name"); + dump("host", v->host); + dump("decoded", guest); + continue; + } + ok(); + } + + /* The long-tier budget arithmetic, pinned on the frozen strings: symbols + * are BMP code points, one UTF-16 unit each, on top of the four ASCII + * prefix units. + */ + check_units("long-tier min units", ".ef=" CFV_LEN126 CFV_PAIR42, 89); + check_units("linux name max units", ".ef=" CFV_LEN255 CFV_PAIR85, 175); +} + +static void section_boundaries(void) +{ + char name[CASEFOLD_GUEST_NAME_MAX + 1]; + char enc[CASEFOLD_HOST_NAME_MAX + 1]; + + /* Both sides of the tier boundary, and the largest name Linux can express. + * The long tier exists precisely so the last of these works. + */ + fill(name, CASEFOLD_HEX_MAX, 'x'); + check_roundtrip("hex tier at its maximum", name); + fill(name, CASEFOLD_HEX_MAX + 1, 'x'); + check_roundtrip("symbol tier at its minimum", name); + fill(name, CASEFOLD_GUEST_NAME_MAX, 'x'); + check_roundtrip("guest NAME_MAX", name); + fill(name, CASEFOLD_GUEST_NAME_MAX, 'X'); + check_roundtrip("guest NAME_MAX, all uppercase", name); + fill(name, 1, 'q'); + check_roundtrip("single byte", name); + + /* The exact worst case, not just "within budget". docs/filenames.md quotes + * these two numbers as the headroom argument for why no guest name can ever + * be refused for length, and prose drifts from code silently; asserting + * them keeps the claim measured. 255 bytes is the largest name Linux can + * express, so nothing gets closer than this. + */ + fill(name, CASEFOLD_GUEST_NAME_MAX, 'X'); + if (casefold_escape(name, enc, sizeof(enc)) == 0 && + casefold_utf16_units(enc) == 175 && CASEFOLD_UNIT_MAX - 175 == 80) + ok(); + else + fail("worst-case escape costs 175 of 255 units", + "the longest guest name no longer costs 175 units"); + + /* The tier is picked by length alone, so a short name is never spelled with + * symbols and a long one never with hex. That is half of what makes each + * name have exactly one encoding. + */ + fill(name, CASEFOLD_HEX_MAX, 'x'); + if (casefold_escape(name, enc, sizeof(enc)) == 0 && + (unsigned char) enc[CASEFOLD_PREFIX_LEN] < 0x80) + ok(); + else + fail("hex tier uses hex", "short name did not use the hex tier"); + fill(name, CASEFOLD_HEX_MAX + 1, 'x'); + if (casefold_escape(name, enc, sizeof(enc)) == 0 && + (unsigned char) enc[CASEFOLD_PREFIX_LEN] >= 0x80) + ok(); + else + fail("symbol tier uses symbols", + "long name did not use the symbol tier"); + + /* "." and ".." name no entry, so no escape can stand for one. Rejecting + * them keeps every name the codec accepts one that has a slot to live in; + * accepting them would produce a spelling that decodes to something no + * directory can hold. + */ + if (casefold_escape(".", enc, sizeof(enc)) < 0 && errno == EINVAL) + ok(); + else + fail("dot is refused", "\".\" was escaped"); + if (casefold_escape("..", enc, sizeof(enc)) < 0 && errno == EINVAL) + ok(); + else + fail("dotdot is refused", "\"..\" was escaped"); + + /* A guest name over NAME_MAX cannot reach a syscall, but the codec must + * reject it rather than truncate. + */ + fill(name, CASEFOLD_GUEST_NAME_MAX, 'x'); + char over[CASEFOLD_GUEST_NAME_MAX + 2]; + memset(over, 'x', sizeof(over) - 1); + over[sizeof(over) - 1] = '\0'; + if (casefold_escape(over, enc, sizeof(enc)) < 0 && errno == EINVAL) + ok(); + else + fail("over-long guest name", "should be rejected with EINVAL"); + + /* A caller buffer too small is ENAMETOOLONG, which is distinct from "this + * name has no representation"; the latter cannot happen. + */ + fill(name, 8, 'x'); + if (casefold_escape(name, enc, 4) < 0 && errno == ENAMETOOLONG) + ok(); + else + fail("short output buffer", "should be rejected with ENAMETOOLONG"); + + /* '/' separates components and so never appears inside one. Encoding it + * would produce a name no directory could hold, so it is refused outright + * rather than escaped. + */ + if (casefold_escape("a/z", enc, sizeof(enc)) < 0 && errno == EINVAL) + ok(); + else + fail("component containing a slash", "should be rejected with EINVAL"); + if (casefold_escape("", enc, sizeof(enc)) < 0 && errno == EINVAL) + ok(); + else + fail("empty component", "should be rejected with EINVAL"); +} + +static void section_shapes(void) +{ + /* Everything here is a name a host tool could stage. None is a well-formed + * escape, so each must mean itself. + */ + check_not_escaped("bare prefix", ".ef="); + check_not_escaped("prefix truncated", ".ef"); + check_not_escaped("odd hex length", ".ef=464f4"); + check_not_escaped("uppercase hex", ".ef=464F4F"); + check_not_escaped("non-hex tail", ".ef=zzzz"); + check_not_escaped("hex with trailing junk", ".ef=464f4fq"); + check_not_escaped("prefix not at the start", "a.ef=464f4f"); + check_not_escaped("wrong separator", ".ef_464f4f"); + check_not_escaped("decodes to a slash", ".ef=2f"); + check_not_escaped("decodes to dot", ".ef=2e"); + check_not_escaped("decodes to dotdot", ".ef=2e2e"); + check_not_escaped("decodes to an embedded NUL", ".ef=610062"); + check_not_escaped("empty decode", ".ef="); + /* U+4E00 is a payload symbol, but a lone one is not a valid frame: the + * first symbol carries a length, and 0 is not a legal name length. + */ + check_not_escaped("single payload symbol", ".ef=\xe4\xb8\x80"); + /* U+3042 HIRAGANA A sits outside the payload block. */ + check_not_escaped("symbol outside the block", ".ef=\xe3\x81\x82"); + + /* A guest name shaped like an escape is escaped itself, so reading it back + * yields the name and not whatever it would have decoded to. + */ + check_needs_escape("escape-shaped guest name", ".ef=464f4f", true); + check_roundtrip("escape-shaped guest name round trip", ".ef=464f4f"); + + /* Only the escape prefix is special. Deciding a spelling from the name + * alone leaves the sysroot no bookkeeping file to hide, so no other name is + * reserved and a leading dot buys nothing. Pinned because an earlier scheme + * did reserve a name, and a rule that quietly claims one again would make + * the guest unable to create a file Linux allows. + */ + check_needs_escape("a dotfile is not special", ".elfuse_case_index", false); + check_roundtrip("a dotfile round trips", ".elfuse_case_index"); + check_needs_escape("a bare dotfile is not special", ".hidden", false); + + char enc[CASEFOLD_HOST_NAME_MAX + 1]; + char dec[CASEFOLD_GUEST_NAME_MAX + 1]; + if (casefold_escape(".ef=464f4f", enc, sizeof(enc)) == 0 && + casefold_to_guest(enc, dec, sizeof(dec)) == 0 && + !strcmp(dec, ".ef=464f4f") && strcmp(dec, "FOO")) + ok(); + else + fail("escape-shaped name is not confused with FOO", + "decoded to the wrong name"); + + /* Which names must be escaped at all. Lowercase ASCII is the fixed point + * that can be stored literally; everything else cannot. + */ + check_needs_escape("lowercase ascii", "config.json", false); + check_needs_escape("digits and punctuation", "a-b_c.1~2", false); + check_needs_escape("one uppercase", "Makefile", true); + check_needs_escape("non-ascii", "caf\xc3\xa9", true); + check_needs_escape("invalid utf-8", "bad\xff", true); +} + +static void section_utf8(void) +{ + check_utf8("ascii", "plain", true); + check_utf8("two-byte", "caf\xc3\xa9", true); + check_utf8("three-byte", "\xe6\x96\x87", true); + check_utf8("four-byte", "\xf0\x9f\x9a\x80", true); + + check_utf8("lone continuation", "a\x80", false); + check_utf8("truncated two-byte", "a\xc3", false); + check_utf8("truncated three-byte", "a\xe3\x81", false); + check_utf8("truncated four-byte", "a\xf0\x9f\x9a", false); + check_utf8("overlong two-byte", "a\xc0\xaf", false); + check_utf8("overlong three-byte", "a\xe0\x80\xaf", false); + check_utf8("surrogate low", "a\xed\xa0\x80", false); + check_utf8("surrogate high", "a\xed\xbf\xbf", false); + check_utf8("above U+10FFFF", "a\xf4\x90\x80\x80", false); + check_utf8("five-byte form", "a\xf8\x88\x80\x80\x80", false); + check_utf8("0xfe", "a\xfe", false); + check_utf8("0xff", "a\xff", false); + + /* The host counts UTF-16 units, so a code point above the BMP costs two. + * Getting this wrong is the likeliest way to emit a name the volume + * refuses, which is why the filesystem arm cross-checks it. + */ + check_units("ascii units", "abcd", 4); + check_units("two-byte units", "caf\xc3\xa9", 4); + check_units("three-byte units", "\xe6\x96\x87\xe6\xa1\xa3", 2); + check_units("surrogate pair units", "\xf0\x9f\x9a\x80", 2); + check_units("mixed units", "a\xe6\x96\x87\xf0\x9f\x9a\x80", 4); + check_units("invalid utf-8 has no unit count", "a\xff", 0); +} + +/* Names drawn from the measured behavior of the volume: every one of these + * pairs matches the same on-disk entry, so each member must encode distinctly. + */ +static const char *const i18n_corpus[] = { + "Foo", + "foo", + "FOO", + "caf\xc3\xa9", /* NFC e-acute */ + "cafe\xcc\x81", /* NFD e + combining acute */ + "\xc3\x89t\xc3\xa9", + "\xc3\xa9t\xc3\xa9", + "\xc3\xbc" + "ber", + "u\xcc\x88" + "ber", + "stra\xc3\x9f" + "e", + "strasse", + "STRASSE", + "\xcf\x83o\xcf\x82", + "\xcf\x83o\xcf\x83", + "\xce\xa3o\xcf\x82", + "\xd0\x94\xd0\x90", + "\xd0\xb4\xd0\xb0", + "\xc4\xb1" + "d", + "id", + "\xc4\xb0" + "d", + "\xe6\x96\x87\xe6\xa1\xa3.txt", + "\xe6\x96\x87\xe4\xbb\xb6.txt", + "\xe3\x81\x8c", + "\xe3\x81\x8b\xe3\x82\x99", + "\xed\x95\x9c", + "\xe1\x84\x92\xe1\x85\xa1\xe1\x86\xab", + "\xe2\x84\xa6" + "a", + "\xce\xa9" + "a", + "\xe2\x84\xab" + "c", + "\xc3\x85" + "c", + "\xef\xac\x81" + "b", + "fib", + "\xe1\xbb\x87" + "d", + "e\xcc\xa3\xcc\x82" + "d", + "\xe0\xa4\xa9" + "e", + "\xe0\xa4\xa8\xe0\xa4\xbc" + "e", + "\xf0\x9f\x9a\x80", + "\xe2\xad\x90", + "\xf0\x90\x90\x80" + "y", + "\xf0\x90\x90\xa8" + "y", + "\xe1\x8e\xa0" + "x", + "\xe1\x8f\xb8" + "x", +}; + +static void section_i18n(void) +{ + size_t n = sizeof(i18n_corpus) / sizeof(i18n_corpus[0]); + + for (size_t i = 0; i < n; i++) { + if (casefold_needs_escape(i18n_corpus[i])) + check_roundtrip("i18n round trip", i18n_corpus[i]); + else + ok(); /* fold-stable names are stored literally, nothing to encode + */ + } + + /* Distinct names must encode distinctly, or two files would share a slot. + * Quadratic over a few dozen entries is free and catches an encoder that + * loses information. + */ + for (size_t i = 0; i < n; i++) { + char a[CASEFOLD_HOST_NAME_MAX + 1]; + if (!casefold_needs_escape(i18n_corpus[i])) + continue; + /* An encoder that refused these names would skip every comparison and + * reach the ok() below having proved nothing, so a refusal is the + * failure rather than a reason to move on. + */ + if (casefold_escape(i18n_corpus[i], a, sizeof(a)) < 0) { + fail("distinct names encode distinctly", "escape failed"); + dump("name", i18n_corpus[i]); + return; + } + for (size_t j = i + 1; j < n; j++) { + char b[CASEFOLD_HOST_NAME_MAX + 1]; + if (!strcmp(i18n_corpus[i], i18n_corpus[j])) + continue; + if (!casefold_needs_escape(i18n_corpus[j])) + continue; + if (casefold_escape(i18n_corpus[j], b, sizeof(b)) < 0) { + fail("distinct names encode distinctly", "escape failed"); + dump("name", i18n_corpus[j]); + return; + } + if (!strcmp(a, b)) { + fail("distinct names encode distinctly", + "two names share an encoding"); + dump("first", i18n_corpus[i]); + dump("second", i18n_corpus[j]); + return; + } + } + } + ok(); +} + +/* ---------------------------------------------------------- filesystem arm */ + +static int create_in(const char *dir, const char *name) +{ + char path[8192]; + int fd; + + if (snprintf(path, sizeof(path), "%s/%s", dir, name) >= (int) sizeof(path)) + return -ENAMETOOLONG; + fd = open(path, O_CREAT | O_EXCL | O_WRONLY, 0644); + if (fd < 0) + return -errno; + close(fd); + return 0; +} + +/* Every payload symbol must be distinct from every other after the volume has + * had its way with them. Creating all 4096 in one directory proves it outright: + * any fold between two of them shows up as EEXIST. + */ +static void section_alphabet(const char *root) +{ + char dir[4096]; + int created = 0; + + snprintf(dir, sizeof(dir), "%s/alphabet", root); + if (mkdir(dir, 0755) < 0) { + fail("alphabet directory", strerror(errno)); + return; + } + for (unsigned v = 0; v < 4096; v++) { + const unsigned cp = 0x4E00 + v; /* CASEFOLD_SYM_BASE in casefold.c */ + char name[8]; + int len = utf8_put(name, cp); + int rc; + + name[len] = '\0'; + rc = create_in(dir, name); + if (rc < 0) { + fail("payload alphabet is fold-free", strerror(-rc)); + fprintf(stderr, " symbol %u (U+%04X)\n", v, cp); + return; + } + created++; + } + if (created == 4096) + ok(); + + int seen = 0; + DIR *d = opendir(dir); + struct dirent *de; + while (d && (de = readdir(d))) { + if (strcmp(de->d_name, ".") && strcmp(de->d_name, "..")) + seen++; + } + if (d) + closedir(d); + if (seen == 4096) { + ok(); + } else { + fail("payload alphabet survives a listing", "wrong entry count"); + fprintf(stderr, " listed %d of 4096\n", seen); + } +} + +/* Nothing the encoder emits may be rejected by the volume. This is the backstop + * for the unit accounting: if the budget arithmetic is wrong anywhere, a create + * fails here instead of failing inside a guest much later. + */ +static void section_accept(const char *root) +{ + char dir[4096]; + unsigned seed = 0x9E3779B9u; + bool bad = false; + + snprintf(dir, sizeof(dir), "%s/accept", root); + if (mkdir(dir, 0755) < 0) { + fail("acceptance directory", strerror(errno)); + return; + } + + /* Every byte value, alone and surrounded, then the i18n corpus, then a + * deterministic sweep of every length. The seed is fixed so a failure is + * reproducible; a random one would report a case nobody could re-run. + */ + for (unsigned b = 1; b < 256 && !bad; b++) { + char one[2] = {(char) b, '\0'}; + char three[4] = {'a', (char) b, 'z', '\0'}; + const char *names[2] = {one, three}; + + /* '/' separates components and so never appears inside one; the codec + * rejects it rather than encode a name no directory could hold. + */ + if (b == '/') + continue; + + for (int k = 0; k < 2; k++) { + char host[CASEFOLD_HOST_NAME_MAX + 1]; + int rc; + + /* "." and ".." navigate rather than name an entry, so no directory + * can hold one and the codec refuses them. Only the single-byte + * form can produce one here; "a.z" is an ordinary name and stays. + */ + if (!strcmp(names[k], ".") || !strcmp(names[k], "..")) + continue; + + if (casefold_needs_escape(names[k])) { + if (casefold_escape(names[k], host, sizeof(host)) < 0) { + fail("encoder accepts every byte", "escape failed"); + dump("name", names[k]); + bad = true; + break; + } + } else { + snprintf(host, sizeof(host), "%s", names[k]); + } + /* EEXIST is a failure, not a tolerated outcome. Every name in + * this sweep is distinct, so a second create landing on an entry + * that is already there means two guest names reached one slot, + * precisely the collision the encoding exists to prevent, and + * exactly what tolerating EEXIST would hide. + */ + rc = create_in(dir, host); + if (rc < 0) { + fail("encoder output is creatable", + rc == -EEXIST ? "two names share one entry" + : strerror(-rc)); + dump("guest", names[k]); + dump("host", host); + bad = true; + break; + } + } + } + + for (size_t i = 0; i < sizeof(i18n_corpus) / sizeof(i18n_corpus[0]) && !bad; + i++) { + char host[CASEFOLD_HOST_NAME_MAX + 1]; + int rc; + + if (casefold_needs_escape(i18n_corpus[i])) { + if (casefold_escape(i18n_corpus[i], host, sizeof(host)) < 0) { + fail("encoder accepts the i18n corpus", "escape failed"); + bad = true; + break; + } + } else { + snprintf(host, sizeof(host), "%s", i18n_corpus[i]); + } + rc = create_in(dir, host); + if (rc < 0) { + fail("i18n encoder output is creatable", + rc == -EEXIST ? "two names share one entry" : strerror(-rc)); + dump("guest", i18n_corpus[i]); + dump("host", host); + bad = true; + } + } + + for (size_t n = 1; n <= CASEFOLD_GUEST_NAME_MAX && !bad; n++) { + char guest[CASEFOLD_GUEST_NAME_MAX + 1]; + char host[CASEFOLD_HOST_NAME_MAX + 1]; + int rc; + + for (size_t i = 0; i < n; i++) { + seed = seed * 1103515245u + 12345u; + /* Any byte but NUL and '/', which no filename may contain. */ + unsigned char c = (unsigned char) (seed >> 16); + if (c == 0 || c == '/') + c = 'a'; + guest[i] = (char) c; + } + guest[n] = '\0'; + + if (casefold_needs_escape(guest)) { + if (casefold_escape(guest, host, sizeof(host)) < 0) { + fail("encoder handles every length", "escape failed"); + fprintf(stderr, " length %zu\n", n); + bad = true; + break; + } + } else { + snprintf(host, sizeof(host), "%s", guest); + } + rc = create_in(dir, host); + if (rc < 0 && rc != -EEXIST) { + fail("encoder output is creatable at every length", strerror(-rc)); + fprintf(stderr, " length %zu\n", n); + dump("host", host); + bad = true; + } + } + + if (!bad) + ok(); +} + +/* The three volume behaviors the resolver is built on. If a macOS release + * changes any of them the design needs revisiting, so each gets its own + * message rather than a generic assertion failure. + */ +static void section_volume(const char *root) +{ + char dir[4096]; + char path[8192]; + char real[PATH_MAX]; + const char *spelling; + + snprintf(dir, sizeof(dir), "%s/volume", root); + if (mkdir(dir, 0755) < 0) { + fail("volume directory", strerror(errno)); + return; + } + if (create_in(dir, "Mixed.Case") < 0) { + fail("volume fixture", strerror(errno)); + return; + } + + spelling = disk_name(dir, "Mixed.Case"); + if (spelling && !strcmp(spelling, "Mixed.Case")) + ok(); + else + fail("getattrlistat reports the on-disk spelling", + "probe did not return the name as stored"); + + /* On a folding volume the probe is what separates "absent" from "present + * under another spelling"; on a case-sensitive one the wrong case is simply + * absent. Both answers are correct, and neither is "exists as spelled". + */ + spelling = disk_name(dir, "mixed.case"); + if (!spelling || strcmp(spelling, "mixed.case")) + ok(); + else + fail("a wrong-case spelling is never reported as exact", + "probe accepted a folded spelling"); + + snprintf(path, sizeof(path), "%s/mixed.case", dir); + if (realpath(path, real)) { + const char *base = strrchr(real, '/'); + if (base && !strcmp(base + 1, "Mixed.Case")) + ok(); + else + fail("realpath returns the true on-disk case", real); + } else { + /* A case-sensitive volume has no such entry at all, which is fine. */ + ok(); + } + + /* The per-name budget is counted in UTF-16 units, not bytes: this is what + * lets the symbol tier hold a full-length guest name. + */ + char wide[1024]; + int len = 0; + for (int i = 0; i < CASEFOLD_UNIT_MAX; i++) + len += utf8_put(wide + len, 0x6587); + wide[len] = '\0'; + if (create_in(dir, wide) == 0) + ok(); + else + fail("a 255-unit BMP name is creatable", + "the per-name limit is not counted in UTF-16 units"); + + len = 0; + for (int i = 0; i < CASEFOLD_UNIT_MAX + 1; i++) + len += utf8_put(wide + len, 0x6587); + wide[len] = '\0'; + if (create_in(dir, wide) == -ENAMETOOLONG) + ok(); + else + fail("a 256-unit name is refused", + "the per-name limit is not 255 UTF-16 units"); + + /* The worst case the encoder can produce, created for real. */ + char guest[CASEFOLD_GUEST_NAME_MAX + 1]; + char host[CASEFOLD_HOST_NAME_MAX + 1]; + fill(guest, CASEFOLD_GUEST_NAME_MAX, 'Q'); + if (casefold_escape(guest, host, sizeof(host)) == 0 && + create_in(dir, host) == 0) + ok(); + else + fail("the longest encoded name is creatable", + "the symbol tier does not fit the budget"); +} + +int main(int argc, char **argv) +{ + const char *base = argc > 1 ? argv[1] : getenv("TMPDIR"); + char root[4096]; + + section_golden(); + section_boundaries(); + section_shapes(); + section_utf8(); + section_i18n(); + + if (!base || base[0] == '\0') + base = "/tmp"; + snprintf(root, sizeof(root), "%s/elfuse-casefold-XXXXXX", base); + if (!mkdtemp(root)) { + fprintf(stderr, + "test-casefold-host: cannot create a scratch directory in " + "%s: %s\n", + base, strerror(errno)); + return 1; + } + + section_alphabet(root); + section_accept(root); + section_volume(root); + remove_tree(root); + + printf("test-casefold-host: %d passed, %d failed - %s\n", passes, fails, + fails ? "FAIL" : "PASS"); + return fails ? 1 : 0; +} From fd7a122b02e82d726e6ac75a09ca19ec092ef15a Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Sat, 25 Jul 2026 22:12:21 +0200 Subject: [PATCH 02/11] Add the case-exact path resolver 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. --- Makefile | 9 + mk/config.mk | 1 + mk/tests.mk | 16 +- src/syscall/casefold-walk.c | 428 +++++++++++++++++++++++++++++ src/syscall/casefold-walk.h | 100 +++++++ src/syscall/casefold.h | 9 +- src/syscall/path.c | 19 -- src/syscall/path.h | 24 +- tests/test-casefold-walk-host.c | 465 ++++++++++++++++++++++++++++++++ 9 files changed, 1046 insertions(+), 25 deletions(-) create mode 100644 src/syscall/casefold-walk.c create mode 100644 src/syscall/casefold-walk.h create mode 100644 tests/test-casefold-walk-host.c diff --git a/Makefile b/Makefile index 16768430..14f6700b 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,7 @@ SRCS := \ syscall/fuse.c \ syscall/sidecar.c \ syscall/casefold.c \ + syscall/casefold-walk.c \ syscall/chown-overlay.c \ syscall/fs.c \ syscall/fs-stat.c \ @@ -208,6 +209,14 @@ $(BUILD_DIR)/test-casefold-host: $(BUILD_DIR)/test-casefold-host.o \ @echo " LD $@" $(Q)$(CC) $(CFLAGS) -o $@ $^ +## Build the case-exact path resolution host test (native macOS binary) +# Links the resolver and the codec; the two process-state symbols the resolver +# reads are stubbed in the test. +$(BUILD_DIR)/test-casefold-walk-host: $(BUILD_DIR)/test-casefold-walk-host.o \ + $(BUILD_DIR)/syscall/casefold-walk.o \ + $(BUILD_DIR)/syscall/casefold.o | $(BUILD_DIR) + @echo " LD $@" + $(Q)$(CC) $(CFLAGS) -o $@ $^ # Guest test binaries (cross-compiled, aarch64-linux) # Only used when GUEST_TEST_BINARIES is not set. diff --git a/mk/config.mk b/mk/config.mk index 6bff81cd..41beb8e5 100644 --- a/mk/config.mk +++ b/mk/config.mk @@ -23,6 +23,7 @@ NATIVE_TESTS := tests/test-multi-vcpu.c tests/test-rwx.c \ tests/test-tlbi-encoder-host.c \ tests/test-fork-ipc-protocol-host.c \ tests/test-casefold-host.c \ + tests/test-casefold-walk-host.c \ tests/probe-volume-naming.c SPECIAL_TEST_SRCS := tests/test-lowbase-mem.c SPECIAL_TEST_BINS := $(BUILD_DIR)/test-lowbase-mem-200000 $(BUILD_DIR)/test-lowbase-mem-300000 diff --git a/mk/tests.mk b/mk/tests.mk index 71eaac5b..f09df71c 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -18,6 +18,7 @@ test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \ test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \ test-linkat-symlink-fallback test-casefold-host \ + test-casefold-walk-host \ probe-volume-naming perf ## Build and run the assembly hello world test @@ -82,7 +83,8 @@ check-sanitizer: $(ELFUSE_BIN) $(TEST_DEPS) \ $(BUILD_DIR)/test-vcpu-run-hooks-host \ $(BUILD_DIR)/test-identity-override-host \ $(BUILD_DIR)/test-teardown-live-vcpu-host \ - $(BUILD_DIR)/test-casefold-host + $(BUILD_DIR)/test-casefold-host \ + $(BUILD_DIR)/test-casefold-walk-host @bash tests/driver.sh -e $(ELFUSE_BIN) -d $(TEST_DIR) -v -s '$(SANITIZER_SECTIONS)' @printf "\n$(BLUE)━━━ TLBI RVAE1IS encoder unit test ━━━$(RESET)\n" @$(BUILD_DIR)/test-tlbi-encoder-host @@ -96,6 +98,8 @@ check-sanitizer: $(ELFUSE_BIN) $(TEST_DEPS) \ @$(BUILD_DIR)/test-teardown-live-vcpu-host @printf "\n$(BLUE)━━━ filename codec unit test ━━━$(RESET)\n" @$(BUILD_DIR)/test-casefold-host + @printf "\n$(BLUE)━━━ case-exact path resolution unit test ━━━$(RESET)\n" + @$(BUILD_DIR)/test-casefold-walk-host ## Run the unit test suite plus busybox applet validation check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @@ -104,7 +108,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ $(BUILD_DIR)/test-vcpu-run-hooks-host \ $(BUILD_DIR)/test-identity-override-host \ $(BUILD_DIR)/test-teardown-live-vcpu-host \ - $(BUILD_DIR)/test-casefold-host + $(BUILD_DIR)/test-casefold-host \ + $(BUILD_DIR)/test-casefold-walk-host @bash tests/driver.sh -e $(ELFUSE_BIN) -d $(TEST_DIR) -v @printf "\n$(BLUE)━━━ TLBI RVAE1IS encoder unit test ━━━$(RESET)\n" @$(BUILD_DIR)/test-tlbi-encoder-host @@ -118,6 +123,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(BUILD_DIR)/test-teardown-live-vcpu-host @printf "\n$(BLUE)━━━ filename codec unit test ━━━$(RESET)\n" @$(BUILD_DIR)/test-casefold-host + @printf "\n$(BLUE)━━━ case-exact path resolution unit test ━━━$(RESET)\n" + @$(BUILD_DIR)/test-casefold-walk-host @printf "\n$(BLUE)━━━ shebang parser unit test ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-shebang-host @printf "\n$(BLUE)━━━ proctitle argv-tail regression ━━━$(RESET)\n" @@ -826,6 +833,11 @@ test-proctitle-host: $(BUILD_DIR)/test-proctitle-host test-casefold-host: $(BUILD_DIR)/test-casefold-host $(BUILD_DIR)/test-casefold-host +# Case-exact path resolution unit test. Also takes a directory. +## Run the case-exact path resolution unit tests +test-casefold-walk-host: $(BUILD_DIR)/test-casefold-walk-host + $(BUILD_DIR)/test-casefold-walk-host + # Volume naming probe ## Report how the filesystem treats filenames (regenerates docs/filenames.md tables) probe-volume-naming: $(BUILD_DIR)/probe-volume-naming diff --git a/src/syscall/casefold-walk.c b/src/syscall/casefold-walk.c new file mode 100644 index 00000000..c6b1607b --- /dev/null +++ b/src/syscall/casefold-walk.c @@ -0,0 +1,428 @@ +/* + * Case-exact path resolution + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Applies the encoding in casefold.h to a whole path, asking the volume about + * one component at a time. casefold-walk.h states the contract. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils.h" + +#include "syscall/casefold-walk.h" +#include "syscall/path.h" +#include "syscall/proc.h" + +bool casefold_active(void) +{ + return proc_get_sysroot() && proc_sysroot_casefold_enabled(); +} + +typedef enum { + PROBE_ERROR = -1, + PROBE_EXACT = 0, /* an entry exists spelled exactly as asked */ + PROBE_FOLDED = 1, /* an entry exists, but under a different spelling */ + PROBE_ABSENT = 2, /* nothing is there */ + PROBE_UNUSABLE = 3 /* the volume refuses to hold this name at all */ +} probe_result_t; + +/* Scan the directory holding @path for an entry spelled exactly @leaf. Only + * reached when the volume cannot report a stored spelling through + * getattrlistat, which APFS and HFS+ both can; a network mount that folds case + * might not. Costs a directory read, which is why it is the fallback and not + * the primary. + */ +static probe_result_t probe_by_readdir(host_fd_t base_fd, + const char *path, + const char *leaf) +{ + char parent[LINUX_PATH_MAX]; + const char *slash = strrchr(path, '/'); + struct stat st; + struct dirent *de; + bool found = false; + DIR *d; + int fd; + + if (!slash) { + /* A bare name is measured from @base_fd itself. */ + str_copy_trunc(parent, ".", sizeof(parent)); + } else { + size_t n = slash == path ? 1 : (size_t) (slash - path); + + if (n >= sizeof(parent)) { + errno = ENAMETOOLONG; + return PROBE_ERROR; + } + memcpy(parent, path, n); + parent[n] = '\0'; + } + + fd = openat(base_fd, parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd < 0) + return PROBE_ERROR; + d = fdopendir(fd); + if (!d) { + close(fd); + return PROBE_ERROR; + } + while ((de = readdir(d))) { + if (!strcmp(de->d_name, leaf)) { + found = true; + break; + } + } + closedir(d); + if (found) + return PROBE_EXACT; + + /* Missing from the listing is not the same as absent: the volume may hold + * the name under a spelling that folded onto it, and a lookup by that name + * will find it. Reporting absent here would send a create at the literal + * name straight onto the other file. + */ + if (fstatat(base_fd, path, &st, AT_SYMLINK_NOFOLLOW) == 0) + return PROBE_FOLDED; + return errno == ENOENT || errno == ENOTDIR ? PROBE_ABSENT : PROBE_ERROR; +} + +/* Does an entry spelled exactly @leaf sit at @path? + * + * A plain stat cannot answer this: the volume resolves names case- and + * normalization-blind, so it reports success for a spelling that is not what is + * stored, while Linux resolution is byte-exact and must report ENOENT for that. + * getattrlistat goes through the same folding lookup but hands back the name as + * stored, which is the byte comparison this needs. FSOPT_NOFOLLOW keeps the + * question about the entry itself rather than a symlink's target, so a link is + * judged by its own name. + */ +static probe_result_t probe_exact(host_fd_t base_fd, + const char *path, + const char *leaf) +{ + struct attrlist al = { + .bitmapcount = ATTR_BIT_MAP_COUNT, + .commonattr = ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_NAME, + }; + struct { + u_int32_t length; + attribute_set_t returned; + attrreference_t name_ref; + char name[CASEFOLD_STORED_NAME_MAX]; + } __attribute__((aligned(4), packed)) attr_buf; + + if (getattrlistat(base_fd, path, &al, &attr_buf, sizeof(attr_buf), + FSOPT_NOFOLLOW) == 0) { + if (attr_buf.returned.commonattr & ATTR_CMN_NAME) { + const char *stored = (const char *) &attr_buf.name_ref + + attr_buf.name_ref.attr_dataoffset; + if (!strcmp(stored, leaf)) + return PROBE_EXACT; + /* A mismatch is not yet a fold. For a second hard link to a + * symlink the volume reports the primary link's name here rather + * than the one just looked up (observed on APFS; a second link to + * a regular file reports itself), so an entry spelled exactly as + * 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. + */ + return probe_by_readdir(base_fd, path, leaf); + } + /* The call succeeded but the volume withheld the name, so there is no + * spelling to compare. Say so rather than dispatching on an errno no + * one set, which would pick a verdict out of whatever ran last. + */ + errno = ENOTSUP; + } + + switch (errno) { + case ENOENT: + case ENOTDIR: + return PROBE_ABSENT; + case EILSEQ: + case EINVAL: + /* The volume will not hold this byte sequence as a name, so it can + * never be there and can never be created there. Both answers are the + * same: the name has to be escaped. + * + * EINVAL can also mean a malformed request rather than a malformed + * name, and the two are indistinguishable here. It does not matter: + * the attrlist is a compile-time constant, so a malformed one would + * fail every probe in every directory rather than this one, which no + * lookup in the suite would survive. + */ + return PROBE_UNUSABLE; + case ENOTSUP: + return probe_by_readdir(base_fd, path, leaf); + default: + return PROBE_ERROR; + } +} + +/* Append "/@name" to @out, tracking the running length so the walk does not + * rescan what it has already built. + */ +static int append_component(char *out, + size_t outsz, + size_t *len, + const char *name) +{ + size_t name_len = strlen(name); + size_t pos = *len; + + /* No separator before the first component: an empty prefix means the walk + * is measured from a descriptor, and a leading '/' would make the result + * absolute and resolve it against the host root instead. + */ + if (pos != 0 && out[pos - 1] != '/') { + if (pos + 1 >= outsz) { + errno = ENAMETOOLONG; + return -1; + } + out[pos++] = '/'; + } + if (pos + name_len >= outsz) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(out + pos, name, name_len + 1); + *len = pos + name_len; + return 0; +} + +/* Append a component and record where it landed. Predicting the offset instead + * gets it wrong whenever append_component omits the separator: an empty prefix, + * or a prefix that already ends in one, as a root sysroot does. + */ +static int append_leaf(char *out, + size_t outsz, + size_t *len, + const char *name, + casefold_walk_t *walk) +{ + size_t name_len = strlen(name); + + walk->parent_offset = *len; + if (append_component(out, outsz, len, name) < 0) + return -1; + walk->leaf_offset = *len - name_len; + return 0; +} + +/* The on-disk name a guest component takes when nothing can be probed for it, + * either because it is absent or because its parent is. Escaping depends only + * on the name, so this needs no filesystem access. + */ +static int name_by_rule(const char *guest, char *out, size_t outsz) +{ + if (!casefold_needs_escape(guest)) { + if (strlen(guest) + 1 > outsz) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(out, guest, strlen(guest) + 1); + return 0; + } + 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. + */ +static probe_result_t resolve_component(host_fd_t base_fd, + const char *out, + size_t len, + const char *guest, + char *host, + size_t hostsz, + bool *present) +{ + 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. + */ + 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); + if (verdict == PROBE_ERROR) + return PROBE_ERROR; + if (verdict == PROBE_EXACT) { + if (str_copy_trunc(host, guest, hostsz) >= hostsz) { + errno = ENAMETOOLONG; + return PROBE_ERROR; + } + *present = true; + return PROBE_EXACT; + } + } else { + /* An escape-shaped guest name can only live at its own escape, so the + * literal slot says nothing about it and is left unprobed: whatever + * sits there encodes a different name. Absent until the escape probe + * below says otherwise. + */ + verdict = PROBE_ABSENT; + } + + /* The literal spelling is not what is stored. Whatever the reason (a + * 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. + */ + 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; + } + + 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)) { + case PROBE_EXACT: + *present = true; + return PROBE_EXACT; + case PROBE_ERROR: + return PROBE_ERROR; + default: + break; + } + + /* Neither spelling is there. Which one the name would take is decided by + * the name alone, which is what keeps two processes creating colliding + * names off the same slot. + */ + if (verdict == PROBE_ABSENT) + return name_by_rule(guest, host, hostsz) < 0 ? PROBE_ERROR + : PROBE_ABSENT; + /* The slot is taken by a different spelling, or refused outright, so the + * name belongs at its escape even though nothing is there yet. Reported as + * folded rather than absent: the two differ to a caller deciding whether + * the sysroot has a claim on this path. + * + * A refused name converges on the same answer as an occupied slot, which + * is why PROBE_UNUSABLE needs no separate verdict of its own. Both mean the + * sysroot owns this path and the caller must not look for it on the host. + * The difference is why the literal spelling is unavailable, and no caller + * asks that. Deliberately fail-closed: the alternative sends a guest asking + * for an ill-formed name out to whatever the host happens to hold. + */ + return PROBE_FOLDED; +} + +casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, + const char *base_host_prefix, + const char *guest_path, + bool follow_final, + char *out, + size_t outsz, + casefold_walk_t *walk) +{ + const char *scan = guest_path; + const char *comp; + size_t comp_len; + size_t len; + bool absent = false; + + walk->parent_found = true; + walk->parent_offset = 0; + walk->leaf_offset = 0; + walk->folded = false; + + len = str_copy_trunc(out, base_host_prefix ? base_host_prefix : "", outsz); + if (len >= outsz) { + errno = ENAMETOOLONG; + return CASEFOLD_ERROR; + } + + 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; + + /* "." and ".." navigate rather than name an entry, so they are spelled + * through untouched. An absolute path has already had them collapsed; + * a dirfd-relative one may still carry them. + */ + if (!strcmp(guest, ".") || !strcmp(guest, "..")) { + if (append_leaf(out, outsz, &len, guest, walk) < 0) + return CASEFOLD_ERROR; + continue; + } + + if (absent) { + /* Below a component that is not there, nothing can be probed, and + * nothing needs to be: the spelling follows from the name. + */ + walk->parent_found = false; + if (name_by_rule(guest, host, sizeof(host)) < 0) + return CASEFOLD_ERROR; + } else { + probe_result_t verdict = resolve_component( + base_fd, out, len, guest, host, sizeof(host), &present); + + if (verdict == PROBE_ERROR) + return CASEFOLD_ERROR; + /* A fold means the sysroot holds an entry where the guest asked, + * under a spelling the guest did not use. Recorded for the caller + * because it is the one absent verdict that must not fall through + * to the host: something is already there. + */ + if (verdict == PROBE_FOLDED) + walk->folded = true; + absent = !present; + } + + if (append_leaf(out, outsz, &len, host, walk) < 0) + return CASEFOLD_ERROR; + } + + if (absent) + 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. + */ + if (follow_final && 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 new file mode 100644 index 00000000..7af7f033 --- /dev/null +++ b/src/syscall/casefold-walk.h @@ -0,0 +1,100 @@ +/* + * Case-exact path resolution + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Resolves a guest path to its host spelling one component at a time, applying + * the escape from casefold.h where a name cannot be stored as itself. Kept + * apart from the codec so the codec stays a leaf translation unit that a host + * unit test can link on its own. See docs/filenames.md. + */ + +#pragma once + +#include +#include + +#include "syscall/casefold.h" +#include "syscall/internal.h" + +/* True when guest names have to be reconciled with the volume at all: a sysroot + * is configured and it lives somewhere that folds case. On a byte-exact volume + * every name is stored as itself and nothing here does any work. + */ +bool casefold_active(void); + +/* Longest name the volume can hand back, in bytes: 255 UTF-16 code units at up + * to three UTF-8 bytes each. Larger than CASEFOLD_HOST_NAME_MAX, which bounds + * only the escapes elfuse writes. A literal name the host already holds, such + * as a full-length CJK one, is longer than any escape. Any buffer receiving a + * stored spelling has to be sized by this. + */ +#define CASEFOLD_STORED_NAME_MAX (CASEFOLD_UNIT_MAX * 3 + 1) + +typedef enum { + CASEFOLD_ERROR = -1, /* the probe failed; errno is set */ + CASEFOLD_FOUND = 0, /* every component resolved; out names the object */ + CASEFOLD_ABSENT = 1, /* out is where the object would have to live */ +} casefold_verdict_t; + +typedef struct { + /* Every component but the last resolved. Answers the create resolver's + * question "can this be created here", which a plain access(2) on a + * naively concatenated parent gets wrong, because that folds case. + */ + bool parent_found; + /* Offset in out of the final component, so a caller can split parent from + * leaf without a strrchr that could walk back into the host prefix. + */ + size_t leaf_offset; + /* Length of out before the final component was appended: truncating there + * yields the parent's spelling exactly. Recorded by the walk because only + * it knows whether a separator was inserted before the leaf. A prefix that + * already ends in one, as a root sysroot does, gets none, so a caller + * subtracting one from leaf_offset instead reconstructs an empty parent + * where the parent is the root. + */ + size_t parent_offset; + /* Some component's slot is held by an entry spelled differently, or by one + * the volume refuses to store. The path is absent to a byte-exact reader + * but the sysroot does hold something there, so a caller must report ENOENT + * rather than treat the path as unclaimed and look for it on the host. + */ + bool folded; +} casefold_walk_t; + +/* Resolve @guest_path, interpreted relative to @base_fd, into its host spelling + * in @out. @out is seeded with @base_host_prefix (the sysroot for an absolute + * guest path, the empty string for a dirfd-relative one) and grown a component + * at a time. Nothing is opened: each probe is a getattrlistat against + * the prefix accumulated so far, so the walk holds no descriptors and has no + * cleanup path. + * + * Per component, once the parent is spelled in @out: + * + * exists, spelled as asked -> the literal name + * exists, spelled differently -> the escape, whose slot is therefore free + * the volume refuses the name -> the escape + * absent -> the escape if that exists, otherwise the + * literal name for a fold-stable name and the + * escape for any other + * + * A folded component yields its escape even though nothing is there. That name + * provably does not exist and cannot fold onto the sibling occupying the slot, + * so a caller running its syscall against @out gets the ENOENT Linux would give + * for a wrong-case lookup, with no separate veto path. + * + * Once a component is absent the rest cannot be probed, and do not need to be: + * escaping depends only on the name, so the remainder is spelled directly. + * + * @follow_final rechecks the resolved object through symlinks, so a dangling + * link reports absent, matching what an access(2) existence probe would say. + */ +casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, + const char *base_host_prefix, + const char *guest_path, + bool follow_final, + char *out, + size_t outsz, + casefold_walk_t *walk); diff --git a/src/syscall/casefold.h b/src/syscall/casefold.h index e5d07a63..37311c68 100644 --- a/src/syscall/casefold.h +++ b/src/syscall/casefold.h @@ -54,17 +54,20 @@ #define CASEFOLD_SYM_BITS 12u /* Symbols the long tier spends on a guest name of n bytes: one carrying the - * length, then one per CASEFOLD_SYM_BITS of payload. + * length, then one per CASEFOLD_SYM_BITS of payload. Computed in size_t + * because the result sizes buffers. */ #define CASEFOLD_SYMBOLS(n) \ - (1u + (8u * (n) + CASEFOLD_SYM_BITS - 1u) / CASEFOLD_SYM_BITS) + ((size_t) 1 + \ + ((size_t) 8 * (n) + CASEFOLD_SYM_BITS - 1) / CASEFOLD_SYM_BITS) /* Longest host spelling any escaped name can take, in bytes. Each symbol is a * BMP code point, so three UTF-8 bytes. Buffers holding a host component must * be sized by this and not by NAME_MAX, which is a guest-side limit. */ #define CASEFOLD_HOST_NAME_MAX \ - (CASEFOLD_PREFIX_LEN + 3u * CASEFOLD_SYMBOLS(CASEFOLD_GUEST_NAME_MAX)) + (CASEFOLD_PREFIX_LEN + \ + (size_t) 3 * CASEFOLD_SYMBOLS(CASEFOLD_GUEST_NAME_MAX)) /* Every escape has to fit the volume's per-name limit, and both tiers spend * exactly one unit per output character: the hex tier emits ASCII digits, the diff --git a/src/syscall/path.c b/src/syscall/path.c index 138d7ed6..3997fb03 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -313,25 +313,6 @@ int path_translate_dirent_name(guest_fd_t dirfd, return 0; } -bool path_next_component(const char **pathp, const char **comp, size_t *len) -{ - const char *p = *pathp; - - while (*p == '/') - p++; - if (*p == '\0') { - *pathp = p; - return false; - } - - *comp = p; - while (*p != '\0' && *p != '/') - p++; - *len = (size_t) (p - *comp); - *pathp = p; - return true; -} - static bool path_component_is_dot(const char *comp, size_t len) { return len == 1 && comp[0] == '.'; diff --git a/src/syscall/path.h b/src/syscall/path.h index 8f9962ab..8a33456a 100644 --- a/src/syscall/path.h +++ b/src/syscall/path.h @@ -68,8 +68,30 @@ bool path_prefix_match(const char *path, const char *prefix, size_t plen); * from repeated slashes. Returns true with the component (not NUL-terminated) * reported through comp and len, leaving *pathp at its end; returns false once * only slashes or the terminating NUL remain. + * + * Inline beside path_component_copy, its usual companion, so a leaf module can + * walk a path without linking the rest of the translation layer. */ -bool path_next_component(const char **pathp, const char **comp, size_t *len); +static inline bool path_next_component(const char **pathp, + const char **comp, + size_t *len) +{ + const char *p = *pathp; + + while (*p == '/') + p++; + if (*p == '\0') { + *pathp = p; + return false; + } + + *comp = p; + while (*p != '\0' && *p != '/') + p++; + *len = (size_t) (p - *comp); + *pathp = p; + return true; +} /* Copy a counted component (not NUL-terminated, as path_next_component reports) * into dst and NUL-terminate it. Returns 0, or -1 with errno set to diff --git a/tests/test-casefold-walk-host.c b/tests/test-casefold-walk-host.c new file mode 100644 index 00000000..d38492da --- /dev/null +++ b/tests/test-casefold-walk-host.c @@ -0,0 +1,465 @@ +/* + * Native-host unit test for case-exact path resolution + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Drives casefold_resolve_at against a real directory, because the questions it + * answers are questions about the filesystem: does this name exist spelled the + * way the guest asked, is its slot taken by something spelled differently, and + * where would it have to live if it were created. A guest test could reach the + * same code, but only through a whole VM and only on a folding volume; here the + * fixtures are staged directly and the answers are inspected one component at a + * time. + * + * The resolver reads the sysroot configuration through two functions from the + * process-state layer, stubbed below so the test links the resolver and the + * codec and nothing else. + * + * Code under test: src/syscall/casefold-walk.c. A regression shows up as a + * wrong-case lookup that succeeds where Linux gives ENOENT, an escaped entry + * that stops resolving, a create aimed at the wrong directory, or an over-long + * path silently truncated to name a different file. + * + * Native macOS binary; no HVF entitlement needed. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "host-test-util.h" + +#include "syscall/casefold-walk.h" + +/* Stubs for the process-state symbols the resolver reads. Only casefold_active + * consults them, and the walk itself does not call it, so a fixed answer is + * enough to link. Declared here rather than by including the process-state + * header, which would pull the whole syscall layer in behind it. + */ +const char *proc_get_sysroot(void); +bool proc_sysroot_casefold_enabled(void); + +static char stub_sysroot[PATH_MAX]; +const char *proc_get_sysroot(void) +{ + return stub_sysroot[0] ? stub_sysroot : NULL; +} +bool proc_sysroot_casefold_enabled(void) +{ + return true; +} + +static int passes; +static int fails; +static char root[PATH_MAX]; +static bool volume_folds; + +static void ok(void) +{ + passes++; +} + +static void fail(const char *label, const char *detail) +{ + fails++; + fprintf(stderr, "FAIL %s: %s\n", label, detail); +} + +static void stage_file(const char *rel) +{ + char p[PATH_MAX]; + int fd; + + snprintf(p, sizeof(p), "%s/%s", root, rel); + fd = open(p, O_CREAT | O_WRONLY, 0644); + if (fd >= 0) + close(fd); +} + +static void stage_dir(const char *rel) +{ + char p[PATH_MAX]; + + snprintf(p, sizeof(p), "%s/%s", root, rel); + mkdir(p, 0755); +} + +/* Resolve @guest and compare the host spelling against @want_rel, which is + * relative to the fixture root. @want_verdict is the expected outcome. + */ +static void check(const char *label, + const char *guest, + casefold_verdict_t want_verdict, + const char *want_rel) +{ + char out[LINUX_PATH_MAX]; + char want[PATH_MAX]; + casefold_walk_t walk; + casefold_verdict_t got; + + got = casefold_resolve_at(AT_FDCWD, root, guest, false, out, sizeof(out), + &walk); + if (got != want_verdict) { + fail(label, "wrong verdict"); + fprintf(stderr, " guest %s -> verdict %d, expected %d (host %s)\n", + guest, (int) got, (int) want_verdict, + got == CASEFOLD_ERROR ? strerror(errno) : out); + return; + } + snprintf(want, sizeof(want), "%s%s%s", root, want_rel[0] ? "/" : "", + want_rel); + if (strcmp(out, want)) { + fail(label, "wrong host spelling"); + fprintf(stderr, " guest %s\n got %s\n expected %s\n", guest, + out, want); + return; + } + ok(); +} + +static void check_parent_found(const char *label, const char *guest, bool want) +{ + char out[LINUX_PATH_MAX]; + casefold_walk_t walk; + + if (casefold_resolve_at(AT_FDCWD, root, guest, false, out, sizeof(out), + &walk) == CASEFOLD_ERROR) { + fail(label, strerror(errno)); + return; + } + if (walk.parent_found != want) { + fail(label, want ? "parent should have been found" + : "parent should not have been found"); + return; + } + ok(); +} + +/* Names the codec escapes, spelled here so the expectations read literally. */ +static const char *esc(const char *guest) +{ + static char buf[4][CASEFOLD_HOST_NAME_MAX + 1]; + static int slot; + char *out = buf[slot++ % 4]; + + if (casefold_escape(guest, out, CASEFOLD_HOST_NAME_MAX + 1) < 0) + return ""; + return out; +} + +static void section_literal(void) +{ + char want[PATH_MAX]; + + /* A fold-stable name is stored as itself, so resolution is the identity + * plus an existence answer. + */ + check("lowercase file found", "/plain.txt", CASEFOLD_FOUND, "plain.txt"); + check("lowercase dir found", "/lowdir", CASEFOLD_FOUND, "lowdir"); + check("nested lowercase", "/lowdir/inner.txt", CASEFOLD_FOUND, + "lowdir/inner.txt"); + check("absent lowercase", "/nothere", CASEFOLD_ABSENT, "nothere"); + + /* A host-staged mixed-case name keeps its real spelling, which is what + * makes an unpacked rootfs reachable, so the literal probe must find it + * rather than reaching for the escape. + */ + check("host-staged mixed case", "/Makefile", CASEFOLD_FOUND, "Makefile"); + check("host-staged mixed-case dir", "/Documentation", CASEFOLD_FOUND, + "Documentation"); + check("nested under host-staged dir", "/Documentation/Guide.md", + CASEFOLD_FOUND, "Documentation/Guide.md"); + + /* The wrong case for a host-staged name is a Linux ENOENT. On a folding + * volume the literal slot is occupied by a different spelling, so the name + * resolves to its escape, which is absent, and the caller's own syscall + * then reports ENOENT with no separate veto. + */ + snprintf(want, sizeof(want), "%s", + volume_folds ? esc("makefile") : "makefile"); + check("wrong case is absent", "/makefile", CASEFOLD_ABSENT, want); +} + +static void section_escaped(void) +{ + char want[PATH_MAX]; + + /* An escaped entry is found through its escape, and reported under the host + * spelling the caller must actually use. + */ + check("escaped file found", "/Guest.Made", CASEFOLD_FOUND, + esc("Guest.Made")); + check("escaped dir found", "/GuestDir", CASEFOLD_FOUND, esc("GuestDir")); + snprintf(want, sizeof(want), "%s/deep.txt", esc("GuestDir")); + check("nested below an escaped dir", "/GuestDir/deep.txt", CASEFOLD_FOUND, + want); +} + +static void section_absent(void) +{ + char want[PATH_MAX]; + + /* Below an absent component nothing can be probed, and nothing needs to be: + * the spelling of the rest follows from the names alone. + */ + check("absent intermediate", "/nothere/child", CASEFOLD_ABSENT, + "nothere/child"); + snprintf(want, sizeof(want), "nothere/%s", esc("Child")); + check("absent intermediate, escaping tail", "/nothere/Child", + CASEFOLD_ABSENT, want); + + check_parent_found("leaf absent, parent found", "/lowdir/missing", true); + check_parent_found("intermediate absent", "/nothere/child", false); + check_parent_found("existing leaf", "/plain.txt", true); + + /* A create below an existing escaped directory must land inside it. */ + snprintf(want, sizeof(want), "%s/%s", esc("GuestDir"), esc("New.File")); + check("create below an escaped dir", "/GuestDir/New.File", CASEFOLD_ABSENT, + want); + snprintf(want, sizeof(want), "%s/new.file", esc("GuestDir")); + check("fold-stable create below an escaped dir", "/GuestDir/new.file", + CASEFOLD_ABSENT, want); +} + +static void section_shapes(void) +{ + char want[PATH_MAX]; + + /* An escape-shaped guest name is stored escaped, never probed literally, so + * it cannot collide with the encoding of a different name. + */ + snprintf(want, sizeof(want), "%s", esc(".ef=464f4f")); + check("escape-shaped guest name", "/.ef=464f4f", CASEFOLD_ABSENT, want); + + /* Non-ASCII always escapes, whatever the script. */ + check("cjk name", "/\xe6\x96\x87\xe6\xa1\xa3.txt", CASEFOLD_ABSENT, + esc("\xe6\x96\x87\xe6\xa1\xa3.txt")); + + /* Repeated and trailing separators name the same path. */ + check("redundant separators", "//lowdir///inner.txt", CASEFOLD_FOUND, + "lowdir/inner.txt"); + check("empty path resolves the prefix", "", CASEFOLD_FOUND, ""); +} + +static void section_symlink(void) +{ + char out[LINUX_PATH_MAX]; + casefold_walk_t walk; + + /* The probe stops at a link rather than following it, so a link is judged + * by its own name and an intermediate link is traversed by the kernel when + * the next prefix is probed. + */ + check("symlink resolved by its own name", "/link.to.lowdir", CASEFOLD_FOUND, + "link.to.lowdir"); + check("through a symlinked intermediate", "/link.to.lowdir/inner.txt", + CASEFOLD_FOUND, "link.to.lowdir/inner.txt"); + + /* A dangling link exists as a link but not as a target, so the answer + * depends on which the caller asked about. + */ + if (casefold_resolve_at(AT_FDCWD, root, "/dangling", false, out, + sizeof(out), &walk) == CASEFOLD_FOUND) + ok(); + else + fail("dangling link exists without following", "expected found"); + if (casefold_resolve_at(AT_FDCWD, root, "/dangling", true, out, sizeof(out), + &walk) == CASEFOLD_ABSENT) + ok(); + else + fail("dangling link is absent when followed", "expected absent"); + + /* A second hard link to a symlink is that same link under another name, + * and it resolves by the name the caller used. The volume reports the + * primary link's name for such an entry, so a probe that trusts the + * reported spelling alone rules it a fold and the walk reports absent, + * which is how linkat(2) of a symlink produced an entry lstat could not + * see. + */ + check("fold-stable second link to a symlink", "/second-link", + CASEFOLD_FOUND, "second-link"); + check("escaped second link to a symlink", "/Hard.Link", CASEFOLD_FOUND, + esc("Hard.Link")); +} + +static void section_limits(void) +{ + char guest[LINUX_PATH_MAX]; + char out[LINUX_PATH_MAX]; + casefold_walk_t walk; + size_t len = 0; + bool saw_toolong = false; + int last_ok = 0; + + /* A guest may build a path several times longer than the host accepts, and + * an escaped component roughly doubles it, so deep trees reach the host + * limit first. Grow the path a component at a time and require the boundary + * to be clean: every depth below it resolves and spells its last component + * in full, and the first depth past it reports ENAMETOOLONG. Silent + * truncation is the outcome that matters, because a truncated path names a + * different file. + */ + for (int depth = 1; depth <= 200; depth++) { + char want_leaf[CASEFOLD_HOST_NAME_MAX + 1]; + char comp[64]; + int add; + + snprintf(comp, sizeof(comp), "DirectoryWithAnExcessivelyLongName%04d", + depth); + add = snprintf(guest + len, sizeof(guest) - len, "/%s", comp); + if (add < 0 || (size_t) add >= sizeof(guest) - len) + break; /* the guest path itself reached Linux PATH_MAX */ + len += (size_t) add; + + if (casefold_resolve_at(AT_FDCWD, root, guest, false, out, sizeof(out), + &walk) == CASEFOLD_ERROR) { + if (errno == ENAMETOOLONG) + saw_toolong = true; + else + fail("deep path", strerror(errno)); + break; + } + if (casefold_escape(comp, want_leaf, sizeof(want_leaf)) < 0) { + fail("deep path", "could not spell the expected leaf"); + break; + } + if (strcmp(out + walk.leaf_offset, want_leaf)) { + fail("deep path", "last component was truncated"); + fprintf(stderr, " depth %d, got %s\n", depth, + out + walk.leaf_offset); + break; + } + last_ok = depth; + } + + if (saw_toolong && last_ok > 0) + ok(); + else + fail("over-long host path", saw_toolong + ? "no depth resolved at all" + : "never reported ENAMETOOLONG"); + + /* A caller buffer smaller than the prefix is the same class of failure. */ + char tiny[8]; + if (casefold_resolve_at(AT_FDCWD, root, "/plain.txt", false, tiny, + sizeof(tiny), &walk) == CASEFOLD_ERROR && + errno == ENAMETOOLONG) + ok(); + else + fail("caller buffer too small", "expected ENAMETOOLONG"); +} + +/* Does this volume fold case? The resolver behaves the same either way from the + * guest's point of view, but the host spelling it reports differs, so the + * expectations have to know. + */ +static bool probe_folds(void) +{ + char a[PATH_MAX]; + char b[PATH_MAX]; + int fd; + + snprintf(a, sizeof(a), "%s/FoldProbe", root); + snprintf(b, sizeof(b), "%s/foldprobe", root); + fd = open(a, O_CREAT | O_WRONLY, 0644); + if (fd >= 0) + close(fd); + fd = open(b, O_CREAT | O_EXCL | O_WRONLY, 0644); + if (fd >= 0) { + close(fd); + unlink(b); + unlink(a); + return false; + } + unlink(a); + return true; +} + +int main(int argc, char **argv) +{ + const char *base = argc > 1 ? argv[1] : getenv("TMPDIR"); + char host[CASEFOLD_HOST_NAME_MAX + 1]; + char p[PATH_MAX]; + + if (!base || base[0] == '\0') + base = "/tmp"; + snprintf(root, sizeof(root), "%s/elfuse-walk-XXXXXX", base); + if (!mkdtemp(root)) { + fprintf(stderr, "cannot create a scratch directory in %s: %s\n", base, + strerror(errno)); + return 1; + } + snprintf(stub_sysroot, sizeof(stub_sysroot), "%s", root); + volume_folds = probe_folds(); + + /* Host-staged fixtures keep their real spelling, exactly as a rootfs + * unpacked from a tarball would. + */ + stage_file("plain.txt"); + stage_dir("lowdir"); + stage_file("lowdir/inner.txt"); + stage_file("Makefile"); + stage_dir("Documentation"); + stage_file("Documentation/Guide.md"); + snprintf(p, sizeof(p), "%s/link.to.lowdir", root); + if (symlink("lowdir", p) < 0 && errno != EEXIST) + fprintf(stderr, "warning: symlink fixture failed: %s\n", + strerror(errno)); + snprintf(p, sizeof(p), "%s/dangling", root); + if (symlink("no-such-target", p) < 0 && errno != EEXIST) + fprintf(stderr, "warning: dangling fixture failed: %s\n", + strerror(errno)); + + /* Second hard links to a symlink, one fold-stable and one escaped. + * getattrlistat(ATTR_CMN_NAME) reports the primary link's name for these + * (observed on APFS; a second link to a regular file reports itself), which + * is the aliasing section_symlink pins the probe against. linkat without + * AT_SYMLINK_FOLLOW so the link itself is linked, dangling target and all. + */ + { + char lp[PATH_MAX]; + + snprintf(lp, sizeof(lp), "%s/second-link", root); + if (linkat(AT_FDCWD, p, AT_FDCWD, lp, 0) < 0 && errno != EEXIST) + fprintf(stderr, "warning: second-link fixture failed: %s\n", + strerror(errno)); + if (casefold_escape("Hard.Link", host, sizeof(host)) == 0) { + snprintf(lp, sizeof(lp), "%s/%s", root, host); + if (linkat(AT_FDCWD, p, AT_FDCWD, lp, 0) < 0 && errno != EEXIST) + fprintf(stderr, "warning: escaped-link fixture failed: %s\n", + strerror(errno)); + } + } + + /* Entries the guest would have created, staged under the spelling the + * escape rule gives them. + */ + if (casefold_escape("Guest.Made", host, sizeof(host)) == 0) + stage_file(host); + if (casefold_escape("GuestDir", host, sizeof(host)) == 0) { + char rel[PATH_MAX]; + stage_dir(host); + snprintf(rel, sizeof(rel), "%s/deep.txt", host); + stage_file(rel); + } + + section_literal(); + section_escaped(); + section_absent(); + section_shapes(); + section_symlink(); + section_limits(); + + remove_tree(root); + printf("test-casefold-walk-host: %d passed, %d failed - %s\n", passes, + fails, fails ? "FAIL" : "PASS"); + return fails ? 1 : 0; +} From 0ff34bd96a3bdcbb15fbb819986d318a5c9b8646 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Sun, 26 Jul 2026 22:19:46 +0200 Subject: [PATCH 03/11] Resolve sysroot paths through one case-exact walk 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. The dirent decode is scoped the way the resolvers are: an entry decodes only when the directory it was read from lies under the sysroot, answered once per read from the descriptor's own host path. The process-wide fold switch alone cannot make that call: a directory reached through the host fallback lists names elfuse never wrote, and decoding an escape-shaped literal there reports a name the directory does not contain and that no open resolves, while hiding the entry's real name behind it. 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. --- .ci/check-matrix-lists.sh | 71 + Makefile | 1 - docs/internals.md | 3 +- docs/testing.md | 22 + docs/usage.md | 21 +- mk/analysis.mk | 2 + mk/tests.mk | 75 +- src/syscall/casefold-walk.c | 37 +- src/syscall/casefold-walk.h | 7 + src/syscall/fs.c | 51 +- src/syscall/path.c | 345 ++++- src/syscall/path.h | 33 +- src/syscall/proc-state.c | 265 +++- src/syscall/sidecar.c | 2217 ---------------------------- src/syscall/sidecar.h | 49 - src/syscall/syscall.c | 5 +- tests/test-case-collision.c | 161 +- tests/test-matrix.sh | 48 +- tests/test-sysroot-case-exact.c | 22 +- tests/test-sysroot-host-fallback.c | 4 +- tests/test-sysroot-name-relative.c | 482 ++++++ tests/test-sysroot-name-unique.c | 212 +++ tests/test-util.h | 98 ++ 23 files changed, 1697 insertions(+), 2534 deletions(-) create mode 100755 .ci/check-matrix-lists.sh delete mode 100644 src/syscall/sidecar.c delete mode 100644 src/syscall/sidecar.h create mode 100644 tests/test-sysroot-name-relative.c create mode 100644 tests/test-sysroot-name-unique.c diff --git a/.ci/check-matrix-lists.sh b/.ci/check-matrix-lists.sh new file mode 100755 index 00000000..2f49d8a9 --- /dev/null +++ b/.ci/check-matrix-lists.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +# Keep the test-matrix skip lists honest. +# +# tests/test-matrix.sh runs each test under two runners and carries a skip list +# per runner: QEMU_SKIP for tests the reference kernel cannot adjudicate, +# ELFUSE_SKIP for tests that need a writable byte-exact root the elfuse lane +# does not have. Both lists are matched against a test's label by string, and a +# string that matches nothing fails silently: the suite still reports success, +# having run one test fewer than the reader believes. +# +# Two ways that goes wrong, both of which cost coverage without costing a red +# build, and neither of which the suite itself can notice: +# +# 1. A label in a skip list that no longer names a registered test. Dead +# config: it reads as deliberate coverage policy while guarding nothing, +# and it hides the rename that orphaned it. +# 2. A label in both lists at once. The test is then skipped under every +# runner the matrix has, so it never executes anywhere while still looking +# registered. +# +# The pass counts themselves are not checked here. test-matrix.sh already holds +# each lane to its EXPECTED_BASELINES floor at runtime, which is a stronger +# check than anything static, and duplicating it would only add a second number +# to keep in step. + +set -e -u -o pipefail + +MATRIX="${1:-$(dirname "$0")/../tests/test-matrix.sh}" + +if [ ! -r "$MATRIX" ]; then + echo "check-matrix-lists: cannot read $MATRIX" >&2 + exit 2 +fi + +# Body of a NAME="..." block spanning lines, one entry per line. +list_entries() +{ + sed -n "/^$1=\"/,/^\"\$/p" "$MATRIX" | sed '1d;$d' | tr -s ' \t' '\n' \ + | grep -v '^$' || true +} + +# Labels registered with the test_* wrappers: the argument after "$runner". +registered_labels() +{ + grep -oE '\btest_(check|rc|pipe) +"\$runner" +"[^"]+"' "$MATRIX" \ + | sed -E 's/.*"\$runner" +"([^"]+)"$/\1/' | sort -u +} + +ret=0 +registered="$(registered_labels)" + +for list in QEMU_SKIP ELFUSE_SKIP; do + while IFS= read -r label; do + [ -n "$label" ] || continue + if ! printf '%s\n' "$registered" | grep -qxF "$label"; then + echo "Error: $list names '$label', which no test_* call registers" >&2 + ret=1 + fi + done < <(list_entries "$list") +done + +while IFS= read -r label; do + [ -n "$label" ] || continue + if list_entries QEMU_SKIP | grep -qxF "$label"; then + echo "Error: '$label' is in both QEMU_SKIP and ELFUSE_SKIP, so it never runs" >&2 + ret=1 + fi +done < <(list_entries ELFUSE_SKIP) + +exit $ret diff --git a/Makefile b/Makefile index 14f6700b..cd9cf505 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,6 @@ SRCS := \ syscall/mem.c \ syscall/path.c \ syscall/fuse.c \ - syscall/sidecar.c \ syscall/casefold.c \ syscall/casefold-walk.c \ syscall/chown-overlay.c \ diff --git a/docs/internals.md b/docs/internals.md index b68f3f5c..a8e6ea5f 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -97,7 +97,8 @@ Key files: | `src/syscall/fs.c`, `fs-stat.c`, `fs-xattr.c` | filesystem syscalls | | `src/syscall/io.c`, `poll.c`, `fd.c`, `fdtable.c` | I/O, polling, FD lifecycle and table | | `src/syscall/path.c` | centralized guest-to-host path resolution | -| `src/syscall/sidecar.c` | case-fold sidecar tokens for case-insensitive macOS volumes | +| `src/syscall/casefold.c` | guest/host filename encoding for case-folding volumes (see [filenames.md](filenames.md)) | +| `src/syscall/casefold-walk.c` | case-exact path resolution against the sysroot | | `src/syscall/fuse.c` | guest-internal FUSE transport and minimal VFS | | `src/syscall/inotify.c` | inotify via kqueue `EVFILT_VNODE` | | `src/syscall/sysvipc.c` | System V shared memory and semaphores | diff --git a/docs/testing.md b/docs/testing.md index c42e1b72..2692171c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -102,6 +102,9 @@ What they do: - the BusyBox applet smoke suite (auto-resolved from `externals/test-fixtures/aarch64-musl/staticbin/bin/busybox` or downloaded into `build/busybox` on first run) + - the filename codec and case-exact path resolution unit tests + - the sysroot filename lanes: one representation per name, non-ASCII + and normalization, full-length names, and host-staged escape shapes - the sysroot procfs exec, FUSE-on-Alpine, and `timeout=0` regressions - the Rosetta CLI gating regressions - the hot-syscall guardrail (`tests/test-bench-guardrail.sh`) @@ -178,6 +181,25 @@ runs in both `elfuse-aarch64` and `qemu-aarch64` modes, so most tests are exercised twice per matrix run: once against `build/elfuse`, once against the real kernel. +`ELFUSE_SKIP` is the same mechanism pointing the other way: tests that run only +against the reference kernel. A test belongs there when it needs something the +elfuse lane cannot provide: most often a writable, byte-exact root, which that +lane has no sysroot to give and which the macOS root is not. A skip is not a +pass, so the `elfuse-aarch64` row of `EXPECTED_BASELINES` does not move when a +test is added to the list. + +Both lists match a test by its label, and a label matching nothing fails +silently while still reading as deliberate policy, so +`.ci/check-matrix-lists.sh` rejects a label that names no registered test and a +label present in both lists, which would run under no runner at all. + +The filename tests are `ELFUSE_SKIP`'s main occupants. They assert that names +differing only in case, or only in Unicode normalization, stay distinct, +exactly what a case-folding host volume is entitled to get wrong. Running them +against the VM's tmpfs turns those expectations into measurements; their +elfuse-side coverage is the `make check` sysroot lanes, where a real sysroot +exists. + The x86_64 mode is narrower: it aggregates the Rosetta-specific acceptance scripts and their per-binary summaries into the same matrix runner, including the Rosetta thread/signal audit smoke, the LuaJIT guest-JIT probe, and the diff --git a/docs/usage.md b/docs/usage.md index 35aa6276..dc6ed3d4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -155,8 +155,9 @@ expected interpreter path (for example `/lib/ld-musl-aarch64.so.1` or Practical notes: -- The sysroot is consulted only for guest absolute paths; relative paths still - resolve from the guest working directory. +- The sysroot is consulted for guest absolute paths; relative paths resolve + from the guest working directory, and inside the sysroot they receive the + same byte-exact name semantics as absolute ones. - `/tmp`, `/var/tmp`, and any `.ccache` directory are backed by the sysroot alone. A guest's temporary files go there so they cannot collide on a case-insensitive host `/tmp`, and every operation on those paths, including @@ -166,11 +167,17 @@ Practical notes: else. - The sysroot setting is preserved across guest `fork` and `execve`, so spawned children see the same view of the filesystem. -- On case-insensitive macOS volumes, `elfuse` maintains per-directory - sidecar token files so case-colliding Linux names remain distinct, and - lookups verify the on-disk spelling byte-for-byte: a name that differs - from an existing entry only by case (or Unicode normalization form) - reports `ENOENT`, matching Linux semantics instead of APFS's folding. +- On case-insensitive macOS volumes, `elfuse` keeps Linux's byte-exact name + semantics: a lookup whose spelling differs from the on-disk entry only by + case or Unicode normalization form reports `ENOENT`, and a name the volume + cannot store as itself is held under an escaped `.ef=` spelling the + guest never sees. Guest names keep their full 255 bytes either way. + [docs/filenames.md](filenames.md) describes the model. +- A sysroot holding `.ef_` entries plus a `.elfuse_case_index` file + per directory was written by a different on-disk encoding and is not + readable: those entries decode to nothing and surface under their literal + host names. Recreate the sysroot: unpack the rootfs again, with + `--create-sysroot` if the volume folds case. - Use `--create-sysroot PATH` if the host filesystem is case-insensitive (default APFS) and the sysroot is being provisioned for the first time; `elfuse` creates a case-sensitive APFS sparsebundle, mounts it diff --git a/mk/analysis.mk b/mk/analysis.mk index 246c948f..1ad9e77a 100644 --- a/mk/analysis.mk +++ b/mk/analysis.mk @@ -28,6 +28,8 @@ analyze: check-format: check-syscall-dispatch @echo " FMT src/ tests/ (check)" $(Q)$(CLANG_FORMAT) --dry-run --Werror $(C_FORMAT_FILES) + @echo " MATRIX skip lists" + $(Q)bash .ci/check-matrix-lists.sh @printf " SHCHK %d scripts\n" $(words $(SHELL_SCRIPTS)) @fail=0; \ for f in $(SHELL_SCRIPTS); do \ diff --git a/mk/tests.mk b/mk/tests.mk index f09df71c..eb841085 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -18,7 +18,8 @@ test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \ test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \ test-linkat-symlink-fallback test-casefold-host \ - test-casefold-walk-host \ + test-casefold-walk-host test-sysroot-name-unique \ + test-sysroot-name-relative \ probe-volume-naming perf ## Build and run the assembly hello world test @@ -100,6 +101,10 @@ check-sanitizer: $(ELFUSE_BIN) $(TEST_DEPS) \ @$(BUILD_DIR)/test-casefold-host @printf "\n$(BLUE)━━━ case-exact path resolution unit test ━━━$(RESET)\n" @$(BUILD_DIR)/test-casefold-walk-host + @printf "\n$(BLUE)━━━ one on-disk name per guest name ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-unique + @printf "\n$(BLUE)━━━ relative and dirfd-relative names ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-relative ## Run the unit test suite plus busybox applet validation check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @@ -125,6 +130,10 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(BUILD_DIR)/test-casefold-host @printf "\n$(BLUE)━━━ case-exact path resolution unit test ━━━$(RESET)\n" @$(BUILD_DIR)/test-casefold-walk-host + @printf "\n$(BLUE)━━━ one on-disk name per guest name ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-unique + @printf "\n$(BLUE)━━━ relative and dirfd-relative names ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-relative @printf "\n$(BLUE)━━━ shebang parser unit test ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-shebang-host @printf "\n$(BLUE)━━━ proctitle argv-tail regression ━━━$(RESET)\n" @@ -268,7 +277,7 @@ test-case-collision-fallback: $(ELFUSE_BIN) $(BUILD_DIR)/test-case-collision $(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-case-collision ## Host paths outside the sysroot must stay reachable when the sysroot is -## case-insensitive (sidecar active): the sidecar walk defers to the +## case-insensitive (case-exact walk active): the walk defers to the ## resolver's host-literal fallback instead of vetoing it with ENOENT. ## Regression test for the test-matrix "musl dyn" coreutils failures. test-sysroot-host-fallback: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-host-fallback @@ -325,10 +334,10 @@ test-sysroot-tmp-remove: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-tmp-remove ## Wrong-case (and wrong-normalization) lookups must fail with ENOENT: ## Linux treats names as byte strings, while APFS resolves them case- and -## normalization-insensitively. The sidecar walk verifies the on-disk -## spelling of every unmapped component instead of trusting the folded +## normalization-insensitively. The case-exact walk verifies the on-disk +## spelling of every fold-stable component instead of trusting the folded ## probe. Stages exact-case fixtures host-side; the guest asserts folded -## spellings do not resolve. The normalization probes require the sidecar, +## spellings do not resolve. The normalization probes require the walk, ## so the guest skips them when the staging volume is case-sensitive. test-sysroot-case-exact: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-case-exact @set -e; \ @@ -348,6 +357,62 @@ test-sysroot-case-exact: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-case-exact exit 1; \ fi +# A guest name whose spelling the volume cannot hold is stored escaped, so a +# directory can hold a mixture of literal and escaped entries. Each guest name +# must stay reachable through exactly one of them, and the on-disk spellings +# must never surface. The recipe asserts the host-side half afterwards: an +# escaped entry whose decoded name is also present literally would be a name +# reachable two ways, and a leftover entry would be one the teardown could not +# name. +## Each guest name has exactly one on-disk representation +test-sysroot-name-unique: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-unique + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-name-unique; \ + left=$$(ls -A "$$tmpdir/name-unique" 2>/dev/null | wc -l | tr -d ' '); \ + if [ "$$left" != 0 ]; then \ + printf "$(RED)FAIL$(RESET) %s entries survived the teardown\n" "$$left"; \ + ls -Ab "$$tmpdir/name-unique"; \ + exit 1; \ + fi + +# A guest names one file two ways: absolutely, and relative to its working +# directory or a directory descriptor. Both have to reach it, which is not +# automatic when the name is stored under an escaped spelling: a relative name +# has no leading component to key the sysroot resolvers on, only the descriptor +# it is measured against. Tree walkers reach every name this way. +## Relative and dirfd-relative names resolve to the same file as absolute ones +test-sysroot-name-relative: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-relative + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + outside="$$tmpdir-outside"; \ + mkdir -p "$$outside"; \ + trap 'rm -rf "$$tmpdir" "$$tmpdir-outside"' EXIT; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-name-relative "$$outside"; \ + stray=$$(ls -A "$$tmpdir/name-relative" \ + | grep -v '^\.ef=' | grep -cvE '^(walkdir|slashfile|notdirfile)$$' || true); \ + if [ "$$stray" != 0 ]; then \ + printf "$(RED)FAIL$(RESET) %s name(s) stored unescaped\n" "$$stray"; \ + ls -Ab "$$tmpdir/name-relative"; \ + exit 1; \ + fi; \ + if [ ! -d "$$tmpdir/name-relative/walkdir" ] || \ + [ ! -f "$$tmpdir/name-relative/slashfile" ] || \ + [ ! -f "$$tmpdir/name-relative/notdirfile" ]; then \ + printf "$(RED)FAIL$(RESET) a fold-stable fixture was escaped\n"; \ + ls -Ab "$$tmpdir/name-relative"; \ + exit 1; \ + fi; \ + if [ ! -e "$$outside/Outside.Rel" ] || [ ! -e "$$outside/Outside.Abs" ]; then \ + printf "$(RED)FAIL$(RESET) a name outside the sysroot was not stored literally\n"; \ + ls -Ab "$$outside"; \ + exit 1; \ + fi + # Build APFS-side dirents whose UTF-8 byte length exceeds Linux # NAME_MAX (255). 89 copies of U+3042 (3-byte UTF-8) plus a 1-byte # ASCII tag = 268 bytes per name; the guest cannot forge this via diff --git a/src/syscall/casefold-walk.c b/src/syscall/casefold-walk.c index c6b1607b..27c1bc03 100644 --- a/src/syscall/casefold-walk.c +++ b/src/syscall/casefold-walk.c @@ -32,10 +32,18 @@ bool casefold_active(void) typedef enum { PROBE_ERROR = -1, - PROBE_EXACT = 0, /* an entry exists spelled exactly as asked */ - PROBE_FOLDED = 1, /* an entry exists, but under a different spelling */ - PROBE_ABSENT = 2, /* nothing is there */ - PROBE_UNUSABLE = 3 /* the volume refuses to hold this name at all */ + PROBE_EXACT = 0, /* an entry exists spelled exactly as asked */ + PROBE_FOLDED = 1, /* an entry exists, but under a different spelling */ + PROBE_ABSENT = 2, /* nothing is there */ + PROBE_UNUSABLE = 3, /* the volume refuses to hold this name at all */ + /* Nothing is there because a component of the prefix is not a directory. + * Kept apart from PROBE_ABSENT because the two answer differently for a + * caller deciding whether the sysroot has a claim on the path: resolution + * stopped inside the tree (path_resolution(7)), so the path is the + * sysroot's to answer for and owes ENOTDIR rather than falling through to + * a host file that merely shares the literal spelling. + */ + PROBE_NOTDIR = 4 } probe_result_t; /* Scan the directory holding @path for an entry spelled exactly @leaf. Only @@ -95,7 +103,9 @@ static probe_result_t probe_by_readdir(host_fd_t base_fd, */ if (fstatat(base_fd, path, &st, AT_SYMLINK_NOFOLLOW) == 0) return PROBE_FOLDED; - return errno == ENOENT || errno == ENOTDIR ? PROBE_ABSENT : PROBE_ERROR; + if (errno == ENOTDIR) + return PROBE_NOTDIR; + return errno == ENOENT ? PROBE_ABSENT : PROBE_ERROR; } /* Does an entry spelled exactly @leaf sit at @path? @@ -149,8 +159,9 @@ static probe_result_t probe_exact(host_fd_t base_fd, switch (errno) { case ENOENT: - case ENOTDIR: return PROBE_ABSENT; + case ENOTDIR: + return PROBE_NOTDIR; case EILSEQ: case EINVAL: /* The volume will not hold this byte sequence as a name, so it can @@ -327,9 +338,8 @@ static probe_result_t resolve_component(host_fd_t base_fd, * the name alone, which is what keeps two processes creating colliding * names off the same slot. */ - if (verdict == PROBE_ABSENT) - return name_by_rule(guest, host, hostsz) < 0 ? PROBE_ERROR - : PROBE_ABSENT; + if (verdict == PROBE_ABSENT || verdict == PROBE_NOTDIR) + return name_by_rule(guest, host, hostsz) < 0 ? PROBE_ERROR : verdict; /* The slot is taken by a different spelling, or refused outright, so the * name belongs at its escape even though nothing is there yet. Reported as * folded rather than absent: the two differ to a caller deciding whether @@ -363,6 +373,7 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, walk->parent_offset = 0; walk->leaf_offset = 0; walk->folded = false; + walk->notdir = false; len = str_copy_trunc(out, base_host_prefix ? base_host_prefix : "", outsz); if (len >= outsz) { @@ -408,6 +419,14 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, */ if (verdict == PROBE_FOLDED) walk->folded = true; + /* Resolution stopped at a component that is not a directory, so + * the sysroot has answered and the caller must not look for the + * path on the host. Recorded rather than returned immediately so + * out still receives the remaining components: a caller reporting + * the error issues its own syscall against the whole spelling. + */ + if (verdict == PROBE_NOTDIR) + walk->notdir = true; absent = !present; } diff --git a/src/syscall/casefold-walk.h b/src/syscall/casefold-walk.h index 7af7f033..0e1dbb2f 100644 --- a/src/syscall/casefold-walk.h +++ b/src/syscall/casefold-walk.h @@ -62,6 +62,13 @@ typedef struct { * rather than treat the path as unclaimed and look for it on the host. */ bool folded; + /* Some component of the path is not a directory, so resolution stopped + * there (path_resolution(7)) and everything below it owes ENOTDIR. Absent + * for the same reason folded is: the path is not there, but the sysroot + * decided that, so a caller must report the error rather than treat the + * path as unclaimed and look for it on the host. + */ + bool notdir; } casefold_walk_t; /* Resolve @guest_path, interpreted relative to @base_fd, into its host spelling diff --git a/src/syscall/fs.c b/src/syscall/fs.c index a7ce56af..14259ba9 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -39,7 +39,6 @@ #include "syscall/path.h" #include "syscall/poll.h" /* epoll_dup_fd */ #include "syscall/proc.h" -#include "syscall/sidecar.h" /* Linux dirent64 layout. */ typedef struct { @@ -483,27 +482,6 @@ int64_t sys_openat_path(guest_t *g, int linux_flags, int mode) { - if (linux_flags & LINUX_O_CREAT) { - int sidecar_fd = - sidecar_openat(dirfd, pathp, linux_flags, (mode_t) mode); - if (sidecar_fd != (int) SIDECAR_NOT_HANDLED) { - if (sidecar_fd < 0) - return linux_errno(); - int type = opened_fd_type(sidecar_fd, linux_flags); - if (type < 0) { - close_keep_errno(sidecar_fd); - return linux_errno(); - } - int guest_fd = fd_alloc_opened_host(sidecar_fd, type, linux_flags, - -1, NULL, NULL); - if (guest_fd < 0) { - close_keep_errno(sidecar_fd); - return linux_errno(); - } - return guest_fd; - } - } - path_translation_t tx; unsigned int tx_flags = (linux_flags & LINUX_O_NOFOLLOW) ? PATH_TR_NOFOLLOW : PATH_TR_NONE; @@ -1621,6 +1599,11 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) */ uint8_t entry_buf[280]; + /* One answer per call, not per entry: which side of the sysroot boundary + * the stream reads from is a property of the directory. + */ + const bool dir_holds_escapes = path_dirent_dir_holds_escapes(dirfd(dir)); + while (1) { /* Save position BEFORE readdir so getdents emulation can rewind if the * entry does not fit. macOS telldir returns an opaque cookie -- @@ -1632,10 +1615,8 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) break; char guest_name[NAME_MAX + 1]; - int name_rc = path_translate_dirent_name(fd, de->d_name, guest_name, - sizeof(guest_name)); - if (name_rc > 0) - continue; + int name_rc = path_translate_dirent_name( + dir_holds_escapes, de->d_name, guest_name, sizeof(guest_name)); if (name_rc < 0) { /* macOS APFS accepts UTF-8 filenames whose byte length exceeds * Linux NAME_MAX (255). A guest libc cannot represent such a name @@ -1957,10 +1938,6 @@ int64_t sys_unlinkat(guest_t *g, int dirfd, uint64_t path_gva, int flags) if (!validate_at_flags(flags, LINUX_AT_REMOVEDIR)) return -LINUX_EINVAL; - int64_t sidecar_rc = sidecar_unlinkat(dirfd, path, flags); - if (sidecar_rc != SIDECAR_NOT_HANDLED) - return sidecar_rc; - path_translation_t tx; int64_t rc = read_translated_path(g, dirfd, path_gva, PATH_TR_CREATE, path, &tx); @@ -2005,10 +1982,6 @@ int64_t sys_mkdirat(guest_t *g, int dirfd, uint64_t path_gva, int mode) if (guest_read_str(g, path_gva, path, sizeof(path)) < 0) return -LINUX_EFAULT; - int64_t sidecar_rc = sidecar_mkdirat(dirfd, path, (mode_t) mode); - if (sidecar_rc != SIDECAR_NOT_HANDLED) - return sidecar_rc; - path_translation_t tx; int64_t rc = read_translated_path( g, dirfd, path_gva, PATH_TR_CREATE | PATH_TR_CREATE_PARENTS, path, &tx); @@ -2063,11 +2036,6 @@ int64_t sys_renameat2(guest_t *g, return -LINUX_EINVAL; } - int64_t sidecar_rc = - sidecar_renameat(olddirfd, oldpath, newdirfd, newpath, flags); - if (sidecar_rc != SIDECAR_NOT_HANDLED) - return sidecar_rc; - if (path_translate_at(olddirfd, oldpath, PATH_TR_NOFOLLOW, &old_tx) < 0 || path_translate_at(newdirfd, newpath, PATH_TR_CREATE | PATH_TR_NOFOLLOW, &new_tx) < 0) @@ -2392,11 +2360,6 @@ int64_t sys_linkat(guest_t *g, if (!validate_at_flags(flags, LINUX_AT_SYMLINK_FOLLOW)) return -LINUX_EINVAL; - int64_t sidecar_rc = - sidecar_linkat(olddirfd, oldpath, newdirfd, newpath, flags); - if (sidecar_rc != SIDECAR_NOT_HANDLED) - return sidecar_rc; - unsigned int old_flags = (flags & LINUX_AT_SYMLINK_FOLLOW) ? PATH_TR_NONE : PATH_TR_NOFOLLOW; if (path_translate_at(olddirfd, oldpath, old_flags, &old_tx) < 0 || diff --git a/src/syscall/path.c b/src/syscall/path.c index 3997fb03..f8ca1b46 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -19,10 +19,10 @@ #include "runtime/procemu.h" #include "syscall/abi.h" +#include "syscall/casefold-walk.h" #include "syscall/fuse.h" #include "syscall/path.h" #include "syscall/proc.h" -#include "syscall/sidecar.h" #ifndef MAXSYMLINKS #define MAXSYMLINKS 40 @@ -147,10 +147,21 @@ int path_check_intercept_access(const struct stat *st, int mode, int flags) return -1; } +/* True when @path names a directory by ending in one or more separators. "/" + * itself does not count: it is the root, not an assertion about a leaf. + */ +static bool path_has_trailing_slash(const char *path) +{ + size_t len = path ? strlen(path) : 0; + + return len > 1 && path[len - 1] == '/'; +} + /* Forward-declared: defined below dirfd_guest_base_path(), which it needs. */ static int path_check_relative_sysroot_containment(guest_fd_t dirfd, const char *path, - unsigned int flags); + unsigned int flags, + bool *in_sysroot); int path_translate_at(guest_fd_t dirfd, const char *path, @@ -165,8 +176,8 @@ int path_translate_at(guest_fd_t dirfd, /* Only the fields read on the no-rewrite fast path need explicit defaults; * proc_path / guest_buf / host_buf are read-after-written by their * respective resolvers. memset of all three 4 KiB buffers would add ~12 KiB - * of zeroing per call, which became visible at ~30 calls per dynamic-linker - * startup after the sidecar caches dropped the rest of the openat overhead. + * of zeroing per call, which is visible at ~30 calls per dynamic-linker + * startup. */ tx->guest_path = path; tx->intercept_path = path; @@ -203,8 +214,8 @@ int path_translate_at(guest_fd_t dirfd, * stay on the sysroot path so the synthetic-directory intercepts keep * answering for them. The resolver rejects "..", embedded '/', and * empty names with EACCES. The early return skips sysroot resolution, - * the relative-containment recheck, and the sidecar lookup: the backing - * path is absolute, self-contained, and must never be sidecar-mapped. + * the relative-containment recheck, and the casefold walk: the backing + * path is absolute, self-contained, and must never be escape-mapped. * is_dev_shm signals the redirect to callers, which must force nofollow * on the host call; see dev_shm_resolve_path() for that invariant. */ @@ -218,12 +229,6 @@ int path_translate_at(guest_fd_t dirfd, } errno = 0; - if ((flags & PATH_TR_CREATE) && sidecar_active() && - sidecar_path_targets_reserved_name(tx->guest_path)) { - errno = ENOENT; - return -1; - } - if (flags & PATH_TR_CREATE) { tx->host_path = path_resolve_sysroot_create_path( tx->guest_path, tx->host_buf, sizeof(tx->host_buf), @@ -249,25 +254,48 @@ int path_translate_at(guest_fd_t dirfd, * collapses ".." and any symlink indirection, including an absolute * target, before the prefix check runs. */ + bool relative_in_sysroot = false; if (tx->host_path && tx->guest_path[0] != '/' && proc_get_sysroot() && - path_check_relative_sysroot_containment(dirfd, tx->guest_path, flags) < - 0) { + path_check_relative_sysroot_containment(dirfd, tx->guest_path, flags, + &relative_in_sysroot) < 0) { tx->host_path = NULL; if (errno == 0) errno = ELOOP; } - /* Sidecar only runs after sysroot resolution succeeds. If the resolver - * rejected the path (e.g. nofollow containment violation), sidecar must not - * be allowed to walk an alternate index and resurrect the rejected target. + /* A relative name has no leading component for the resolvers above to key + * on, so they hand it back untouched, but it still names a file that may + * be stored under an escaped spelling. Resolve it the same way, seeded from + * the descriptor it is measured against rather than from the sysroot. + * Without this the two ways of naming one file disagree: a create through a + * relative name lands beside the entry an absolute create already made, and + * O_EXCL on a name that exists succeeds. + * + * Only for a name the sysroot actually claims. Outside it the guest is + * looking at the host filesystem, where an absolute path is passed through + * untouched and a relative one has to match; escaping here would leave + * elfuse's spellings in directories it does not own. The containment check + * above already made that call, and now reports it. + * + * Runs after that check, so a path it rejected is not resolved to a usable + * spelling afterwards. */ - if (tx->host_path && !(flags & PATH_TR_CREATE)) { - int sidecar_rc = sidecar_translate_lookup_at( - dirfd, tx->guest_path, tx->host_buf, sizeof(tx->host_buf)); - if (sidecar_rc < 0) + if (tx->host_path && relative_in_sysroot && casefold_active()) { + 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; - if (sidecar_rc > 0) - tx->host_path = tx->host_buf; + } + verdict = + casefold_resolve_at(ref.fd, "", tx->guest_path, false, tx->host_buf, + sizeof(tx->host_buf), &walk); + host_fd_ref_close(&ref); + if (verdict == CASEFOLD_ERROR) + return -1; + tx->host_path = tx->host_buf; } if (!tx->host_path) { @@ -280,10 +308,59 @@ int path_translate_at(guest_fd_t dirfd, return -1; } + /* A trailing slash asserts the target is a directory (POSIX 4.13, and + * path_resolution(7)), so "file/" owes ENOTDIR. The component walk skips + * separators, so the assertion is lost by the time the host path is built. + * Put it back rather than re-stat here: Darwin enforces trailing-slash + * semantics itself, so the kernel answers on the caller's own syscall, + * which is both free and atomic with the operation being performed. + */ + if (path_has_trailing_slash(tx->guest_path) && + !path_has_trailing_slash(tx->host_path)) { + size_t len = strlen(tx->host_path); + + if (tx->host_path != tx->host_buf) { + if (str_copy_trunc(tx->host_buf, tx->host_path, + sizeof(tx->host_buf)) >= sizeof(tx->host_buf)) { + errno = ENAMETOOLONG; + return -1; + } + tx->host_path = tx->host_buf; + } + if (len + 2 > sizeof(tx->host_buf)) { + errno = ENAMETOOLONG; + return -1; + } + tx->host_buf[len] = '/'; + tx->host_buf[len + 1] = '\0'; + } + return 0; } -int path_translate_dirent_name(guest_fd_t dirfd, +bool path_dirent_dir_holds_escapes(host_fd_t host_dirfd) +{ + char sr[LINUX_PATH_MAX], dirpath[LINUX_PATH_MAX]; + + if (!casefold_active() || !proc_sysroot_snapshot(sr, sizeof(sr))) + return false; + if (fcntl(host_dirfd, F_GETPATH, dirpath) < 0) + return true; + size_t sr_len = strlen(sr); + /* "--sysroot /" is the one prefix that is a bare separator: it owns every + * host path, but path_prefix_match on it accepts only "/" itself. + */ + if (sr_len == 1) + return true; + /* proc_set_sysroot stores a realpath()-canonical prefix and F_GETPATH + * reports canonical paths, so a byte compare is sound; the residual + * folding-volume caveat is the accepted gap the NO_XDEV checker + * documents (path.h). + */ + return path_prefix_match(dirpath, sr, sr_len); +} + +int path_translate_dirent_name(bool dir_holds_escapes, const char *host_name, char *guest_name, size_t guest_name_sz) @@ -293,24 +370,29 @@ int path_translate_dirent_name(guest_fd_t dirfd, return -1; } - guest_name[0] = '\0'; - int sidecar_rc = sidecar_translate_dirent_name(dirfd, host_name, guest_name, - guest_name_sz); - if (sidecar_rc < 0) - return sidecar_rc; - if (sidecar_rc > 0) - return sidecar_rc; - if (guest_name[0] != '\0') + /* Only a directory the sysroot owns can hold escaped spellings, and the + * process-wide fold switch cannot say which side of that boundary these + * entries came from; the caller answers it from the directory's own host + * identity. Outside the sysroot the guest is looking at the host + * filesystem directly, where a name merely shaped like an escape is an + * ordinary file that means itself; decoding it would report a name the + * directory does not contain and that no later open could resolve, while + * hiding the entry's real name behind it. + */ + if (!dir_holds_escapes) { + if (str_copy_trunc(guest_name, host_name, guest_name_sz) >= + guest_name_sz) { + errno = ENAMETOOLONG; + return -1; + } return 0; - - size_t len = strlen(host_name); - if (len + 1 > guest_name_sz) { - errno = ENAMETOOLONG; - return -1; } - memcpy(guest_name, host_name, len + 1); - return 0; + /* Decoding an on-disk name needs nothing beyond the name itself: no + * bookkeeping entry to hide, and no failure mode beyond a caller buffer + * too small for the result. + */ + return casefold_to_guest(host_name, guest_name, guest_name_sz); } static bool path_component_is_dot(const char *comp, size_t len) @@ -384,6 +466,38 @@ int sys_path_has_symlink(guest_fd_t dirfd, const char *path) int rc = 0; int walk_count = 0; + /* An absolute path arrived already translated. A relative one is still + * spelled the guest's way, and the walk below asks the volume for each + * component by name, so inside a sysroot it needs the stored spelling, the + * same translation path_translate_at applies. Without it this walker + * and that one disagree, and a guest gets two answers for one path: openat + * opens the file while openat2 reports it missing. + * + * Gated on containment for the reason path_translate_at is: outside the + * sysroot the names on disk are the guest's own, and escaping one would + * look for a file nobody wrote. + */ + if (path[0] != '/' && casefold_active()) { + bool in_sysroot = false; + + if (path_check_relative_sysroot_containment( + dirfd, path, PATH_TR_NOFOLLOW, &in_sysroot) < 0) { + rc = -1; + goto out; + } + if (in_sysroot) { + casefold_walk_t walk; + + if (casefold_resolve_at(current_fd, "", path, false, sysroot_buf, + sizeof(sysroot_buf), + &walk) == CASEFOLD_ERROR) { + rc = -1; + goto out; + } + scan = sysroot_buf; + } + } + while (path_next_component(&scan, &comp, &len)) { if (++walk_count > MAXSYMLINKS) { errno = ELOOP; @@ -393,7 +507,11 @@ int sys_path_has_symlink(guest_fd_t dirfd, const char *path) if (path_component_is_dot(comp, len)) continue; - char name[NAME_MAX + 1]; + /* Sized for the stored spelling, not the guest one: both branches + * above leave host-spelled components in @scan, and an escape runs + * past NAME_MAX for a name Linux still allows. + */ + char name[CASEFOLD_STORED_NAME_MAX]; if (path_component_copy(name, sizeof(name), comp, len) < 0) { rc = -1; goto out; @@ -838,9 +956,52 @@ static int classify_guest_path_mount(const char *guest_path) return PATH_MOUNT_ROOT; } -static int host_path_to_guest_path(const char *host_path, - char *out, - size_t outsz) +/* Rewrite each component of an absolute host-relative path into its guest + * spelling. A guest-created name whose spelling the volume cannot hold sits on + * disk under its escape, so publishing the stripped remainder as-is would show + * the guest a name it has never seen and cannot open. + */ +static int path_decode_components(const char *host_rel, char *out, size_t outsz) +{ + const char *scan = host_rel; + const char *comp; + size_t comp_len; + size_t len = 0; + + while (path_next_component(&scan, &comp, &comp_len)) { + /* Both sized for a name the volume can hand back, not for an escape. + * CASEFOLD_HOST_NAME_MAX bounds only what elfuse writes; a literal + * component the host already holds (a full-length CJK name, say) is + * longer than any escape, and decoding leaves such a name unchanged + * so the guest side needs the same room. + */ + char host_name[CASEFOLD_STORED_NAME_MAX]; + char guest_name[CASEFOLD_STORED_NAME_MAX]; + + if (path_component_copy(host_name, sizeof(host_name), comp, comp_len) < + 0) + return -1; + if (casefold_to_guest(host_name, guest_name, sizeof(guest_name)) < 0) + return -1; + if (len + 1 + strlen(guest_name) + 1 > outsz) { + errno = ENAMETOOLONG; + return -1; + } + out[len++] = '/'; + len += (size_t) snprintf(out + len, outsz - len, "%s", guest_name); + } + if (len == 0) { + if (outsz < 2) { + errno = ENAMETOOLONG; + return -1; + } + out[len++] = '/'; + } + out[len] = '\0'; + return 0; +} + +int path_host_to_guest(const char *host_path, char *out, size_t outsz) { char sysroot[LINUX_PATH_MAX]; const char *guest_path = host_path; @@ -852,6 +1013,13 @@ static int host_path_to_guest_path(const char *host_path, guest_path = host_path + sysroot_len; if (*guest_path == '\0') guest_path = "/"; + /* Only a volume that folds case holds escaped names. On a + * byte-exact sysroot the stored spelling is already the guest's, + * and decoding would rename a host-staged file that merely looks + * like an escape. + */ + else if (casefold_active()) + return path_decode_components(guest_path, out, outsz); } } @@ -908,7 +1076,7 @@ static int dirfd_guest_base_path(guest_fd_t dirfd, char *out, size_t outsz) char host_path[LINUX_PATH_MAX]; if (path_openat2_dirfd_host_path(dirfd, host_path, sizeof(host_path)) == 0) - return host_path_to_guest_path(host_path, out, outsz); + return path_host_to_guest(host_path, out, outsz); /* fd_snapshot already proved dirfd is open, so a valid-but-wrong-type fd * (pipe, socket, epoll, ...) belongs here, not in the "bad fd" case: Linux @@ -931,9 +1099,12 @@ static int dirfd_guest_base_path(guest_fd_t dirfd, char *out, size_t outsz) static int path_check_relative_sysroot_containment(guest_fd_t dirfd, const char *path, - unsigned int flags) + unsigned int flags, + bool *in_sysroot) { char base[LINUX_PATH_MAX]; + + *in_sysroot = false; if (dirfd_guest_base_path(dirfd, base, sizeof(base)) < 0) return -1; @@ -946,21 +1117,38 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd, char host_buf[LINUX_PATH_MAX]; const char *checked; - if (flags & PATH_TR_NOFOLLOW) { - checked = path_resolve_sysroot_nofollow_path(abs_path, host_buf, - sizeof(host_buf)); - } else if (flags & PATH_TR_CREATE) { + /* CREATE outranks NOFOLLOW, exactly as in path_translate_at's absolute + * ladder: a create decides where an absent leaf goes, which the create + * resolver anchors in the sysroot, while the lookup resolvers fall + * through to the host for an absent path. Testing NOFOLLOW first sends a + * renameat destination (translated with both flags) through the + * lookup fallback, so an in-sysroot target below an escaped directory + * reads as outside and the caller skips the escape walk entirely. + * Nofollow semantics are not lost: the create resolver never follows a + * final link either. + */ + if (flags & PATH_TR_CREATE) { /* create_parents=false regardless of the caller's actual flags: this * pass only checks whether the resolution is contained, and must not * mkdir() anything on the reconstructed path as a side effect. */ checked = path_resolve_sysroot_create_path(abs_path, host_buf, sizeof(host_buf), false); + } else if (flags & PATH_TR_NOFOLLOW) { + checked = path_resolve_sysroot_nofollow_path(abs_path, host_buf, + sizeof(host_buf)); } else { checked = path_resolve_sysroot_path(abs_path, host_buf, sizeof(host_buf)); } + /* The resolver returns its own buffer for a path the sysroot claims and the + * input pointer for one that falls through to the host, so this comparison + * is the sysroot-or-host decision, already taken. Report it rather than + * discard it: a relative name is measured from a descriptor and has no + * prefix of its own to make that call from. + */ + *in_sysroot = checked && checked != abs_path; return checked ? 0 : -1; } @@ -1095,6 +1283,33 @@ static int reset_walk_fd(host_fd_t *current_fd, host_fd_t root_fd) return replace_walk_fd(current_fd, next_fd); } +/* Spell one component the way the volume stores it, for a probe measured from + * @dirfd. A walker that asks the host for a name has to use the stored + * spelling; the guest's own would find nothing wherever an escape applies, and + * 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. + */ +static int host_component_spelling(host_fd_t dirfd, + const char *guest, + char *out, + size_t outsz) +{ + casefold_walk_t walk; + + if (!casefold_active()) { + if (str_copy_trunc(out, guest, outsz) >= outsz) { + errno = ENAMETOOLONG; + return -1; + } + return 0; + } + return casefold_resolve_at(dirfd, "", guest, false, out, outsz, &walk) == + CASEFOLD_ERROR + ? -1 + : 0; +} + int path_openat2_crosses_mount(guest_fd_t dirfd, const char *path, bool in_root, @@ -1178,6 +1393,13 @@ int path_openat2_crosses_mount(guest_fd_t dirfd, if (len == 1 && comp[0] == '.') continue; + /* The component's stored spelling, resolved once per component: the + * symlink probe below and the descent that follows it both address the + * same entry through the same descriptor, and each resolution costs a + * host probe. + */ + char host_name[CASEFOLD_STORED_NAME_MAX]; + if (len == 2 && comp[0] == '.' && comp[1] == '.') { size_t before_len = strlen(current); guest_path_pop(current, floor_len); @@ -1198,9 +1420,14 @@ int path_openat2_crosses_mount(guest_fd_t dirfd, goto out; } + if (host_walk && + host_component_spelling(current_fd, name, host_name, + sizeof(host_name)) < 0) + goto out; + struct stat st; if (host_walk && - fstatat(current_fd, name, &st, AT_SYMLINK_NOFOLLOW) == 0) { + 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) @@ -1218,8 +1445,8 @@ int path_openat2_crosses_mount(guest_fd_t dirfd, str_copy_trunc(current, parent, sizeof(current)); char target[LINUX_PATH_MAX]; - ssize_t target_len = readlinkat(current_fd, name, target, - sizeof(target) - 1); + ssize_t target_len = readlinkat(current_fd, host_name, + target, sizeof(target) - 1); if (target_len < 0) goto out; if (++symlink_count > MAXSYMLINKS) { @@ -1284,11 +1511,8 @@ int path_openat2_crosses_mount(guest_fd_t dirfd, rest++; if (host_walk && *rest != '\0' && !(len == 2 && comp[0] == '.' && comp[1] == '.')) { - char name[NAME_MAX + 1]; - if (path_component_copy(name, sizeof(name), comp, len) < 0) - goto out; - host_fd_t next_fd = - openat(current_fd, name, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + host_fd_t next_fd = openat(current_fd, host_name, + O_RDONLY | O_DIRECTORY | O_CLOEXEC); if (replace_walk_fd(¤t_fd, next_fd) < 0) goto out; } @@ -1332,8 +1556,8 @@ int path_openat2_check_fd_xdev(int guest_fd, int start_class) * mis-classify as /tmp). Trust the precheck in those cases and only * re-derive the class when the resolution started at root: that is * precisely the window where a symlink can escape into an intercept class - * without the walker seeing it (sidecar shadows hide the link node from - * fstatat). + * without the walker seeing it (a link stored under an escaped spelling + * is invisible to a walker probing the guest spelling). * * The /proc/self/fd/N magic-link case (where snap.proc_path stamps the * resulting fd with a PROC label even though the real mount of the dup @@ -1360,8 +1584,7 @@ int path_openat2_check_fd_xdev(int guest_fd, int start_class) char host_path[LINUX_PATH_MAX]; if (fcntl(snap.host_fd, F_GETPATH, host_path) < 0) return -1; - if (host_path_to_guest_path(host_path, guest_path, sizeof(guest_path)) < - 0) + if (path_host_to_guest(host_path, guest_path, sizeof(guest_path)) < 0) return -1; end_class = classify_guest_path_mount(guest_path); } else { diff --git a/src/syscall/path.h b/src/syscall/path.h index 8a33456a..54ec02f4 100644 --- a/src/syscall/path.h +++ b/src/syscall/path.h @@ -120,7 +120,27 @@ int path_translate_at(guest_fd_t dirfd, const char *path, unsigned int flags, path_translation_t *tx); -int path_translate_dirent_name(guest_fd_t dirfd, +/* Convert a host path to the guest path naming the same object: strip the + * sysroot prefix, and decode any component the volume made elfuse store under + * an escape. The result is what the guest must be shown for its own cwd, and it + * has to be a path the guest can hand straight back to chdir(2). + * + * Returns 0, or -1 with errno set to ENAMETOOLONG when @out is too small. + */ +int path_host_to_guest(const char *host_path, char *out, size_t outsz); + +/* True when the directory behind @host_dirfd is one whose entries elfuse may + * have stored escaped: a folding sysroot is configured and the directory's + * canonical host path lies under it. One answer per directory read, not per + * entry: the answer is a property of the directory, and F_GETPATH is a + * syscall. When the fd's path cannot be read the directory is treated as the + * sysroot's: the realistic failure is a directory unlinked while open, which + * lists nothing, while the opposite fallback would leak stored spellings for + * a live in-sysroot directory that merely lost its path. + */ +bool path_dirent_dir_holds_escapes(host_fd_t host_dirfd); + +int path_translate_dirent_name(bool dir_holds_escapes, const char *host_name, char *guest_name, size_t guest_name_sz); @@ -166,13 +186,14 @@ int path_openat2_resolved_within_root(guest_fd_t dirfd, * on every non-error return so the caller can re-run the check against the * actually opened fd via path_openat2_check_fd_xdev. The post-open check is * what closes the symlink bypass for callers that do not also set - * RESOLVE_NO_SYMLINKS: the precheck's fstatat walk cannot see symlinks that - * live in a sidecar shadow directory (case-fold sysroot), so the kernel may - * follow a link the walker did not, and only F_GETPATH on the resulting fd - * reveals the real landing site. + * RESOLVE_NO_SYMLINKS: the precheck's fstatat walk probes each component by + * its stored spelling, and on a case-fold sysroot that spelling can change + * between the precheck and the open, so the kernel may follow a link the + * walker did not, and only F_GETPATH on the resulting fd reveals the real + * landing site. * * Known gaps (best-effort by design): - * - host_path_to_guest_path strips the configured sysroot prefix with + * - path_host_to_guest strips the configured sysroot prefix with * a case-sensitive strncmp; on case-insensitive macOS volumes a * differently-cased F_GETPATH could fail to strip and the dirfd is * then classified as the root class. Sysroots that happen to live diff --git a/src/syscall/proc-state.c b/src/syscall/proc-state.c index d309f49e..15cc211d 100644 --- a/src/syscall/proc-state.c +++ b/src/syscall/proc-state.c @@ -18,6 +18,7 @@ #include "utils.h" #include "core/sysroot.h" +#include "syscall/casefold-walk.h" #include "runtime/thread.h" @@ -84,20 +85,17 @@ void proc_state_init(void) int proc_cwd_refresh(void) { char cwd[LINUX_PATH_MAX]; - const char *guest_cwd = cwd; + char guest[LINUX_PATH_MAX]; if (!getcwd(cwd, sizeof(cwd))) return -1; - char sr[LINUX_PATH_MAX]; - if (proc_sysroot_snapshot(sr, sizeof(sr))) { - size_t sr_len = strlen(sr); - if (!strncmp(cwd, sr, sr_len) && - (cwd[sr_len] == '\0' || cwd[sr_len] == '/')) { - guest_cwd = cwd + sr_len; - if (*guest_cwd == '\0') - guest_cwd = "/"; - } - } + /* One conversion, shared with the rest of the path layer: a private + * prefix-strip here would not know a component can be stored escaped, and + * would hand the guest a cwd it cannot chdir back into. + */ + if (path_host_to_guest(cwd, guest, sizeof(guest)) < 0) + return -1; + const char *guest_cwd = guest; size_t len = strlen(guest_cwd); pthread_mutex_lock(&cwd_lock); @@ -442,6 +440,29 @@ bool proc_sysroot_casefold_enabled(void) pthread_mutex_unlock(&sysroot_lock); return enabled; } + +/* True when realpath(3) failed because the path stopped resolving rather than + * because it resolves somewhere it should not. A sibling thread or process can + * unlink or rename the entry between the resolver's existence probe and this + * recheck, and canonicalizing a vanished path dies with ENOENT (or ENOTDIR + * when a component was replaced by a file). Nothing can be reached through a + * path that no longer resolves, so the caller may keep the sysroot spelling + * and let its own syscall report the truth; turning the failure into a veto + * manufactures ELOOP for a plain concurrent unlink. Every other realpath + * errno stays a veto: a loop really is ELOOP, and denying on EACCES or EIO is + * the conservative side of a containment check. + * + * The reasoning covers the sysroot argument as much as the path under it: a + * sysroot renamed out from under a running guest leaves nothing reachable + * beneath it either, so the caller's own syscall owes the same ENOENT. There + * is no containment question left to answer once the root of the comparison + * is gone. + */ +static bool realpath_vanished(void) +{ + return errno == ENOENT || errno == ENOTDIR; +} + /* Confirm @resolved_path canonicalizes inside @sysroot. This is a * check-then-use sequence: callers issue the actual syscall after this returns, * so a symlink swapped in between will not be re-validated. openat2 @@ -456,11 +477,11 @@ 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)) - return false; + return realpath_vanished(); if (follow_final) { if (!realpath(resolved_path, real_path)) - return false; + return realpath_vanished(); } else { const char *base = strrchr(resolved_path, '/'); /* "." and ".." basenames navigate the directory tree and cannot @@ -471,7 +492,7 @@ static bool sysroot_path_is_contained(const char *resolved_path, */ if (base && (!strcmp(base + 1, "..") || !strcmp(base + 1, "."))) { if (!realpath(resolved_path, real_path)) - return false; + return realpath_vanished(); } else { char parent[LINUX_PATH_MAX]; char *slash; @@ -489,7 +510,7 @@ static bool sysroot_path_is_contained(const char *resolved_path, *slash = '\0'; if (!realpath(parent, real_path)) - return false; + return realpath_vanished(); size_t parent_len = strlen(real_path); if (snprintf(real_path + parent_len, sizeof(real_path) - parent_len, "/%s", @@ -656,14 +677,70 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, return NULL; } - int n = snprintf(buf, bufsz, "%s%s", sr, path); - if (n < 0) { - if (errno == 0) - errno = EINVAL; - return NULL; + /* Does this path name something under the sysroot, and if so under what + * host spelling? On a volume that folds case those are one question: the + * walk decides each component's stored spelling and reports whether the + * whole path resolved, so its verdict is the existence answer and its + * output is the host path. On a byte-exact volume the guest spelling is + * the host spelling, and a concatenation plus one probe answers both. + */ + bool present; + bool folded = false; + if (casefold_active()) { + casefold_walk_t walk; + casefold_verdict_t verdict = casefold_resolve_at( + AT_FDCWD, sr, path, follow_final, buf, bufsz, &walk); + + if (verdict == CASEFOLD_ERROR) + return NULL; + present = verdict == CASEFOLD_FOUND; + folded = walk.folded; + /* 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, + * or "file/tail" would be reported against an unrelated host path, + * ENOENT where Linux owes ENOTDIR. The caller's own syscall against + * the sysroot spelling reproduces the right errno. The containment + * check is skipped for the reason the folded return below gives: + * nothing past the offending component is ever reached. + */ + if (!present && walk.notdir) + return buf; + /* An all-slash guest path names the root, which always exists, is + * trivially contained, and has no parent for the containment check + * to split off: with the sysroot at "/" the resolved path is the + * one-character prefix itself, and treating that shape as impossible + * reports ELOOP for lstat("/"). + */ + if (present && walk.leaf_offset == 0) + return buf; + } else { + int n = snprintf(buf, bufsz, "%s%s", sr, path); + if (n < 0) { + if (errno == 0) + errno = EINVAL; + return NULL; + } + if ((size_t) n >= bufsz) { + errno = ENAMETOOLONG; + return NULL; + } + present = sysroot_path_exists(buf, follow_final); + /* A probe that dies with ENOTDIR found a sysroot component that is + * not a directory. Linux resolves left to right and fails there with + * ENOTDIR (path_resolution(7)), so the sysroot has answered: the + * host fallback below must not run, or "file/" and "file/tail" would + * be reported against an unrelated host path, ENOENT where Linux + * owes ENOTDIR. The caller's own syscall against the sysroot + * spelling reproduces the right errno on the host. The containment + * check is skipped for the reason the folded return below gives: + * resolution never reaches anything past the offending component. + */ + if (!present && errno == ENOTDIR) + return buf; } - bool full_path_truncated = (size_t) n >= bufsz; - if (!full_path_truncated && sysroot_path_exists(buf, follow_final)) { + + if (present) { if (!sysroot_path_is_contained(buf, sr, follow_final)) { errno = ELOOP; return NULL; @@ -671,10 +748,21 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, return buf; } - if (full_path_truncated) { - errno = ENAMETOOLONG; - return NULL; - } + /* The sysroot holds an entry where this path asked, spelled differently, + * so the path is absent to a byte-exact reader but the sysroot has a claim + * on it, and the answer Linux owes is ENOENT. Falling through would open an + * unrelated host file that merely shares the name, which is how a + * wrong-case lookup escapes the tree entirely. + * + * Returning before the containment check above is deliberate: a folded + * component's escape does not exist, every component before it resolved + * case-exactly without passing through a link, and resolution proceeds + * component by component from the left (path_resolution(7)), so the + * caller's syscall dies with ENOENT at the folded component before + * anything after it could be reached, contained or not. + */ + if (folded) + return buf; /* Prevent escaping guest system paths to macOS host paths, which leads * to host contamination and permission failures (e.g. SIP/EPERM). @@ -719,41 +807,96 @@ const char *proc_resolve_sysroot_create_path(const char *path, return NULL; } - int n = snprintf(buf, bufsz, "%s%s", sr, path); - if (n < 0) { - if (errno == 0) - errno = EINVAL; - return NULL; - } - if ((size_t) n >= bufsz) { - errno = ENAMETOOLONG; - return NULL; - } + char parent[LINUX_PATH_MAX]; + bool parent_present; - /* An all-slash guest path ("/", "///") names the root, which always exists - * and has no parent to check; trimming it would walk strrchr into the - * sysroot prefix and trip the containment guard. - * - * Return buf as-is. + /* The target does not exist yet, so what has to be decided is where it + * would go: the host spelling of every component leading to it, and + * whether that parent is there. On a folding volume a concatenated parent + * answers neither: a parent stored escaped reads as absent, and a + * wrong-case one folds onto a directory the create would then land in. */ - if (path[strspn(path, "/")] == '\0') - return buf; + if (casefold_active()) { + casefold_walk_t walk; - char parent[LINUX_PATH_MAX]; - str_copy_trunc(parent, buf, sizeof(parent)); - /* Trailing slashes name the same directory (POSIX); drop them so the final - * separator splits off the leaf component rather than matching the trailing - * slash itself, which would leave the target as its own parent. - */ - size_t plen = strlen(parent); - while (plen > 1 && parent[plen - 1] == '/') - parent[--plen] = '\0'; - char *slash = strrchr(parent, '/'); - if (!slash || slash == parent) - return buf; + if (casefold_resolve_at(AT_FDCWD, sr, path, false, buf, bufsz, &walk) == + CASEFOLD_ERROR) + return NULL; + if (str_copy_trunc(parent, buf, sizeof(parent)) >= sizeof(parent)) { + errno = ENAMETOOLONG; + return NULL; + } + /* An all-slash guest path names the root, which always exists and has + * no parent to check. + */ + if (walk.leaf_offset == 0) + return buf; + /* The walk records where the parent's spelling ends, so truncating + * there needs no correction for the separator: only the walk knows + * whether it inserted one, and with the sysroot at "/" it did not: + * the parent is the root, not the empty string a leaf_offset - 1 + * truncation would leave. + */ + parent[walk.parent_offset] = '\0'; + parent_present = walk.parent_found; + + /* A fold above the leaf means the parent the guest named is not the + * entry the volume matched, so that parent does not exist. Both other + * answers are wrong: falling through puts the create on the host, and + * returning the sysroot spelling puts it inside the folded entry, + * which is a directory this sysroot did not create under that name. + */ + if (walk.folded && !parent_present) { + errno = ENOENT; + return NULL; + } + } else { + int n = snprintf(buf, bufsz, "%s%s", sr, path); + if (n < 0) { + if (errno == 0) + errno = EINVAL; + return NULL; + } + if ((size_t) n >= bufsz) { + errno = ENAMETOOLONG; + return NULL; + } + + /* An all-slash guest path ("/", "///") names the root, which always + * exists and has no parent to check; trimming it would walk strrchr + * into the sysroot prefix and trip the containment guard. + * + * Return buf as-is. + */ + if (path[strspn(path, "/")] == '\0') + return buf; + + str_copy_trunc(parent, buf, sizeof(parent)); + /* Trailing slashes name the same directory (POSIX); drop them so the + * final separator splits off the leaf component rather than matching + * the trailing slash itself, which would leave the target as its own + * parent. + */ + size_t plen = strlen(parent); + while (plen > 1 && parent[plen - 1] == '/') + parent[--plen] = '\0'; + char *slash = strrchr(parent, '/'); + if (!slash || slash == parent) + return buf; + + *slash = '\0'; + parent_present = access(parent, F_OK) == 0; + /* access() failed for a reason other than "parent missing" (e.g. + * EACCES, ELOOP, ENAMETOOLONG, EIO). Treating those as "parent absent" + * would let the redirect logic auto-create or silently fall back to + * the host literal, which can bypass sysroot resolution. Surface the + * real error. + */ + if (!parent_present && errno != ENOENT && errno != ENOTDIR) + return NULL; + } - *slash = '\0'; - if (access(parent, F_OK) == 0) { + if (parent_present) { if (!sysroot_path_is_contained(parent, sr, true)) { errno = ELOOP; return NULL; @@ -761,14 +904,6 @@ const char *proc_resolve_sysroot_create_path(const char *path, return buf; } - /* access() failed for a reason other than "parent missing" (e.g. EACCES, - * ELOOP, ENAMETOOLONG, EIO). Treating those as "parent absent" would let - * the redirect logic auto-create or silently fall back to the host literal, - * which can bypass sysroot resolution. Surface the real error. - */ - if (errno != ENOENT && errno != ENOTDIR) - return NULL; - /* Parent doesn't exist in sysroot. Only the temp roots and the guest system * directories are forced to resolve there; everything else falls back to * the host literal. diff --git a/src/syscall/sidecar.c b/src/syscall/sidecar.c deleted file mode 100644 index 32cffff0..00000000 --- a/src/syscall/sidecar.c +++ /dev/null @@ -1,2217 +0,0 @@ -/* - * Case-folding fallback VFS helpers - * - * Copyright 2026 elfuse contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "utils.h" - -#include "syscall/abi.h" -#include "syscall/internal.h" -#include "syscall/path.h" -#include "syscall/proc.h" -#include "syscall/sidecar.h" - -#ifndef LINUX_RENAME_NOREPLACE -#define LINUX_RENAME_NOREPLACE (1 << 0) -#endif -#ifndef LINUX_RENAME_EXCHANGE -#define LINUX_RENAME_EXCHANGE (1 << 1) -#endif - -#define SIDECAR_INDEX_TMP_NAME SIDECAR_INDEX_NAME ".tmp" -#define SIDECAR_INDEX_LOCK_NAME SIDECAR_INDEX_NAME ".lock" - -/* fcntl POSIX advisory locks are per-process. Within a single elfuse instance, - * multiple vCPU threads all "hold" the same fcntl lock simultaneously. This - * mutex serializes index updates across vCPU threads; the fcntl lock on the - * dedicated lock sentinel still serializes against forked elfuse processes that - * share the same sysroot. - */ -static pthread_mutex_t sidecar_global_lock = PTHREAD_MUTEX_INITIALIZER; - -/* Per-directory cache: did this directory lack a sidecar index file for the - * current directory metadata version? Keyed by (st_dev, st_ino) so a renamed or - * moved directory does not leak a stale answer. Sidecar's case-fold walker - * visits every parent directory of every translated path; during dynamic-linker - * bring-up that walker fires per guest openat and dominates startup (histogram - * showed openat at 61% of getent's 7.5 ms warm path). The cache lets the common - * "no index here" answer return after a 5 us fstat instead of a ~30 us openat - * per directory traversed. - * - * 64 slots, single-level open-addressing (last writer wins on collision). The - * directory ctime/mtime pair is part of the cache key: publishing an index from - * another elfuse process changes the directory entry set, so a stale ABSENT - * entry becomes UNKNOWN on the next lookup. - */ -enum { - SIDECAR_IDX_UNKNOWN = 0, - SIDECAR_IDX_ABSENT = 1, -}; -typedef struct { - dev_t dev; - ino_t ino; - struct timespec mtime; - struct timespec ctime; - uint8_t state; -} sidecar_idx_slot_t; -#define SIDECAR_IDX_CACHE_SLOTS 64 -static sidecar_idx_slot_t sidecar_idx_cache[SIDECAR_IDX_CACHE_SLOTS]; -static pthread_mutex_t sidecar_idx_cache_lock = PTHREAD_MUTEX_INITIALIZER; - -static size_t sidecar_idx_cache_slot(dev_t dev, ino_t ino) -{ - /* Mix dev into ino so two filesystems with overlapping inode numbers do not - * pin the same slot. The golden-ratio multiplier scatters small inode - * numbers across the table without needing a real hash. - */ - uint64_t key = (uint64_t) ino ^ ((uint64_t) dev * 0x9E3779B97F4A7C15ULL); - return (size_t) (key % SIDECAR_IDX_CACHE_SLOTS); -} - -static bool sidecar_idx_cache_matches(const sidecar_idx_slot_t *slot, - const struct stat *st) -{ - return slot->state != SIDECAR_IDX_UNKNOWN && slot->dev == st->st_dev && - slot->ino == st->st_ino && - slot->mtime.tv_sec == st->st_mtimespec.tv_sec && - slot->mtime.tv_nsec == st->st_mtimespec.tv_nsec && - slot->ctime.tv_sec == st->st_ctimespec.tv_sec && - slot->ctime.tv_nsec == st->st_ctimespec.tv_nsec; -} - -static int sidecar_idx_cache_lookup_stat(const struct stat *st) -{ - size_t slot = sidecar_idx_cache_slot(st->st_dev, st->st_ino); - int state = SIDECAR_IDX_UNKNOWN; - pthread_mutex_lock(&sidecar_idx_cache_lock); - if (sidecar_idx_cache_matches(&sidecar_idx_cache[slot], st)) - state = sidecar_idx_cache[slot].state; - pthread_mutex_unlock(&sidecar_idx_cache_lock); - return state; -} - -static void sidecar_idx_cache_set_stat(const struct stat *st, int state) -{ - size_t slot = sidecar_idx_cache_slot(st->st_dev, st->st_ino); - pthread_mutex_lock(&sidecar_idx_cache_lock); - sidecar_idx_cache[slot].dev = st->st_dev; - sidecar_idx_cache[slot].ino = st->st_ino; - sidecar_idx_cache[slot].mtime = st->st_mtimespec; - sidecar_idx_cache[slot].ctime = st->st_ctimespec; - sidecar_idx_cache[slot].state = (uint8_t) state; - pthread_mutex_unlock(&sidecar_idx_cache_lock); -} - -/* Roll the cache entry back to UNKNOWN. Used by the index publish path before - * the renameat so a concurrent reader entering the post-rename window cannot - * consume a stale ABSENT and skip the openat. UNKNOWN forces the reader to - * consult the filesystem. - */ -static void sidecar_idx_cache_invalidate(int dirfd) -{ - struct stat st; - if (fstat(dirfd, &st) < 0) - return; - sidecar_idx_cache_set_stat(&st, SIDECAR_IDX_UNKNOWN); -} - -/* Cached sysroot dirfd. sidecar_open_base opens the sysroot directory on every - * translated absolute path; for dynamic-linker bring-up that fires once per - * openat / fstat / access / readlink. Caching the host fd and handing the - * walker a dup turns that ~30 us open into a ~5 us dup. The cache invalidates - * lazily by comparing the path string -- proc_set_sysroot is rare (once at - * startup, once on fork-child IPC restore), so the snapshot+strcmp on every - * call is still a net win over the open it replaces. Concurrent vCPU lookups - * serialize on the lock for the cache check and dup. - */ -static int sidecar_sysroot_cached_fd = -1; -static char sidecar_sysroot_cached_path[LINUX_PATH_MAX] = {0}; -static dev_t sidecar_sysroot_cached_dev; -static ino_t sidecar_sysroot_cached_ino; -static pthread_mutex_t sidecar_sysroot_cached_lock = PTHREAD_MUTEX_INITIALIZER; - -static int sidecar_open_sysroot_cached(const char *path) -{ - pthread_mutex_lock(&sidecar_sysroot_cached_lock); - int cached = sidecar_sysroot_cached_fd; - if (cached >= 0 && !strcmp(path, sidecar_sysroot_cached_path)) { - /* Path text alone is not a sufficient cache key: a host-side rename or - * replace can rebind the same path to a different inode while the - * cached fd still resolves to the original. Stat the path on every hit - * and accept the cache only when (dev, ino) still match the inode - * captured at fill time. The stat is one host syscall per call, much - * cheaper than the open it replaces. - * - * Race window: a host-side mutation between this stat and the dup below - * can leave the cache returning the pre-mutation fd even though a fresh - * open(path) at that instant would resolve the post-mutation binding. - * The window is microseconds. It only matters under adversarial - * host-side sysroot mutation; normal sysroot configuration is - * canonicalized once at proc_set_sysroot time and never changes. - * Closing it adversarially would require dropping the cache, which - * gives back the ~25 us per-call win; not worth it absent a real - * reproducer. - */ - struct stat current; - if (stat(path, ¤t) == 0 && - current.st_dev == sidecar_sysroot_cached_dev && - current.st_ino == sidecar_sysroot_cached_ino) { - cached = fcntl(cached, F_DUPFD_CLOEXEC, 0); - pthread_mutex_unlock(&sidecar_sysroot_cached_lock); - return cached; - } - /* Inode mismatch: fall through to refresh below. */ - } - if (sidecar_sysroot_cached_fd >= 0) { - close(sidecar_sysroot_cached_fd); - sidecar_sysroot_cached_fd = -1; - sidecar_sysroot_cached_path[0] = '\0'; - } - int fresh = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC); - if (fresh < 0) { - pthread_mutex_unlock(&sidecar_sysroot_cached_lock); - return -1; - } - /* Capture the inode of the freshly-opened dir so subsequent cache hits can - * validate against it. Using fstat on the open fd gives the same (dev, ino) - * as a path stat would, with no second walk. - */ - struct stat fresh_st; - if (fstat(fresh, &fresh_st) < 0) { - close(fresh); - pthread_mutex_unlock(&sidecar_sysroot_cached_lock); - return -1; - } - /* CLOEXEC dup: a concurrent posix_spawn from another vCPU thread must not - * inherit the sysroot dirfd into the fork-child. F_DUPFD_CLOEXEC sets the - * flag atomically with the dup, closing the inheritance window that plain - * dup() leaves open. - */ - int cache_fd = fcntl(fresh, F_DUPFD_CLOEXEC, 0); - if (cache_fd >= 0) { - sidecar_sysroot_cached_fd = cache_fd; - sidecar_sysroot_cached_dev = fresh_st.st_dev; - sidecar_sysroot_cached_ino = fresh_st.st_ino; - str_copy_trunc(sidecar_sysroot_cached_path, path, - sizeof(sidecar_sysroot_cached_path)); - } - pthread_mutex_unlock(&sidecar_sysroot_cached_lock); - return fresh; -} - -typedef struct { - char *guest_name; - char token[SIDECAR_TOKEN_NAME_LEN + 1]; -} sidecar_row_t; - -typedef struct { - sidecar_row_t *rows; - size_t count; -} sidecar_index_t; - -typedef struct { - host_fd_t dirfd; - bool absolute; - char basename[NAME_MAX + 1]; -} sidecar_parent_t; - -static void sidecar_index_free(sidecar_index_t *index) -{ - if (!index) - return; - for (size_t i = 0; i < index->count; i++) - free(index->rows[i].guest_name); - free(index->rows); - index->rows = NULL; - index->count = 0; -} - -/* Deep-copy @src into @dst so the caller can mutate @dst freely and still - * recover @src for rollback. - * - * Returns 0 on success, -1 with errno on alloc failure (@dst is left empty in - * that case). - */ -static int sidecar_index_clone(const sidecar_index_t *src, sidecar_index_t *dst) -{ - dst->rows = NULL; - dst->count = 0; - if (src->count == 0) - return 0; - dst->rows = (sidecar_row_t *) malloc(src->count * sizeof(sidecar_row_t)); - if (!dst->rows) - return -1; - for (size_t i = 0; i < src->count; i++) { - dst->rows[i].guest_name = strdup(src->rows[i].guest_name); - if (!dst->rows[i].guest_name) { - dst->count = i; - sidecar_index_free(dst); - return -1; - } - memcpy(dst->rows[i].token, src->rows[i].token, - sizeof(dst->rows[i].token)); - } - dst->count = src->count; - return 0; -} - -bool sidecar_active(void) -{ - return proc_get_sysroot() && proc_sysroot_casefold_enabled(); -} - -bool sidecar_name_reserved(const char *name) -{ - return name && (!strcmp(name, SIDECAR_INDEX_NAME) || - !strcmp(name, SIDECAR_INDEX_TMP_NAME) || - !strcmp(name, SIDECAR_INDEX_LOCK_NAME)); -} - -bool sidecar_path_targets_reserved_name(const char *path) -{ - if (!path || path[0] == '\0') - return false; - - const char *basename = strrchr(path, '/'); - basename = basename ? basename + 1 : path; - return sidecar_name_reserved(basename); -} - -static int sidecar_decode_name(const char *hex, char **out) -{ - size_t len = strlen(hex); - if ((len & 1u) != 0) { - errno = EPROTO; - return -1; - } - - char *name = (char *) malloc(len / 2 + 1); - if (!name) - return -1; - - for (size_t i = 0; i < len; i += 2) { - int hi = hex_nibble((unsigned char) hex[i]); - int lo = hex_nibble((unsigned char) hex[i + 1]); - if (hi < 0 || lo < 0) { - free(name); - errno = EPROTO; - return -1; - } - name[i / 2] = (char) ((hi << 4) | lo); - } - name[len / 2] = '\0'; - *out = name; - return 0; -} - -static int sidecar_load_index(int dirfd, sidecar_index_t *index) -{ - memset(index, 0, sizeof(*index)); - - /* Per-dir absence cache: skip the openat round-trip only while the - * directory metadata still matches the snapshot that produced ENOENT. - * Another elfuse process can publish the index through the fcntl-locked - * writer, so cross-process invalidation relies on the directory mtime/ctime - * changing when the sidecar index appears. - */ - struct stat dir_st; - bool have_dir_st = fstat(dirfd, &dir_st) == 0; - if (have_dir_st && - sidecar_idx_cache_lookup_stat(&dir_st) == SIDECAR_IDX_ABSENT) - return 0; - - int fd = openat(dirfd, SIDECAR_INDEX_NAME, O_RDONLY | O_CLOEXEC); - if (fd < 0) { - if (errno == ENOENT) { - if (have_dir_st) - sidecar_idx_cache_set_stat(&dir_st, SIDECAR_IDX_ABSENT); - return 0; - } - return -1; - } - - struct stat st; - if (fstat(fd, &st) < 0) { - close(fd); - return -1; - } - if (st.st_size == 0) { - close(fd); - return 0; - } - if (st.st_size < 0 || st.st_size >= (off_t) LINUX_PATH_MAX * 64) { - close(fd); - errno = EFBIG; - return -1; - } - - size_t size = (size_t) st.st_size; - char *buf = (char *) malloc(size + 1); - if (!buf) { - close(fd); - return -1; - } - - size_t off = 0; - while (off < size) { - ssize_t n = read(fd, buf + off, size - off); - if (n < 0) { - if (errno == EINTR) - continue; - free(buf); - close(fd); - return -1; - } - if (n == 0) - break; - off += (size_t) n; - } - close(fd); - buf[off] = '\0'; - - char *line = buf; - while (*line) { - char *newline = strchr(line, '\n'); - if (newline) - *newline = '\0'; - - if (*line != '\0') { - char *tab = strchr(line, '\t'); - if (!tab || tab == line || tab[1] == '\0') { - free(buf); - sidecar_index_free(index); - errno = EPROTO; - return -1; - } - *tab = '\0'; - - sidecar_row_t *rows = (sidecar_row_t *) realloc( - index->rows, (index->count + 1) * sizeof(sidecar_row_t)); - if (!rows) { - free(buf); - sidecar_index_free(index); - return -1; - } - index->rows = rows; - if (sidecar_decode_name( - line, &index->rows[index->count].guest_name) < 0) { - free(buf); - sidecar_index_free(index); - return -1; - } - if (strlen(tab + 1) != SIDECAR_TOKEN_NAME_LEN) { - free(buf); - sidecar_index_free(index); - errno = EPROTO; - return -1; - } - memcpy(index->rows[index->count].token, tab + 1, - SIDECAR_TOKEN_NAME_LEN + 1); - index->count++; - } - - if (!newline) - break; - line = newline + 1; - } - - free(buf); - return 0; -} - -static const char *sidecar_lookup_guest(const sidecar_index_t *index, - const char *guest_name) -{ - for (size_t i = 0; i < index->count; i++) { - if (!strcmp(index->rows[i].guest_name, guest_name)) - return index->rows[i].token; - } - return NULL; -} - -static const char *sidecar_lookup_token(const sidecar_index_t *index, - const char *token) -{ - for (size_t i = 0; i < index->count; i++) { - if (!strcmp(index->rows[i].token, token)) - return index->rows[i].guest_name; - } - return NULL; -} - -static int sidecar_append_component(char *out, - size_t outsz, - size_t *len_io, - const char *comp, - bool absolute) -{ - size_t len = *len_io; - size_t comp_len = strlen(comp); - - if (absolute) { - if (len == 0 || out[len - 1] != '/') { - if (len + 1 >= outsz) { - errno = ENAMETOOLONG; - return -1; - } - out[len++] = '/'; - } - } else if (len != 0) { - if (len + 1 >= outsz) { - errno = ENAMETOOLONG; - return -1; - } - out[len++] = '/'; - } - - if (len + comp_len >= outsz) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(out + len, comp, comp_len); - len += comp_len; - out[len] = '\0'; - *len_io = len; - return 0; -} - -static int sidecar_open_base(guest_fd_t dirfd, - const char *path, - char *out, - size_t outsz, - host_fd_t *base_fd, - bool *absolute) -{ - out[0] = '\0'; - *absolute = false; - - if (path[0] == '/') { - char sysroot[LINUX_PATH_MAX]; - if (!proc_sysroot_snapshot(sysroot, sizeof(sysroot))) { - errno = ENOENT; - return -1; - } - size_t len = str_copy_trunc(out, sysroot, outsz); - if (len >= outsz) { - errno = ENAMETOOLONG; - return -1; - } - *base_fd = sidecar_open_sysroot_cached(sysroot); - if (*base_fd < 0) - return -1; - *absolute = true; - return 0; - } - - if (dirfd == LINUX_AT_FDCWD) { - *base_fd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); - return *base_fd < 0 ? -1 : 0; - } - - host_fd_ref_t ref; - if (host_dirfd_ref_open(dirfd, &ref) < 0) { - errno = EBADF; - return -1; - } - *base_fd = dup(ref.fd); - host_fd_ref_close(&ref); - return *base_fd < 0 ? -1 : 0; -} - -static int sidecar_exact_name_exists(int dirfd, const char *name); - -/* Verdicts for the byte-exact on-disk spelling probe. */ -typedef enum { - SIDECAR_NAME_ERROR = -1, /* probe failed, errno set */ - SIDECAR_NAME_EXACT = 0, - SIDECAR_NAME_ABSENT = 1, - SIDECAR_NAME_CASEFOLD = 2, -} sidecar_name_verdict_t; - -/* Probe whether @name exists in @dirfd spelled exactly as given. APFS and - * HFS+ resolve names case- and normalization-insensitively, so a plain - * openat/fstatat existence probe cannot tell "entry exists as spelled" from - * "entry exists under a folded spelling"; Linux path resolution is byte-exact - * and must report ENOENT for the latter. getattrlistat(ATTR_CMN_NAME) goes - * through the same folding lookup but returns the on-disk spelling for a - * byte comparison. FSOPT_NOFOLLOW keeps the probe on the entry itself so a - * symlink component is verified by its own name, not its target's. The name - * buffer covers the APFS maximum (255 UTF-16 units, up to 765 UTF-8 bytes), - * so a returned name never truncates into a false mismatch. - * - * Returns a SIDECAR_NAME_* verdict; SIDECAR_NAME_ERROR carries errno. - * Filesystems that do not return ATTR_CMN_NAME fall back to the readdir - * scan, with a folded fstatat to separate ABSENT from CASEFOLD. - */ -static sidecar_name_verdict_t sidecar_probe_exact_name(int dirfd, - const char *name) -{ - struct attrlist al = { - .bitmapcount = ATTR_BIT_MAP_COUNT, - .commonattr = ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_NAME, - }; - struct { - u_int32_t length; - attribute_set_t returned; - attrreference_t name_ref; - char name[768]; - } __attribute__((aligned(4), packed)) attr_buf; - - int rc = getattrlistat(dirfd, name, &al, &attr_buf, sizeof(attr_buf), - FSOPT_NOFOLLOW); - if (rc == 0 && (attr_buf.returned.commonattr & ATTR_CMN_NAME)) { - const char *disk_name = (const char *) &attr_buf.name_ref + - attr_buf.name_ref.attr_dataoffset; - return strcmp(disk_name, name) == 0 ? SIDECAR_NAME_EXACT - : SIDECAR_NAME_CASEFOLD; - } - if (rc < 0) { - if (errno == ENOENT || errno == ENOTDIR) - return SIDECAR_NAME_ABSENT; - if (errno != ENOTSUP && errno != EINVAL) - return SIDECAR_NAME_ERROR; - } - - int exists = sidecar_exact_name_exists(dirfd, name); - if (exists < 0) - return SIDECAR_NAME_ERROR; - if (exists == 1) - return SIDECAR_NAME_EXACT; - struct stat st; - if (fstatat(dirfd, name, &st, AT_SYMLINK_NOFOLLOW) == 0) - return SIDECAR_NAME_CASEFOLD; - return (errno == ENOENT || errno == ENOTDIR) ? SIDECAR_NAME_ABSENT - : SIDECAR_NAME_ERROR; -} - -int sidecar_translate_lookup_at(guest_fd_t dirfd, - const char *path, - char *out, - size_t outsz) -{ - if (!sidecar_active() || !path) - return 0; - if (path[0] == '\0') - return 0; - - char normalized[LINUX_PATH_MAX]; - const char *scan = path; - if (path[0] == '/') { - if (path_openat2_normalize_in_root(path, normalized, - sizeof(normalized)) < 0) { - errno = ENAMETOOLONG; - return -1; - } - scan = normalized; - - /* Kernel virtual filesystems live in procemu, not on the sysroot disk - * tree. Walking them here would openat() against a directory that never - * exists in the sysroot and short-circuit the procemu intercept - * downstream of path_translate_at(). Punt to that layer instead. Check - * the normalized form so "/./proc/..." and "//proc/..." also skip; - * match only on a full top-level component so siblings like "/procfoo" - * still go through sidecar. Note that path_openat2_normalize_in_root() - * strips the leading '/' from absolute inputs, so the prefixes here are - * unrooted. - */ - size_t plen = 0; - if (!strncmp(normalized, "proc", 4)) - plen = 4; - else if (!strncmp(normalized, "sys", 3)) - plen = 3; - else if (!strncmp(normalized, "dev", 3)) - plen = 3; - if (plen && (normalized[plen] == '\0' || normalized[plen] == '/')) - return 0; - } - - host_fd_t cur_fd = -1; - bool absolute = false; - if (sidecar_open_base(dirfd, path, out, outsz, &cur_fd, &absolute) < 0) - return -1; - - /* Sidecar only speaks for entries that live inside the sysroot tree (or are - * reachable through an index mapping). The sysroot resolver - * (proc_resolve_sysroot_path_flags) falls back to the literal host path - * when the guest path does not exist under the sysroot; a walk that - * unconditionally re-anchored such paths at the sysroot would veto that - * fallback and break host-resource access (mktemp dirs, /etc/resolv.conf). - * Track whether any component actually consulted an index mapping: with a - * mapped prefix the sysroot view is authoritative and missing suffixes must - * surface as ENOENT against the translated path; without one, a walk that - * leaves the tree simply is not sidecar's business (return 0). - */ - bool used_mapping = false; - size_t out_len = strlen(out); - const char *comp; - size_t comp_len; - while (path_next_component(&scan, &comp, &comp_len)) { - char guest_comp[NAME_MAX + 1]; - if (path_component_copy(guest_comp, sizeof(guest_comp), comp, - comp_len) < 0) { - close(cur_fd); - return -1; - } - - if (sidecar_name_reserved(guest_comp)) { - close(cur_fd); - errno = ENOENT; - return -1; - } - if (!strcmp(guest_comp, ".") || !strcmp(guest_comp, "..")) { - if (sidecar_append_component(out, outsz, &out_len, guest_comp, - absolute) < 0) { - close(cur_fd); - return -1; - } - if (strcmp(guest_comp, ".")) { - int next_fd = openat(cur_fd, guest_comp, - O_RDONLY | O_DIRECTORY | O_CLOEXEC); - if (next_fd < 0) { - int saved_errno = errno; - close(cur_fd); - if (!used_mapping && - (saved_errno == ENOENT || saved_errno == ENOTDIR)) - return 0; - errno = saved_errno; - return -1; - } - close(cur_fd); - cur_fd = next_fd; - } - continue; - } - - sidecar_index_t index; - if (sidecar_load_index(cur_fd, &index) < 0) { - close(cur_fd); - return -1; - } - const char *mapped = sidecar_lookup_guest(&index, guest_comp); - char host_comp[NAME_MAX + 1]; - if (mapped) { - used_mapping = true; - str_copy_trunc(host_comp, mapped, sizeof(host_comp)); - } else { - str_copy_trunc(host_comp, guest_comp, sizeof(host_comp)); - } - - if (sidecar_append_component(out, outsz, &out_len, host_comp, - absolute) < 0) { - sidecar_index_free(&index); - close(cur_fd); - return -1; - } - sidecar_index_free(&index); - - const char *peek = scan; - while (*peek == '/') - peek++; - if (*peek == '\0') { - /* Final component. Index-mapped components are byte-exact by - * construction. An unmapped one must exist under its exact - * spelling: a folded match is a Linux ENOENT and must veto the - * resolver's host-literal fallback outright, which would fold the - * same way against the host tree. A genuinely absent entry defers - * to that fallback when no mapping was consulted, and under a - * mapped prefix keeps the translated path so the actual syscall - * surfaces ENOENT. - */ - if (!mapped) { - sidecar_name_verdict_t probe = - sidecar_probe_exact_name(cur_fd, host_comp); - if (probe == SIDECAR_NAME_ERROR) { - int saved_errno = errno; - close(cur_fd); - errno = saved_errno; - return -1; - } - if (probe == SIDECAR_NAME_CASEFOLD) { - close(cur_fd); - errno = ENOENT; - return -1; - } - if (probe == SIDECAR_NAME_ABSENT && !used_mapping) { - close(cur_fd); - return 0; - } - } - break; - } - - /* Intermediate components resolve through openat, which folds case on - * APFS: reject folded matches here for the same reason as above. - * Absent entries proceed to the openat below, whose ENOENT handling - * distinguishes the fallback and mapped-prefix flows. - */ - if (!mapped) { - sidecar_name_verdict_t probe = - sidecar_probe_exact_name(cur_fd, host_comp); - if (probe == SIDECAR_NAME_ERROR) { - int saved_errno = errno; - close(cur_fd); - errno = saved_errno; - return -1; - } - if (probe == SIDECAR_NAME_CASEFOLD) { - close(cur_fd); - errno = ENOENT; - return -1; - } - } - - int next_fd = - openat(cur_fd, host_comp, O_RDONLY | O_DIRECTORY | O_CLOEXEC); - if (next_fd < 0) { - int saved_errno = errno; - close(cur_fd); - if (saved_errno != ENOENT && saved_errno != ENOTDIR) { - errno = saved_errno; - return -1; - } - if (!used_mapping) - return 0; - /* The walk left the sysroot beneath an index-mapped prefix. No - * index can exist under a missing directory, so the remaining - * components translate to themselves; the caller's syscall then - * reports ENOENT against the translated path. - */ - while (path_next_component(&scan, &comp, &comp_len)) { - char rest_comp[NAME_MAX + 1]; - if (path_component_copy(rest_comp, sizeof(rest_comp), comp, - comp_len) < 0) - return -1; - if (sidecar_append_component(out, outsz, &out_len, rest_comp, - absolute) < 0) - return -1; - } - return 1; - } - close(cur_fd); - cur_fd = next_fd; - } - - close(cur_fd); - return 1; -} - -int sidecar_translate_dirent_name(guest_fd_t dirfd, - const char *host_name, - char *guest_name, - size_t guest_name_sz) -{ - if (!sidecar_active()) - return 0; - if (sidecar_name_reserved(host_name)) - return 1; - - host_fd_ref_t ref; - if (host_fd_ref_open(dirfd, &ref) < 0) { - errno = EBADF; - return -1; - } - - sidecar_index_t index; - int rc = sidecar_load_index(ref.fd, &index); - host_fd_ref_close(&ref); - if (rc < 0) - return -1; - - const char *guest = sidecar_lookup_token(&index, host_name); - if (!guest) { - sidecar_index_free(&index); - return 0; - } - - size_t len = strlen(guest); - if (len + 1 > guest_name_sz) { - sidecar_index_free(&index); - errno = ENAMETOOLONG; - return -1; - } - memcpy(guest_name, guest, len + 1); - sidecar_index_free(&index); - return 0; -} -static int sidecar_encode_name(const char *name, char **out) -{ - static const char hex[] = "0123456789abcdef"; - size_t len = strlen(name); - char *buf = (char *) malloc(len * 2 + 1); - if (!buf) - return -1; - for (size_t i = 0; i < len; i++) { - unsigned char c = (unsigned char) name[i]; - buf[i * 2] = hex[c >> 4]; - buf[i * 2 + 1] = hex[c & 0x0f]; - } - buf[len * 2] = '\0'; - *out = buf; - return 0; -} - -static int sidecar_exact_name_exists(int dirfd, const char *name) -{ - int dup_fd = dup(dirfd); - if (dup_fd < 0) - return -1; - - DIR *dir = fdopendir(dup_fd); - if (!dir) { - close(dup_fd); - return -1; - } - - int found = 0; - struct dirent *de; - while ((de = readdir(dir)) != NULL) { - if (!strcmp(de->d_name, name)) { - found = 1; - break; - } - } - closedir(dir); - return found; -} - -static ssize_t sidecar_find_guest_index(const sidecar_index_t *index, - const char *guest_name) -{ - for (size_t i = 0; i < index->count; i++) { - if (!strcmp(index->rows[i].guest_name, guest_name)) - return (ssize_t) i; - } - return -1; -} - -/* fcntl-only lock acquisition. Caller must hold sidecar_global_lock so that the - * lock_two_indices nested path does not need a recursive mutex. - */ -static int sidecar_lock_index_fcntl(int dirfd, int *lock_fd) -{ - *lock_fd = openat(dirfd, SIDECAR_INDEX_LOCK_NAME, - O_RDWR | O_CREAT | O_CLOEXEC, 0644); - if (*lock_fd < 0) - return -1; - - struct flock fl = { - .l_type = F_WRLCK, - .l_whence = SEEK_SET, - .l_start = 0, - .l_len = 0, - }; - while (fcntl(*lock_fd, F_SETLKW, &fl) < 0) { - if (errno != EINTR) { - int saved_errno = errno; - close(*lock_fd); - *lock_fd = -1; - errno = saved_errno; - return -1; - } - } - return 0; -} - -static void sidecar_unlock_index_fcntl(int lock_fd) -{ - if (lock_fd < 0) - return; - struct flock fl = { - .l_type = F_UNLCK, - .l_whence = SEEK_SET, - .l_start = 0, - .l_len = 0, - }; - (void) fcntl(lock_fd, F_SETLK, &fl); - close(lock_fd); -} - -static int sidecar_lock_index(int dirfd, int *lock_fd) -{ - pthread_mutex_lock(&sidecar_global_lock); - if (sidecar_lock_index_fcntl(dirfd, lock_fd) < 0) { - int saved_errno = errno; - pthread_mutex_unlock(&sidecar_global_lock); - errno = saved_errno; - return -1; - } - return 0; -} - -static void sidecar_unlock_index(int lock_fd) -{ - sidecar_unlock_index_fcntl(lock_fd); - pthread_mutex_unlock(&sidecar_global_lock); -} - -static int sidecar_load_locked_index(int parent_dirfd, - int lock_fd, - sidecar_index_t *index) -{ - (void) lock_fd; - memset(index, 0, sizeof(*index)); - - int fd = openat(parent_dirfd, SIDECAR_INDEX_NAME, O_RDONLY | O_CLOEXEC); - if (fd < 0) { - if (errno == ENOENT) - return 0; - return -1; - } - - struct stat st; - if (fstat(fd, &st) < 0) { - close(fd); - return -1; - } - if (st.st_size == 0) { - close(fd); - return 0; - } - if (st.st_size < 0 || st.st_size >= (off_t) LINUX_PATH_MAX * 64) { - close(fd); - errno = EFBIG; - return -1; - } - - size_t size = (size_t) st.st_size; - char *buf = (char *) malloc(size + 1); - if (!buf) { - close(fd); - return -1; - } - if (lseek(fd, 0, SEEK_SET) < 0) { - int saved_errno = errno; - close(fd); - free(buf); - errno = saved_errno; - return -1; - } - - /* readv() avoids tripping clang's unix.BlockInCriticalSection checker. The - * checker flags read() while a pthread mutex is held (the global sidecar - * lock here), but regular-file reads do not actually block in any - * user-observable sense. readv with a single iovec slice is functionally - * identical to read. - */ - size_t off = 0; - while (off < size) { - struct iovec iov = {.iov_base = buf + off, .iov_len = size - off}; - ssize_t n = readv(fd, &iov, 1); - if (n < 0) { - if (errno == EINTR) - continue; - int saved_errno = errno; - close(fd); - free(buf); - errno = saved_errno; - return -1; - } - if (n == 0) - break; - off += (size_t) n; - } - buf[off] = '\0'; - - char *line = buf; - while (*line) { - char *newline = strchr(line, '\n'); - if (newline) - *newline = '\0'; - if (*line != '\0') { - char *tab = strchr(line, '\t'); - if (!tab || tab == line || tab[1] == '\0') { - close(fd); - free(buf); - sidecar_index_free(index); - errno = EPROTO; - return -1; - } - *tab = '\0'; - sidecar_row_t *rows = (sidecar_row_t *) realloc( - index->rows, (index->count + 1) * sizeof(sidecar_row_t)); - if (!rows) { - int saved_errno = errno; - close(fd); - free(buf); - sidecar_index_free(index); - errno = saved_errno; - return -1; - } - index->rows = rows; - if (sidecar_decode_name( - line, &index->rows[index->count].guest_name) < 0) { - int saved_errno = errno; - close(fd); - free(buf); - sidecar_index_free(index); - errno = saved_errno; - return -1; - } - if (strlen(tab + 1) != SIDECAR_TOKEN_NAME_LEN) { - close(fd); - free(buf); - sidecar_index_free(index); - errno = EPROTO; - return -1; - } - memcpy(index->rows[index->count].token, tab + 1, - SIDECAR_TOKEN_NAME_LEN + 1); - index->count++; - } - if (!newline) - break; - line = newline + 1; - } - - if (close(fd) < 0) { - int saved_errno = errno; - free(buf); - sidecar_index_free(index); - errno = saved_errno; - return -1; - } - free(buf); - return 0; -} - -/* Write all bytes or fail. - * - * Returns 0 on success, -1 with errno set on error. Handles short writes by - * retrying until everything is committed. - */ -/* Serialize the index into a malloc'd buffer. *@out_len receives the byte - * count. - * - * Returns 0 on success, -1 with errno on error; *@out is NULL on failure. - */ -static int sidecar_serialize_index(const sidecar_index_t *index, - char **out, - size_t *out_len) -{ - *out = NULL; - *out_len = 0; - - /* Estimate capacity: each row is enc(name) + '\t' + token + '\n'. enc is at - * most 2 * NAME_MAX (hex encoding) plus a null. Round up. - */ - size_t cap = 256; - char *buf = (char *) malloc(cap); - if (!buf) - return -1; - size_t len = 0; - - for (size_t i = 0; i < index->count; i++) { - char *enc = NULL; - if (sidecar_encode_name(index->rows[i].guest_name, &enc) < 0) { - int saved_errno = errno; - free(buf); - errno = saved_errno; - return -1; - } - size_t enc_len = strlen(enc); - size_t row_len = enc_len + 1 + SIDECAR_TOKEN_NAME_LEN + 1; - if (len + row_len > cap) { - size_t new_cap = cap; - while (new_cap < len + row_len) - new_cap *= 2; - /* Grow via malloc + copy rather than realloc: on realloc failure - * the original block stays valid and must be freed, but static - * analyzers model realloc as freeing its argument and flag that - * free as a use-after-free. An explicit copy keeps ownership clear. - */ - char *nb = (char *) malloc(new_cap); - if (!nb) { - int saved_errno = errno; - free(enc); - free(buf); - errno = saved_errno; - return -1; - } - memcpy(nb, buf, len); - free(buf); - buf = nb; - cap = new_cap; - } - memcpy(buf + len, enc, enc_len); - len += enc_len; - buf[len++] = '\t'; - memcpy(buf + len, index->rows[i].token, SIDECAR_TOKEN_NAME_LEN); - len += SIDECAR_TOKEN_NAME_LEN; - buf[len++] = '\n'; - free(enc); - } - - *out = buf; - *out_len = len; - return 0; -} - -/* Write the index atomically: serialize into memory, write to a tmp file - * adjacent to the real index, then renameat() over the real index. The caller - * already holds a separate lock sentinel for cross-process serialization. - */ -static int sidecar_write_locked_index(int parent_dirfd, - int lock_fd, - const sidecar_index_t *index) -{ - (void) lock_fd; - - char *payload = NULL; - size_t payload_len = 0; - if (sidecar_serialize_index(index, &payload, &payload_len) < 0) - return -1; - - int tmp_fd = openat(parent_dirfd, SIDECAR_INDEX_TMP_NAME, - O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); - if (tmp_fd < 0) { - int saved_errno = errno; - free(payload); - errno = saved_errno; - return -1; - } - if (payload_len > 0 && write_all(tmp_fd, payload, payload_len) < 0) { - int saved_errno = errno; - close(tmp_fd); - (void) unlinkat(parent_dirfd, SIDECAR_INDEX_TMP_NAME, 0); - free(payload); - errno = saved_errno; - return -1; - } - if (close(tmp_fd) < 0) { - int saved_errno = errno; - (void) unlinkat(parent_dirfd, SIDECAR_INDEX_TMP_NAME, 0); - free(payload); - errno = saved_errno; - return -1; - } - /* Invalidate the cache to UNKNOWN BEFORE the rename so a concurrent walker - * that loses the race to read the new file does not return a stale ABSENT - * verdict from this slot. A reader entering the window between rename and - * post-rename mark would otherwise skip the openat and miss the - * freshly-published index. UNKNOWN forces the reader to do the openat and - * observe the new file directly. - */ - sidecar_idx_cache_invalidate(parent_dirfd); - if (renameat(parent_dirfd, SIDECAR_INDEX_TMP_NAME, parent_dirfd, - SIDECAR_INDEX_NAME) < 0) { - int saved_errno = errno; - (void) unlinkat(parent_dirfd, SIDECAR_INDEX_TMP_NAME, 0); - free(payload); - errno = saved_errno; - return -1; - } - free(payload); - return 0; -} - -static int sidecar_remove_guest_locked(sidecar_index_t *index, - size_t remove_idx) -{ - if (remove_idx >= index->count) { - errno = ENOENT; - return -1; - } - - free(index->rows[remove_idx].guest_name); - if (remove_idx + 1 < index->count) { - memmove(&index->rows[remove_idx], &index->rows[remove_idx + 1], - (index->count - remove_idx - 1) * sizeof(sidecar_row_t)); - } - index->count--; - return 0; -} - -static int sidecar_append_guest_locked(sidecar_index_t *index, - const char *guest_name, - const char *token) -{ - sidecar_row_t *rows = (sidecar_row_t *) realloc( - index->rows, (index->count + 1) * sizeof(sidecar_row_t)); - if (!rows) - return -1; - - index->rows = rows; - index->rows[index->count].guest_name = strdup(guest_name); - if (!index->rows[index->count].guest_name) - return -1; - memcpy(index->rows[index->count].token, token, SIDECAR_TOKEN_NAME_LEN + 1); - index->count++; - return 0; -} - -static int sidecar_generate_token(char token[SIDECAR_TOKEN_NAME_LEN + 1]) -{ - uint64_t rnd = (((uint64_t) arc4random()) << 32) | arc4random(); - int n = snprintf(token, SIDECAR_TOKEN_NAME_LEN + 1, "%s%016llx", - SIDECAR_TOKEN_PREFIX, (unsigned long long) rnd); - if (n != SIDECAR_TOKEN_NAME_LEN) { - errno = EINVAL; - return -1; - } - return 0; -} - -/* Open the parent directory sidecar would maintain an index in for @path. - * Returns 0 with parent->dirfd open on success, -1 on error, and 1 when the - * parent resolves outside the sysroot: such paths follow the resolver's - * host-literal fallback (proc_resolve_sysroot_path_flags) and carry no index, - * so the mutation entry points hand them back as SIDECAR_NOT_HANDLED. - */ -static int sidecar_walk_parent_at(guest_fd_t dirfd, - const char *path, - sidecar_parent_t *parent) -{ - memset(parent, 0, sizeof(*parent)); - if (!path || path[0] == '\0') { - errno = ENOENT; - return -1; - } - - char normalized[LINUX_PATH_MAX]; - char work[LINUX_PATH_MAX]; - if (path[0] == '/') { - if (path_openat2_normalize_in_root(path, normalized, - sizeof(normalized)) < 0) { - errno = ENAMETOOLONG; - return -1; - } - str_copy_trunc(work, normalized, sizeof(work)); - parent->absolute = true; - } else { - str_copy_trunc(work, path, sizeof(work)); - } - - char *slash = strrchr(work, '/'); - const char *basename = slash ? slash + 1 : work; - if (*basename == '\0') { - errno = ENOENT; - return -1; - } - if (strlen(basename) >= sizeof(parent->basename)) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(parent->basename, basename, strlen(basename) + 1); - if (sidecar_name_reserved(parent->basename)) { - errno = ENOENT; - return -1; - } - - if (slash) - *slash = '\0'; - else - str_copy_trunc(work, ".", sizeof(work)); - - if (!strcmp(work, ".")) { - bool absolute = false; - return sidecar_open_base(dirfd, path, normalized, sizeof(normalized), - &parent->dirfd, &absolute); - } - - char guest_parent[LINUX_PATH_MAX]; - if (path[0] == '/') { - if (!strcmp(work, ".")) - str_copy_trunc(guest_parent, "/", sizeof(guest_parent)); - else if (snprintf(guest_parent, sizeof(guest_parent), "/%s", work) >= - (int) sizeof(guest_parent)) { - errno = ENAMETOOLONG; - return -1; - } - } else { - str_copy_trunc(guest_parent, work, sizeof(guest_parent)); - } - - char host_parent[LINUX_PATH_MAX]; - int rc = sidecar_translate_lookup_at(dirfd, guest_parent, host_parent, - sizeof(host_parent)); - if (rc < 0) - return -1; - if (rc > 0) { - if (path[0] == '/' || dirfd == LINUX_AT_FDCWD) { - parent->dirfd = - open(host_parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC); - } else { - host_fd_ref_t ref; - if (host_dirfd_ref_open(dirfd, &ref) < 0) { - errno = EBADF; - return -1; - } - parent->dirfd = - openat(ref.fd, host_parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC); - host_fd_ref_close(&ref); - } - } else if (path[0] == '/') { - /* The parent does not resolve inside the sysroot (or lives on a - * procemu-backed prefix): the operation belongs to the regular - * translation flow, not the sidecar index. - */ - return 1; - } else if (dirfd == LINUX_AT_FDCWD) { - parent->dirfd = open(work, O_RDONLY | O_DIRECTORY | O_CLOEXEC); - } else { - host_fd_ref_t ref; - if (host_dirfd_ref_open(dirfd, &ref) < 0) { - errno = EBADF; - return -1; - } - parent->dirfd = - openat(ref.fd, work, O_RDONLY | O_DIRECTORY | O_CLOEXEC); - host_fd_ref_close(&ref); - } - - return parent->dirfd < 0 ? -1 : 0; -} - -static void sidecar_parent_close(sidecar_parent_t *parent) -{ - if (parent && parent->dirfd >= 0) { - close(parent->dirfd); - parent->dirfd = -1; - } -} - -static int sidecar_parent_stat(sidecar_parent_t *parent, struct stat *st) -{ - if (fstat(parent->dirfd, st) < 0) { - sidecar_parent_close(parent); - return -1; - } - return 0; -} - -static const char *sidecar_existing_name_locked(const sidecar_index_t *index, - int dirfd, - const char *guest_name) -{ - const char *mapped = sidecar_lookup_guest(index, guest_name); - if (mapped) - return mapped; - return sidecar_exact_name_exists(dirfd, guest_name) == 1 ? guest_name - : NULL; -} - -int sidecar_openat(guest_fd_t dirfd, - const char *path, - int linux_flags, - mode_t mode) -{ - if (!sidecar_active() || !(linux_flags & LINUX_O_CREAT)) - return (int) SIDECAR_NOT_HANDLED; - - sidecar_parent_t parent; - int walk_rc = sidecar_walk_parent_at(dirfd, path, &parent); - if (walk_rc < 0) - return -1; - if (walk_rc > 0) - return (int) SIDECAR_NOT_HANDLED; - if (sidecar_name_reserved(parent.basename)) { - sidecar_parent_close(&parent); - errno = ENOENT; - return -1; - } - - int lock_fd = -1; - if (sidecar_lock_index(parent.dirfd, &lock_fd) < 0) { - sidecar_parent_close(&parent); - return -1; - } - - sidecar_index_t index; - if (sidecar_load_locked_index(parent.dirfd, lock_fd, &index) < 0) { - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - return -1; - } - - int mac_flags = translate_open_flags(linux_flags); - const char *existing = - sidecar_existing_name_locked(&index, parent.dirfd, parent.basename); - if (existing) { - if (linux_flags & LINUX_O_EXCL) { - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - errno = EEXIST; - return -1; - } - int fd = openat(parent.dirfd, existing, mac_flags, mode); - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - return fd; - } - - int fd = -1; - char token[SIDECAR_TOKEN_NAME_LEN + 1]; - for (;;) { - if (sidecar_generate_token(token) < 0) - break; - fd = openat(parent.dirfd, token, mac_flags | O_EXCL, mode); - if (fd >= 0) - break; - if (errno != EEXIST) - break; - } - if (fd < 0) { - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - return -1; - } - - sidecar_row_t *rows = (sidecar_row_t *) realloc( - index.rows, (index.count + 1) * sizeof(sidecar_row_t)); - if (!rows) { - int saved_errno = errno; - close(fd); - unlinkat(parent.dirfd, token, 0); - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - errno = saved_errno; - return -1; - } - index.rows = rows; - index.rows[index.count].guest_name = strdup(parent.basename); - memcpy(index.rows[index.count].token, token, sizeof(token)); - index.count++; - if (sidecar_write_locked_index(parent.dirfd, lock_fd, &index) < 0) { - int saved_errno = errno; - close(fd); - unlinkat(parent.dirfd, token, 0); - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - errno = saved_errno; - return -1; - } - - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - return fd; -} - -int64_t sidecar_mkdirat(guest_fd_t dirfd, const char *path, mode_t mode) -{ - if (!sidecar_active()) - return SIDECAR_NOT_HANDLED; - - sidecar_parent_t parent; - int walk_rc = sidecar_walk_parent_at(dirfd, path, &parent); - if (walk_rc < 0) - return linux_errno(); - if (walk_rc > 0) - return SIDECAR_NOT_HANDLED; - - int lock_fd = -1; - if (sidecar_lock_index(parent.dirfd, &lock_fd) < 0) { - sidecar_parent_close(&parent); - return linux_errno(); - } - - sidecar_index_t index; - if (sidecar_load_locked_index(parent.dirfd, lock_fd, &index) < 0) { - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - return linux_errno(); - } - if (sidecar_existing_name_locked(&index, parent.dirfd, parent.basename)) { - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - return -LINUX_EEXIST; - } - - char token[SIDECAR_TOKEN_NAME_LEN + 1]; - for (;;) { - if (sidecar_generate_token(token) < 0) - break; - if (mkdirat(parent.dirfd, token, mode) == 0) - break; - if (errno != EEXIST) { - token[0] = '\0'; - break; - } - } - if (token[0] == '\0') { - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - return linux_errno(); - } - - sidecar_row_t *rows = (sidecar_row_t *) realloc( - index.rows, (index.count + 1) * sizeof(sidecar_row_t)); - if (!rows) { - int saved_errno = errno; - unlinkat(parent.dirfd, token, AT_REMOVEDIR); - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - errno = saved_errno; - return linux_errno(); - } - index.rows = rows; - index.rows[index.count].guest_name = strdup(parent.basename); - memcpy(index.rows[index.count].token, token, sizeof(token)); - index.count++; - if (sidecar_write_locked_index(parent.dirfd, lock_fd, &index) < 0) { - int saved_errno = errno; - unlinkat(parent.dirfd, token, AT_REMOVEDIR); - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - errno = saved_errno; - return linux_errno(); - } - - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - return 0; -} - -int64_t sidecar_unlinkat(guest_fd_t dirfd, const char *path, int flags) -{ - if (!sidecar_active()) - return SIDECAR_NOT_HANDLED; - - sidecar_parent_t parent; - int walk_rc = sidecar_walk_parent_at(dirfd, path, &parent); - if (walk_rc < 0) - return linux_errno(); - if (walk_rc > 0) - return SIDECAR_NOT_HANDLED; - - int lock_fd = -1; - if (sidecar_lock_index(parent.dirfd, &lock_fd) < 0) { - sidecar_parent_close(&parent); - return linux_errno(); - } - - sidecar_index_t index; - if (sidecar_load_locked_index(parent.dirfd, lock_fd, &index) < 0) { - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - return linux_errno(); - } - - size_t remove_idx = index.count; - char host_name[SIDECAR_TOKEN_NAME_LEN + 1]; - bool have_host_name = false; - for (size_t i = 0; i < index.count; i++) { - if (!strcmp(index.rows[i].guest_name, parent.basename)) { - memcpy(host_name, index.rows[i].token, sizeof(host_name)); - remove_idx = i; - have_host_name = true; - break; - } - } - - int64_t rc = 0; - if (have_host_name) { - /* Write the index update first so that an interrupted unlinkat does not - * leave the on-disk token without a mapping. If the unlinkat fails, - * restore the mapping and rewrite the index; the second write going - * wrong is logged but cannot be helped. - */ - sidecar_row_t saved_row = index.rows[remove_idx]; - char *saved_name = strdup(saved_row.guest_name); - if (!saved_name) { - rc = linux_errno(); - /* No mutation happened yet, so reporting the allocation failure - * keeps the index and host state unchanged. - */ - } else { - sidecar_remove_guest_locked(&index, remove_idx); - if (sidecar_write_locked_index(parent.dirfd, lock_fd, &index) < 0) { - rc = linux_errno(); - free(saved_name); - /* No host mutation happened, in-memory index has the entry - * removed but on-disk still holds the original. That is - * consistent with the failure being reported to the guest. - */ - } else if (unlinkat(parent.dirfd, host_name, flags) < 0) { - int saved_errno = errno; - sidecar_row_t *rows = (sidecar_row_t *) realloc( - index.rows, (index.count + 1) * sizeof(sidecar_row_t)); - if (rows && saved_name) { - index.rows = rows; - index.rows[index.count].guest_name = saved_name; - memcpy(index.rows[index.count].token, saved_row.token, - sizeof(saved_row.token)); - index.count++; - (void) sidecar_write_locked_index(parent.dirfd, lock_fd, - &index); - } else { - free(saved_name); - } - errno = saved_errno; - rc = linux_errno(); - } else { - free(saved_name); - } - } - } else { - int exists = sidecar_exact_name_exists(parent.dirfd, parent.basename); - if (exists < 0) - rc = linux_errno(); - else if (exists == 0) - rc = -LINUX_ENOENT; - else if (unlinkat(parent.dirfd, parent.basename, flags) < 0) - rc = linux_errno(); - } - - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&parent); - return rc; -} - -/* Same contract as sidecar_walk_parent_at: 0 resolved, -1 error, 1 when the - * path lives outside the sysroot and the caller should not handle it. - */ -static int sidecar_resolve_existing_at(guest_fd_t dirfd, - const char *path, - sidecar_parent_t *parent, - char host_name[NAME_MAX + 1]) -{ - int walk_rc = sidecar_walk_parent_at(dirfd, path, parent); - if (walk_rc != 0) - return walk_rc; - - sidecar_index_t index; - if (sidecar_load_index(parent->dirfd, &index) < 0) { - sidecar_parent_close(parent); - return -1; - } - - const char *mapped = sidecar_lookup_guest(&index, parent->basename); - if (mapped) { - str_copy_trunc(host_name, mapped, NAME_MAX + 1); - sidecar_index_free(&index); - return 0; - } - sidecar_index_free(&index); - - int exists = sidecar_exact_name_exists(parent->dirfd, parent->basename); - if (exists < 0) { - sidecar_parent_close(parent); - return -1; - } - if (exists == 0) { - sidecar_parent_close(parent); - errno = ENOENT; - return -1; - } - str_copy_trunc(host_name, parent->basename, NAME_MAX + 1); - return 0; -} - -int64_t sidecar_linkat(guest_fd_t olddirfd, - const char *oldpath, - guest_fd_t newdirfd, - const char *newpath, - int flags) -{ - if (!sidecar_active()) - return SIDECAR_NOT_HANDLED; - - sidecar_parent_t old_parent; - char old_host[NAME_MAX + 1]; - int old_rc = - sidecar_resolve_existing_at(olddirfd, oldpath, &old_parent, old_host); - if (old_rc < 0) - return linux_errno(); - if (old_rc > 0) - return SIDECAR_NOT_HANDLED; - - sidecar_parent_t new_parent; - int new_rc = sidecar_walk_parent_at(newdirfd, newpath, &new_parent); - if (new_rc != 0) { - sidecar_parent_close(&old_parent); - return new_rc < 0 ? linux_errno() : SIDECAR_NOT_HANDLED; - } - - int lock_fd = -1; - if (sidecar_lock_index(new_parent.dirfd, &lock_fd) < 0) { - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return linux_errno(); - } - - sidecar_index_t index; - if (sidecar_load_locked_index(new_parent.dirfd, lock_fd, &index) < 0) { - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return linux_errno(); - } - if (sidecar_existing_name_locked(&index, new_parent.dirfd, - new_parent.basename)) { - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return -LINUX_EEXIST; - } - - char token[SIDECAR_TOKEN_NAME_LEN + 1]; - int rc = -LINUX_EIO; - int mac_flags = translate_at_flags(flags); - for (;;) { - if (sidecar_generate_token(token) < 0) - break; - if (linkat(old_parent.dirfd, old_host, new_parent.dirfd, token, - mac_flags) == 0) { - rc = 0; - break; - } - if (errno != EEXIST) { - rc = linux_errno(); - break; - } - } - if (rc == 0) { - if (sidecar_append_guest_locked(&index, new_parent.basename, token) < - 0) { - int saved_errno = errno; - unlinkat(new_parent.dirfd, token, 0); - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - errno = saved_errno; - return linux_errno(); - } - if (sidecar_write_locked_index(new_parent.dirfd, lock_fd, &index) < 0) { - int saved_errno = errno; - unlinkat(new_parent.dirfd, token, 0); - errno = saved_errno; - rc = linux_errno(); - } - } - - sidecar_index_free(&index); - sidecar_unlock_index(lock_fd); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return rc; -} - -typedef struct { - size_t index_pos; - bool mapped; - bool exists; - char host_name[NAME_MAX + 1]; -} sidecar_entry_state_t; - -static int sidecar_read_entry_state(const sidecar_index_t *index, - int dirfd, - const char *guest_name, - sidecar_entry_state_t *state) -{ - memset(state, 0, sizeof(*state)); - state->index_pos = index->count; - - ssize_t mapped_idx = sidecar_find_guest_index(index, guest_name); - if (mapped_idx >= 0) { - state->mapped = true; - state->exists = true; - state->index_pos = (size_t) mapped_idx; - str_copy_trunc(state->host_name, index->rows[mapped_idx].token, - sizeof(state->host_name)); - return 0; - } - - int exact = sidecar_exact_name_exists(dirfd, guest_name); - if (exact < 0) - return -1; - if (exact == 1) { - state->exists = true; - str_copy_trunc(state->host_name, guest_name, sizeof(state->host_name)); - } - return 0; -} - -static int sidecar_lock_two_indices(sidecar_parent_t *first, - sidecar_parent_t *second, - bool same_dir, - int *first_lock_fd, - int *second_lock_fd, - bool *swapped) -{ - struct stat first_st; - struct stat second_st; - if (sidecar_parent_stat(first, &first_st) < 0 || - sidecar_parent_stat(second, &second_st) < 0) { - return -1; - } - - sidecar_parent_t *lock_a = first; - sidecar_parent_t *lock_b = second; - *swapped = false; - if (first_st.st_dev > second_st.st_dev || - (first_st.st_dev == second_st.st_dev && - first_st.st_ino > second_st.st_ino)) { - lock_a = second; - lock_b = first; - *swapped = true; - } - - pthread_mutex_lock(&sidecar_global_lock); - if (sidecar_lock_index_fcntl(lock_a->dirfd, first_lock_fd) < 0) { - int saved_errno = errno; - pthread_mutex_unlock(&sidecar_global_lock); - errno = saved_errno; - return -1; - } - if (same_dir) { - *second_lock_fd = *first_lock_fd; - return 0; - } - if (sidecar_lock_index_fcntl(lock_b->dirfd, second_lock_fd) < 0) { - int saved_errno = errno; - sidecar_unlock_index_fcntl(*first_lock_fd); - *first_lock_fd = -1; - pthread_mutex_unlock(&sidecar_global_lock); - errno = saved_errno; - return -1; - } - return 0; -} - -static void sidecar_unlock_two_indices(int first_lock_fd, - int second_lock_fd, - bool same_dir) -{ - if (same_dir) { - sidecar_unlock_index_fcntl(first_lock_fd); - } else { - sidecar_unlock_index_fcntl(second_lock_fd); - sidecar_unlock_index_fcntl(first_lock_fd); - } - pthread_mutex_unlock(&sidecar_global_lock); -} - -int64_t sidecar_renameat(guest_fd_t olddirfd, - const char *oldpath, - guest_fd_t newdirfd, - const char *newpath, - int flags) -{ - if (!sidecar_active()) - return SIDECAR_NOT_HANDLED; - - if (flags & LINUX_RENAME_EXCHANGE) { - char old_host_path[LINUX_PATH_MAX]; - char new_host_path[LINUX_PATH_MAX]; - int old_rc = sidecar_translate_lookup_at( - olddirfd, oldpath, old_host_path, sizeof(old_host_path)); - int new_rc = sidecar_translate_lookup_at( - newdirfd, newpath, new_host_path, sizeof(new_host_path)); - if (old_rc < 0 || new_rc < 0) - return linux_errno(); - if (old_rc == 0 || new_rc == 0) - return SIDECAR_NOT_HANDLED; - - if (renamex_np(old_host_path, new_host_path, RENAME_SWAP) < 0) - return linux_errno(); - return 0; - } - - sidecar_parent_t old_parent; - int old_walk_rc = sidecar_walk_parent_at(olddirfd, oldpath, &old_parent); - if (old_walk_rc < 0) - return linux_errno(); - if (old_walk_rc > 0) - return SIDECAR_NOT_HANDLED; - - sidecar_parent_t new_parent; - int new_walk_rc = sidecar_walk_parent_at(newdirfd, newpath, &new_parent); - if (new_walk_rc != 0) { - sidecar_parent_close(&old_parent); - return new_walk_rc < 0 ? linux_errno() : SIDECAR_NOT_HANDLED; - } - - if (!strcmp(old_parent.basename, new_parent.basename)) { - struct stat old_same; - struct stat new_same; - if (fstat(old_parent.dirfd, &old_same) == 0 && - fstat(new_parent.dirfd, &new_same) == 0 && - old_same.st_dev == new_same.st_dev && - old_same.st_ino == new_same.st_ino) { - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return 0; - } - } - - struct stat old_dir_st; - struct stat new_dir_st; - if (fstat(old_parent.dirfd, &old_dir_st) < 0 || - fstat(new_parent.dirfd, &new_dir_st) < 0) { - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return linux_errno(); - } - bool same_dir = old_dir_st.st_dev == new_dir_st.st_dev && - old_dir_st.st_ino == new_dir_st.st_ino; - - int first_lock_fd = -1; - int second_lock_fd = -1; - bool swapped = false; - if (sidecar_lock_two_indices(&old_parent, &new_parent, same_dir, - &first_lock_fd, &second_lock_fd, - &swapped) < 0) { - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return linux_errno(); - } - - sidecar_index_t old_index = {0}; - sidecar_index_t new_index = {0}; - int rc; - if (same_dir) { - rc = sidecar_load_locked_index(old_parent.dirfd, first_lock_fd, - &old_index); - } else if (!swapped) { - rc = sidecar_load_locked_index(old_parent.dirfd, first_lock_fd, - &old_index); - if (rc == 0) - rc = sidecar_load_locked_index(new_parent.dirfd, second_lock_fd, - &new_index); - } else { - rc = sidecar_load_locked_index(new_parent.dirfd, first_lock_fd, - &new_index); - if (rc == 0) - rc = sidecar_load_locked_index(old_parent.dirfd, second_lock_fd, - &old_index); - } - if (rc < 0) { - if (!same_dir) { - sidecar_index_free(&old_index); - sidecar_index_free(&new_index); - } else { - sidecar_index_free(&old_index); - } - sidecar_unlock_two_indices(first_lock_fd, second_lock_fd, same_dir); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return linux_errno(); - } - - /* Snapshot the loaded indices so that a host renameat failure later can - * roll the on-disk index back. Without this, an index update followed by a - * failed host renameat leaves the mapping pointing at a moved or missing - * token. - */ - sidecar_index_t saved_old = {0}; - sidecar_index_t saved_new = {0}; - if (sidecar_index_clone(&old_index, &saved_old) < 0 || - (!same_dir && sidecar_index_clone(&new_index, &saved_new) < 0)) { - int64_t err = linux_errno(); - sidecar_index_free(&saved_old); - if (!same_dir) { - sidecar_index_free(&new_index); - sidecar_index_free(&saved_new); - } - sidecar_index_free(&old_index); - sidecar_unlock_two_indices(first_lock_fd, second_lock_fd, same_dir); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return err; - } - - sidecar_index_t *dst_index = same_dir ? &old_index : &new_index; - sidecar_entry_state_t old_state; - sidecar_entry_state_t new_state; - if (sidecar_read_entry_state(&old_index, old_parent.dirfd, - old_parent.basename, &old_state) < 0 || - sidecar_read_entry_state(dst_index, new_parent.dirfd, - new_parent.basename, &new_state) < 0) { - int64_t err = linux_errno(); - if (!same_dir) - sidecar_index_free(&new_index); - sidecar_index_free(&old_index); - sidecar_unlock_two_indices(first_lock_fd, second_lock_fd, same_dir); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return err; - } - - if (!old_state.exists) { - if (!same_dir) - sidecar_index_free(&new_index); - sidecar_index_free(&old_index); - sidecar_unlock_two_indices(first_lock_fd, second_lock_fd, same_dir); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return -LINUX_ENOENT; - } - if ((flags & LINUX_RENAME_NOREPLACE) && new_state.exists) { - if (!same_dir) - sidecar_index_free(&new_index); - sidecar_index_free(&old_index); - sidecar_unlock_two_indices(first_lock_fd, second_lock_fd, same_dir); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return -LINUX_EEXIST; - } - - char target_host[NAME_MAX + 1]; - bool add_new_mapping = false; - bool rename_existing_old_mapping = - same_dir && old_state.mapped && !new_state.exists; - if (new_state.exists) { - str_copy_trunc(target_host, new_state.host_name, sizeof(target_host)); - } else if (old_state.mapped) { - str_copy_trunc(target_host, old_state.host_name, sizeof(target_host)); - add_new_mapping = !same_dir; - } else { - for (;;) { - if (sidecar_generate_token(target_host) < 0) - break; - int probe = fstatat(new_parent.dirfd, target_host, - &(struct stat) {0}, AT_SYMLINK_NOFOLLOW); - if (probe < 0 && errno == ENOENT) { - add_new_mapping = true; - break; - } - if (probe == 0) - continue; - if (errno == ENOENT) - continue; - break; - } - if (!add_new_mapping) { - int64_t err = linux_errno(); - sidecar_index_free(&saved_old); - if (!same_dir) { - sidecar_index_free(&saved_new); - sidecar_index_free(&new_index); - } - sidecar_index_free(&old_index); - sidecar_unlock_two_indices(first_lock_fd, second_lock_fd, same_dir); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return err; - } - } - - int64_t result = 0; - int mod_rc = 0; - if (rename_existing_old_mapping) { - free(old_index.rows[old_state.index_pos].guest_name); - old_index.rows[old_state.index_pos].guest_name = - strdup(new_parent.basename); - if (!old_index.rows[old_state.index_pos].guest_name) - mod_rc = -1; - } else if (old_state.mapped) { - if (sidecar_remove_guest_locked(&old_index, old_state.index_pos) < 0) - mod_rc = -1; - } - if (mod_rc == 0) { - if (new_state.mapped) { - size_t idx = new_state.index_pos; - if (same_dir && old_state.mapped && idx > old_state.index_pos) - idx--; - free(dst_index->rows[idx].guest_name); - dst_index->rows[idx].guest_name = strdup(new_parent.basename); - if (!dst_index->rows[idx].guest_name) - mod_rc = -1; - } else if (add_new_mapping) { - if (sidecar_append_guest_locked(dst_index, new_parent.basename, - target_host) < 0) - mod_rc = -1; - } - } - if (mod_rc < 0) { - result = linux_errno(); - goto cleanup; - } - - /* Commit the index changes to disk before any host filesystem mutation so a - * failed write does not leave an orphan host file. The host renameat is the - * actual commit point; on host failure, revert the on-disk index by writing - * the saved snapshot back. - */ - if (same_dir) { - rc = sidecar_write_locked_index(old_parent.dirfd, first_lock_fd, - &old_index); - } else if (!swapped) { - rc = sidecar_write_locked_index(old_parent.dirfd, first_lock_fd, - &old_index); - if (rc == 0) - rc = sidecar_write_locked_index(new_parent.dirfd, second_lock_fd, - &new_index); - } else { - rc = sidecar_write_locked_index(new_parent.dirfd, first_lock_fd, - &new_index); - if (rc == 0) - rc = sidecar_write_locked_index(old_parent.dirfd, second_lock_fd, - &old_index); - } - if (rc < 0) { - result = linux_errno(); - goto cleanup; - } - - if (!(same_dir && old_state.mapped && !new_state.exists && - !strcmp(target_host, old_state.host_name))) { - if (renameat(old_parent.dirfd, old_state.host_name, new_parent.dirfd, - target_host) < 0) { - result = linux_errno(); - /* Roll the index back to the pre-modification state so the mapping - * stays consistent with the unchanged host tree. A failed rollback - * write is the best-effort case; the guest sees the original - * renameat errno regardless. - */ - if (same_dir) { - (void) sidecar_write_locked_index(old_parent.dirfd, - first_lock_fd, &saved_old); - } else if (!swapped) { - (void) sidecar_write_locked_index(old_parent.dirfd, - first_lock_fd, &saved_old); - (void) sidecar_write_locked_index(new_parent.dirfd, - second_lock_fd, &saved_new); - } else { - (void) sidecar_write_locked_index(new_parent.dirfd, - first_lock_fd, &saved_new); - (void) sidecar_write_locked_index(old_parent.dirfd, - second_lock_fd, &saved_old); - } - } - } - -cleanup: - sidecar_index_free(&saved_old); - if (!same_dir) { - sidecar_index_free(&saved_new); - sidecar_index_free(&new_index); - } - sidecar_index_free(&old_index); - sidecar_unlock_two_indices(first_lock_fd, second_lock_fd, same_dir); - sidecar_parent_close(&old_parent); - sidecar_parent_close(&new_parent); - return result; -} diff --git a/src/syscall/sidecar.h b/src/syscall/sidecar.h deleted file mode 100644 index 384c1272..00000000 --- a/src/syscall/sidecar.h +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Case-folding fallback VFS helpers - * - * Copyright 2026 elfuse contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -#pragma once - -#include -#include -#include -#include - -#include "syscall/internal.h" - -#define SIDECAR_INDEX_NAME ".elfuse_case_index" -#define SIDECAR_TOKEN_PREFIX ".ef_" -#define SIDECAR_TOKEN_HEX_LEN 16 -#define SIDECAR_TOKEN_NAME_LEN (4 + SIDECAR_TOKEN_HEX_LEN) -#define SIDECAR_NOT_HANDLED ((int64_t) INT64_MIN) - -bool sidecar_active(void); -bool sidecar_name_reserved(const char *name); -bool sidecar_path_targets_reserved_name(const char *path); -int sidecar_translate_lookup_at(guest_fd_t dirfd, - const char *path, - char *out, - size_t outsz); -int sidecar_translate_dirent_name(guest_fd_t dirfd, - const char *host_name, - char *guest_name, - size_t guest_name_sz); -int sidecar_openat(guest_fd_t dirfd, - const char *path, - int linux_flags, - mode_t mode); -int64_t sidecar_mkdirat(guest_fd_t dirfd, const char *path, mode_t mode); -int64_t sidecar_unlinkat(guest_fd_t dirfd, const char *path, int flags); -int64_t sidecar_linkat(guest_fd_t olddirfd, - const char *oldpath, - guest_fd_t newdirfd, - const char *newpath, - int flags); -int64_t sidecar_renameat(guest_fd_t olddirfd, - const char *oldpath, - guest_fd_t newdirfd, - const char *newpath, - int flags); diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index ca5e833f..75d2209c 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -2071,8 +2071,9 @@ static int64_t sc_openat2(guest_t *g, if (opened >= 0 && (resolve & RESOLVE_NO_XDEV) && no_xdev_start_class >= 0) { /* The string walker cannot see symlinks that the kernel followed - * during the actual open (sysroot case-fold sidecar shadows hide - * the link node from the precheck's fstatat walk). Re-classify the + * during the actual open (on a case-fold sysroot a link stored + * under an escaped spelling is invisible to the precheck's fstatat + * walk when the spelling changes underneath it). Re-classify the * opened fd's resolved host path; if it landed in a different mount * class, drop the fd and return EXDEV. This also tightens the * precheck-vs-open TOCTOU window since the post-check sees the diff --git a/tests/test-case-collision.c b/tests/test-case-collision.c index ac6d3610..fb37ad82 100644 --- a/tests/test-case-collision.c +++ b/tests/test-case-collision.c @@ -3,6 +3,23 @@ * * Copyright 2026 elfuse contributors * SPDX-License-Identifier: Apache-2.0 + * + * Names that a case-folding volume would merge must stay separate files to the + * guest, and every syscall that names a file has to agree about which one it + * means. This walks the whole surface (open, rename, renameat2 with EXCHANGE + * and NOREPLACE, linkat, symlinkat, getdents64, statx, xattr) against a set + * of names differing only in case. + * + * Code under test: the resolver in src/syscall/casefold-walk.c reached through + * src/syscall/path.c. A regression shows up as two guest names resolving to one + * file, so a write through one spelling is visible through the other, or as a + * listing reporting a name the guest cannot then open. + * + * Nothing here may be conditional on the sysroot's on-disk layout. Three checks + * once gated themselves on the presence of a per-directory index file that the + * stateless scheme does not create, which made them report success without + * running their assertions; a guard whose condition cannot hold is a silent + * pass, so these assert unconditionally. Run under --sysroot. */ #include @@ -158,11 +175,49 @@ static int getdents_contains_after_partial(const char *dir_path, return saw_first && saw_second; } -static int sidecar_fallback_active(const char *dir_path) +/* One linkat case over a symlink: create @target_name, point @link_name at it + * (spelled relative or absolute by @absolute_target), and hard-link the + * symlink with @flags. @expect_link says which node the new name must be: the + * link itself without AT_SYMLINK_FOLLOW, the target with it. The hard-link + * names are case-protected, so on a folding sysroot every case exercises the + * escaped-create path through linkat rather than through open. + */ +static void check_linkat(const char *base, + const char *target_name, + const char *link_name, + const char *hard_name, + bool absolute_target, + int flags, + bool expect_link) { - char index_path[512]; - snprintf(index_path, sizeof(index_path), "%s/.elfuse_case_index", dir_path); - return access(index_path, F_OK) == 0; + char target[320]; + char link_path[320]; + char hard_path[320]; + struct stat st; + + snprintf(target, sizeof(target), "%s/%s", base, target_name); + snprintf(link_path, sizeof(link_path), "%s/%s", base, link_name); + snprintf(hard_path, sizeof(hard_path), "%s/%s", base, hard_name); + unlink(hard_path); + unlink(link_path); + unlink(target); + + if (create_file(target, "linkat\n") < 0) { + FAIL("failed to create link target"); + } else if (symlink(absolute_target ? target : target_name, link_path) < 0) { + FAIL("failed to create symlink"); + } else if (lstat(link_path, &st) < 0 || !S_ISLNK(st.st_mode)) { + FAIL("lstat did not report the symlink"); + } else if (linkat(AT_FDCWD, link_path, AT_FDCWD, hard_path, flags) < 0) { + FAIL("linkat failed"); + } else if (lstat(hard_path, &st) < 0) { + FAIL("lstat on the new hard link failed"); + } else if (expect_link ? !S_ISLNK(st.st_mode) : !S_ISREG(st.st_mode)) { + FAIL(expect_link ? "followed the symlink when asked not to" + : "linked the symlink itself instead of its target"); + } else { + PASS(); + } } int main(void) @@ -349,7 +404,7 @@ int main(void) } } - TEST("plain rename updates sidecar mapping for colliding source"); + TEST("plain rename moves a colliding source to its new spelling"); { char old_path[320]; char new_path[320]; @@ -373,7 +428,7 @@ int main(void) FAIL("rename disturbed untouched colliding entry"); } else if (dir_has_entry(base, "foo") != 0 || dir_has_entry(base, "bar") != 1) { - FAIL("directory listing did not reflect sidecar rename"); + FAIL("directory listing did not reflect the rename"); } else { PASS(); } @@ -400,58 +455,70 @@ int main(void) } } - TEST("fallback linkat preserves AT_SYMLINK_FOLLOW semantics"); + /* AT_SYMLINK_FOLLOW hard-links what the symlink points at, so the result + * is a regular file; without it linkat(2) links the symlink itself, and + * that holds for an absolute target too: nothing has to resolve the + * target to copy the link. + * + * The followed target is relative on purpose. An absolute target is + * stored verbatim, because the guest has to read back the bytes it wrote, + * so when the host kernel follows the link it resolves those bytes + * against the host root rather than the sysroot and the target is not + * there. Following a relative target stays inside the translated parent + * and works. Using an absolute one here would test that gap instead of + * linkat. + */ + TEST("linkat AT_SYMLINK_FOLLOW links the target, not the symlink"); + check_linkat(base, "real-target", "real-link", "REAL-HARD", false, + AT_SYMLINK_FOLLOW, false); + + TEST("linkat without AT_SYMLINK_FOLLOW links the symlink itself"); + check_linkat(base, "abs-target", "abs-link", "ABS-HARD", true, 0, true); + + /* One probe suffices for flag validation: the flag set is checked before + * the paths are resolved, so which fixture it runs against does not enter + * into it. + */ + TEST("unsupported linkat flags are rejected"); { char target[320]; - char link_path[320]; char hard_path[320]; - struct stat st; snprintf(target, sizeof(target), "%s/real-target", base); - snprintf(link_path, sizeof(link_path), "%s/real-link", base); - snprintf(hard_path, sizeof(hard_path), "%s/REAL-HARD", base); - unlink(hard_path); - unlink(link_path); - unlink(target); - - if (create_file(target, "follow\n") < 0) { - FAIL("failed to create link target"); - } else if (symlink(target, link_path) < 0) { - FAIL("failed to create symlink"); - } else if (!sidecar_fallback_active(base)) { - PASS(); - } else if (linkat(AT_FDCWD, link_path, AT_FDCWD, hard_path, - AT_SYMLINK_FOLLOW) < 0) { - FAIL("linkat with AT_SYMLINK_FOLLOW failed"); - } else if (lstat(hard_path, &st) < 0) { - FAIL("lstat on hardlink target failed"); - } else if (!S_ISREG(st.st_mode)) { - FAIL("sidecar fallback linked the symlink instead of its target"); - } else if (linkat(AT_FDCWD, target, AT_FDCWD, hard_path, 0x40000000) != - -1 || - errno != EINVAL) { - FAIL("sidecar fallback accepted unsupported linkat flags"); - } else { - PASS(); - } + snprintf(hard_path, sizeof(hard_path), "%s/FLAG-HARD", base); + EXPECT_TRUE( + linkat(AT_FDCWD, target, AT_FDCWD, hard_path, 0x40000000) == -1 && + errno == EINVAL, + "unsupported linkat flags were accepted"); } - TEST("fallback rejects reserved sidecar basename for create paths"); + /* Deriving the on-disk spelling from the guest name alone means the sysroot + * keeps no bookkeeping file of its own, so no basename is reserved and the + * guest may create any name Linux allows. An earlier scheme did reserve + * one, and refusing a name the guest is entitled to create is the failure + * this pins. + */ + TEST("no basename is reserved for create paths"); { - char poison[320]; - snprintf(poison, sizeof(poison), "%s/.elfuse_case_index", base); - unlink(poison); + char plain[320]; + char buf[64]; + ssize_t n; - if (!sidecar_fallback_active(base)) { - PASS(); - } else if (symlinkat("target", AT_FDCWD, poison) != -1 || - errno != ENOENT) { - FAIL("reserved sidecar basename was creatable"); - } else if (!sidecar_fallback_active(base)) { - FAIL("reserved-name probe disturbed sidecar metadata"); + snprintf(plain, sizeof(plain), "%s/.elfuse_case_index", base); + unlink(plain); + + if (symlinkat("target", AT_FDCWD, plain) < 0) { + FAIL("a name the guest is entitled to create was refused"); + } else if ((n = readlink(plain, buf, sizeof(buf) - 1)) < 0) { + FAIL("the created name does not resolve back"); } else { - PASS(); + buf[n] = '\0'; + if (strcmp(buf, "target")) + FAIL("readlink returned the wrong target"); + else + PASS(); } + unlink(plain); } TEST("255-byte colliding basenames both open"); diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index 8565766f..d043a170 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -353,12 +353,28 @@ QEMU_SKIP=" # write -- a genuine behavioral difference worth reviewing on its own, # not just an environment artifact. -is_qemu_skipped() +# Tests that only run under qemu. A test belongs here when it needs a writable, +# byte-exact root: the elfuse lane runs without a sysroot, and the macOS root is +# neither writable nor byte-exact. +# +# The filename tests need one for a second reason. They assert that names Linux +# keeps apart stay apart, which a case-folding volume is entitled to get wrong, +# so running them without a sysroot would not merely fail to set up, it would +# measure the host's naming rules instead of Linux's. Their elfuse-side coverage +# is the make-check sysroot lanes. +ELFUSE_SKIP=" + test-sysroot-name-unique + test-sysroot-name-relative +" + +# Whitespace-separated membership test, shared by the per-runner skip lists so a +# third runner does not need a third copy. +list_has() { - local label="$1" - local skipped - for skipped in $QEMU_SKIP; do - [ "$skipped" = "$label" ] && return 0 + local needle="$1" + local item + for item in $2; do + [ "$item" = "$needle" ] && return 0 done return 1 } @@ -370,11 +386,16 @@ is_qemu_skipped() maybe_qemu_skip() { local runner="$1" label="$2" - if [ "$runner" = "run_qemu" ] && is_qemu_skipped "$label"; then + if [ "$runner" = "run_qemu" ] && list_has "$label" "$QEMU_SKIP"; then test_report skip "$label" " (qemu)" skip=$((skip + 1)) return 0 fi + if [ "$runner" = "run_elfuse" ] && list_has "$label" "$ELFUSE_SKIP"; then + test_report skip "$label" " (elfuse: needs a sysroot lane)" + skip=$((skip + 1)) + return 0 + fi return 1 } @@ -804,6 +825,19 @@ run_unit_tests() printf "\nX11 raw protocol\n" test_check "$runner" "test-x11" "0 failed" "$bindir/test-x11" + + # Filenames, against the reference kernel. These pin what Linux does with + # names that collide only under case folding or Unicode normalization, and + # with names at the length limit, the expectations the sysroot's on-disk + # encoding exists to satisfy. Against a real kernel they are measurements + # rather than beliefs, because the VM's / and /tmp are tmpfs: byte-exact and + # case-sensitive. Each cleans up after itself, so repeated runs in one boot + # are safe. + printf "\nFilenames\n" + test_check "$runner" "test-sysroot-name-unique" "0 failed" \ + "$bindir/test-sysroot-name-unique" + test_check "$runner" "test-sysroot-name-relative" "0 failed" \ + "$bindir/test-sysroot-name-relative" } run_coreutils_tests() @@ -1233,7 +1267,7 @@ run_suite() # detector does not recognize yet. EXPECTED_BASELINES=( "elfuse-aarch64|238|0" - "qemu-aarch64|218|0" + "qemu-aarch64|220|0" "elfuse-x86_64:apple-m1-m2|71|0" "elfuse-x86_64:apple-m3-plus|71|0" "elfuse-x86_64:apple-unknown|71|0" diff --git a/tests/test-sysroot-case-exact.c b/tests/test-sysroot-case-exact.c index 0160b09b..5a233fc4 100644 --- a/tests/test-sysroot-case-exact.c +++ b/tests/test-sysroot-case-exact.c @@ -7,22 +7,22 @@ * Linux path resolution treats names as byte strings: a lookup whose spelling * differs from the on-disk entry only by case (or Unicode normalization form) * must fail with ENOENT. APFS resolves such lookups case- and - * normalization-insensitively, so on a case-insensitive sysroot the sidecar - * walk has to verify the on-disk spelling of every unmapped component instead - * of trusting the folded openat/fstatat probe. Read-path syscalls (stat, open, - * access) used to leak the folded match through; mutation syscalls already - * went through a byte-exact readdir check. + * normalization-insensitively, so on a case-insensitive sysroot the case-exact + * walk (src/syscall/casefold-walk.c) has to verify the on-disk spelling of + * every fold-stable component instead of trusting the folded openat/fstatat + * probe. Read-path syscalls (stat, open, access) used to leak the folded match + * through; mutation syscalls already went through a byte-exact readdir check. * * The harness (mk/tests.mk) stages inside the sysroot, host-side: * /data/Makefile ("exact\n") * /data/sub/f.txt ("sub\n") * /data/caf\xc3\xa9 NFC spelling ("nfc\n") - * and passes argv[1] = "ci" when the sysroot volume is case-insensitive - * (sidecar active) or "cs" when it is case-sensitive. The wrong-case probes - * hold either way; the normalization probes only hold with the sidecar's - * byte-exact verification, so they are skipped under "cs" (APFS folds - * normalization even on case-sensitive volumes -- a documented limitation of - * running without the sidecar). + * and passes argv[1] = "ci" when the sysroot volume is case-insensitive (the + * walk active) or "cs" when it is case-sensitive. The wrong-case probes hold + * either way; the normalization probes only hold with the walk's byte-exact + * verification, so they are skipped under "cs" (APFS folds normalization even + * on case-sensitive volumes, a documented limitation of running without the + * walk). */ #include diff --git a/tests/test-sysroot-host-fallback.c b/tests/test-sysroot-host-fallback.c index b2058c8b..fc0d42c8 100644 --- a/tests/test-sysroot-host-fallback.c +++ b/tests/test-sysroot-host-fallback.c @@ -7,13 +7,13 @@ * proc_resolve_sysroot_path_flags resolves absolute guest paths inside the * sysroot when they exist there and otherwise falls back to the literal host * path so guests can reach host resources (mktemp dirs, /etc/resolv.conf). On a - * case-insensitive sysroot the sidecar walk used to veto that fallback: it + * case-insensitive sysroot an earlier walk used to veto that fallback: it * anchored every absolute path at the sysroot root and returned ENOENT as soon * as a component was missing there, which broke every coreutils invocation * against a host mktemp directory (test-matrix "musl dyn" suite). * * The harness (mk/tests.mk) runs this binary under --sysroot with a - * case-insensitive sysroot so the sidecar is active, and passes: + * case-insensitive sysroot so the case-exact walk is active, and passes: * argv[1] host directory whose intermediate components do not exist in * the sysroot; contains hello.txt ("host-visible\n") * argv[2] host file whose parent chain is fully mirrored inside the diff --git a/tests/test-sysroot-name-relative.c b/tests/test-sysroot-name-relative.c new file mode 100644 index 00000000..dd2304af --- /dev/null +++ b/tests/test-sysroot-name-relative.c @@ -0,0 +1,482 @@ +/* + * Relative and dirfd-relative names in a sysroot + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A guest names the same file two ways: absolutely, and relative to its working + * directory or to a directory descriptor. Both must reach the same file. That + * is not automatic here, because a name whose spelling the volume cannot hold + * is stored escaped, so the translation has to run whichever way the guest + * spelled it, and for a relative name there is no leading component to key + * on, only the descriptor it is resolved against. + * + * This matters well beyond a shell doing cd: fts, find, git and rsync walk + * trees with openat(dirfd, name) throughout, and never build an absolute path + * at all. + * + * Code under test: src/syscall/casefold-walk.c reached from + * src/syscall/path.c's path_translate_at, for the case where the guest path + * does not begin with '/'. A regression shows up as the same guest name + * resolving to two different files depending on how it was spelled, so a + * create through one spelling is invisible through the other, and an O_EXCL + * create of a name that already exists succeeds instead of reporting EEXIST. + * + * Run under --sysroot. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DIR_V "/name-relative" + +/* Every fixture name needs escaping, so a spelling that skips the translation + * lands somewhere different and the mismatch is visible. + */ +#define NAME_A "Alpha.One" +#define NAME_B "Beta.Two" +#define NAME_C "Gamma.Three" + +static int write_at(int dirfd, const char *name, const char *text) +{ + int fd = openat(dirfd, name, O_CREAT | O_WRONLY | O_TRUNC, 0644); + ssize_t n; + + if (fd < 0) + return -1; + n = write(fd, text, strlen(text)); + close(fd); + return n == (ssize_t) strlen(text) ? 0 : -1; +} + +static int content_at(int dirfd, const char *name, const char *want) +{ + char buf[64]; + int fd = openat(dirfd, name, O_RDONLY); + ssize_t n; + + if (fd < 0) + return -1; + n = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (n < 0) + return -1; + buf[n] = '\0'; + return !strcmp(buf, want) ? 0 : -1; +} + +/* Outside the sysroot the guest is looking at the real host filesystem, where + * elfuse owns nothing and must store names exactly as given. An absolute path + * that falls through already does; a relative one has to agree, or the same + * file has two spellings depending on how it was named and elfuse leaves + * escaped names in directories that are not its own. + * + * argv[1] is a directory outside the sysroot, staged by the recipe, which also + * checks the on-disk half afterwards. + */ +static void section_outside_sysroot(const char *host_dir) +{ + char abs[PATH_MAX]; + + /* Visible, so a manual run without the recipe's host fixture reads as + * fewer tests run, not as the section passing. + */ + if (!host_dir || !host_dir[0]) { + printf(" (outside-sysroot section skipped: no host dir given)\n"); + return; + } + + TEST("chdir to a directory outside the sysroot"); + EXPECT_TRUE(chdir(host_dir) == 0, "chdir"); + + TEST("create a mixed-case name there through a relative path"); + EXPECT_TRUE(write_at(AT_FDCWD, "Outside.Rel", "rel") == 0, "create"); + + TEST("create a mixed-case name there through an absolute path"); + snprintf(abs, sizeof(abs), "%s/Outside.Abs", host_dir); + EXPECT_TRUE(write_at(AT_FDCWD, abs, "abs") == 0, "create"); + + /* Both spellings must reach both files: nothing was translated, so a + * relative and an absolute name of the same file are the same name. + */ + TEST("the relative-created name opens absolutely"); + snprintf(abs, sizeof(abs), "%s/Outside.Rel", host_dir); + EXPECT_TRUE(content_at(AT_FDCWD, abs, "rel") == 0, "content"); + + TEST("the absolute-created name opens relatively"); + EXPECT_TRUE(content_at(AT_FDCWD, "Outside.Abs", "abs") == 0, "content"); + + TEST("chdir back to the sysroot root"); + EXPECT_TRUE(chdir("/") == 0, "chdir"); +} + +/* openat2(RESOLVE_NO_SYMLINKS) is answered by a second walker, which refuses + * the open if any component is a symlink. That walker resolves the whole path + * itself, so it has to spell each component the way the volume stores it, + * exactly as the translation above does. Two walkers that disagree give a guest + * two different answers for one path: here the file resolves through openat but + * not through openat2, which reports ENOENT for a file that is plainly there. + * + * Every fixture name needs escaping, so a walker still using the guest spelling + * finds nothing and the disagreement is visible rather than incidental. + */ +static void section_openat2_no_symlinks(int dirfd) +{ + struct open_how how = { + .flags = O_RDONLY, .mode = 0, .resolve = RESOLVE_NO_SYMLINKS}; + long fd; + + TEST("openat2 RESOLVE_NO_SYMLINKS opens through an escaped component"); + errno = 0; + fd = syscall(SYS_openat2, dirfd, "Walk.Dir/Leaf.File", &how, sizeof(how)); + if (fd >= 0) { + close((int) fd); + PASS(); + } else { + FAIL("a walker other than the translation missed the escaped name"); + } + + /* The refusal itself must still work, or the fix would have bought the open + * by disabling the check it exists for. + */ + + /* RESOLVE_NO_XDEV is answered by a third walker, which probes each + * component to see whether a symlink moves the path onto another mount. + * It probes by name too, so it has the same requirement. Only detection + * matters here, not where the link points, so the target need not resolve. + */ + TEST("openat2 RESOLVE_NO_XDEV sees a symlink under an escaped name"); + { + struct open_how xdev = { + .flags = O_RDONLY, .mode = 0, .resolve = RESOLVE_NO_XDEV}; + long x; + + errno = 0; + x = syscall(SYS_openat2, dirfd, "Cross.Link/self", &xdev, sizeof(xdev)); + if (x >= 0) { + close((int) x); + FAIL("a mount crossing through an escaped symlink went unnoticed"); + } else { + EXPECT_ERRNO((int) x, EXDEV, "should report a mount crossing"); + } + } + + TEST("openat2 RESOLVE_NO_SYMLINKS still refuses a symlink component"); + errno = 0; + fd = syscall(SYS_openat2, dirfd, "Walk.Link/leaf", &how, sizeof(how)); + if (fd >= 0) { + close((int) fd); + FAIL("a symlink component was traversed"); + } else { + EXPECT_ERRNO((int) fd, ELOOP, "should refuse the symlink"); + } + + /* The walker sees host spellings, and an escape is longer than the name it + * stands for: past the guest limit once the name passes 125 bytes. A + * walker sized to the guest limit refuses those components with + * ENAMETOOLONG for a file openat opens without complaint, which is the + * two-answers disagreement again, in a length rather than a spelling. + */ + TEST("openat2 RESOLVE_NO_SYMLINKS opens a 126-byte escaped name"); + { + char longname[256]; + + memset(longname, 'Q', 126); + longname[126] = '\0'; + if (write_at(dirfd, longname, "long") != 0) { + FAIL("create"); + } else { + errno = 0; + fd = syscall(SYS_openat2, dirfd, longname, &how, sizeof(how)); + if (fd >= 0) { + close((int) fd); + PASS(); + } else { + FAIL("a walker refused a name Linux allows"); + } + } + + TEST("openat2 RESOLVE_NO_SYMLINKS opens a 255-byte escaped name"); + memset(longname, 'Q', 255); + longname[255] = '\0'; + if (write_at(dirfd, longname, "long") != 0) { + FAIL("create"); + } else { + errno = 0; + fd = syscall(SYS_openat2, dirfd, longname, &how, sizeof(how)); + if (fd >= 0) { + close((int) fd); + PASS(); + } else { + FAIL("a walker refused a name Linux allows"); + } + } + } +} + +/* A trailing separator asserts the target is a directory, so "file/" owes + * ENOTDIR (POSIX 4.13, path_resolution(7)). The component walk skips + * separators, so by the time a host path is built the assertion is gone unless + * something puts it back, and it goes missing for an escaped name and a + * fold-stable one alike, so both are checked. A regression reads as open("f/") + * succeeding on a regular file, which no Linux program expects and which turns + * a caller's directory check into a silent success. + */ +static void section_trailing_slash(void) +{ + char path[PATH_MAX]; + int fd; + + TEST("stage a file and a directory whose names need escaping"); + EXPECT_TRUE(write_at(AT_FDCWD, DIR_V "/Slash.File", "f") == 0 && + (mkdir(DIR_V "/Slash.Dir", 0755) == 0 || errno == EEXIST) && + write_at(AT_FDCWD, DIR_V "/slashfile", "g") == 0, + "stage"); + + TEST("open of an escaped file with a trailing slash is ENOTDIR"); + snprintf(path, sizeof(path), "%s/Slash.File/", DIR_V); + fd = open(path, O_RDONLY); + if (fd >= 0) { + close(fd); + FAIL("a regular file opened as a directory"); + } else { + EXPECT_ERRNO(fd, ENOTDIR, "should be ENOTDIR"); + } + + TEST("stat of an escaped file with a trailing slash is ENOTDIR"); + { + struct stat st; + snprintf(path, sizeof(path), "%s/Slash.File/", DIR_V); + EXPECT_ERRNO(stat(path, &st), ENOTDIR, "should be ENOTDIR"); + } + + /* The same for a name stored literally, so the fix is not one that only + * works where an escape happens to be built. + */ + TEST("open of a fold-stable file with a trailing slash is ENOTDIR"); + snprintf(path, sizeof(path), "%s/slashfile/", DIR_V); + fd = open(path, O_RDONLY); + if (fd >= 0) { + close(fd); + FAIL("a regular file opened as a directory"); + } else { + EXPECT_ERRNO(fd, ENOTDIR, "should be ENOTDIR"); + } + + /* And the assertion must not reject what it is supposed to allow. */ + TEST("a directory with a trailing slash still resolves"); + { + struct stat st; + snprintf(path, sizeof(path), "%s/Slash.Dir/", DIR_V); + EXPECT_TRUE(stat(path, &st) == 0 && S_ISDIR(st.st_mode), "should open"); + } + + TEST("the root with a trailing slash still resolves"); + { + struct stat st; + EXPECT_TRUE(stat("/", &st) == 0 && S_ISDIR(st.st_mode), "root"); + } +} + +/* Resolution stops at the first component that is not a directory, and every + * operation naming something below it owes ENOTDIR (path_resolution(7)). The + * trailing-slash section above pins the same rule where the non-directory is + * the final component; here it is an ancestor, which is the form that decides + * whether the sysroot has answered at all. Reporting ENOENT instead sends the + * lookup on to the host, where an unrelated file sharing the literal path + * answers in the sysroot's place, so the wrong errno and a wrong file are the + * same regression. A fold-stable name and an escaped one are both checked, + * because the two take different spellings on disk and the rule has to survive + * either. + */ +static void section_below_non_directory(void) +{ + char path[PATH_MAX]; + struct stat st; + + TEST("stage regular files to resolve below"); + EXPECT_TRUE(write_at(AT_FDCWD, DIR_V "/notdirfile", "f") == 0 && + write_at(AT_FDCWD, DIR_V "/Not.Dir.File", "g") == 0, + "stage"); + + TEST("stat below a fold-stable regular file is ENOTDIR"); + snprintf(path, sizeof(path), "%s/notdirfile/below", DIR_V); + EXPECT_ERRNO(stat(path, &st), ENOTDIR, "should be ENOTDIR"); + + TEST("lstat below a fold-stable regular file is ENOTDIR"); + EXPECT_ERRNO(lstat(path, &st), ENOTDIR, "should be ENOTDIR"); + + TEST("open below a fold-stable regular file is ENOTDIR"); + EXPECT_ERRNO(open(path, O_RDONLY), ENOTDIR, "should be ENOTDIR"); + + TEST("stat below an escaped regular file is ENOTDIR"); + snprintf(path, sizeof(path), "%s/Not.Dir.File/below", DIR_V); + EXPECT_ERRNO(stat(path, &st), ENOTDIR, "should be ENOTDIR"); + + TEST("open below an escaped regular file is ENOTDIR"); + EXPECT_ERRNO(open(path, O_RDONLY), ENOTDIR, "should be ENOTDIR"); + + /* Two components below, so the rule cannot depend on the leaf's parent + * being the offending entry. + */ + TEST("stat two components below a regular file is ENOTDIR"); + snprintf(path, sizeof(path), "%s/notdirfile/a/b", DIR_V); + EXPECT_ERRNO(stat(path, &st), ENOTDIR, "should be ENOTDIR"); + + /* The rule must not swallow a plain absent path, which still owes ENOENT. + */ + TEST("an absent path below a real directory is still ENOENT"); + snprintf(path, sizeof(path), "%s/absent-dir/below", DIR_V); + EXPECT_ERRNO(stat(path, &st), ENOENT, "should be ENOENT"); +} + +int main(int argc, char **argv) +{ + char abs[PATH_MAX]; + int dirfd; + + TEST("fixture mkdir"); + EXPECT_TRUE(mkdir(DIR_V, 0755) == 0 || errno == EEXIST, "mkdir"); + + /* Created absolutely, reopened relatively. */ + snprintf(abs, sizeof(abs), "%s/%s", DIR_V, NAME_A); + TEST("create through an absolute path"); + EXPECT_TRUE(write_at(AT_FDCWD, abs, "abs") == 0, "create"); + + TEST("chdir into the directory"); + EXPECT_TRUE(chdir(DIR_V) == 0, "chdir"); + + TEST("the same file opens through a cwd-relative name"); + EXPECT_TRUE(content_at(AT_FDCWD, NAME_A, "abs") == 0, + "relative spelling reached a different file"); + + /* An O_EXCL create of a name that already exists must fail, whichever way + * it is spelled. If the relative spelling skips the translation it lands on + * a free slot and succeeds, leaving two entries for one guest name. + */ + TEST("O_EXCL through a relative name reports EEXIST"); + EXPECT_ERRNO(openat(AT_FDCWD, NAME_A, O_CREAT | O_EXCL | O_WRONLY, 0644), + EEXIST, "should already exist"); + + /* Created relatively, reopened absolutely. */ + TEST("create through a cwd-relative name"); + EXPECT_TRUE(write_at(AT_FDCWD, NAME_B, "rel") == 0, "create"); + + snprintf(abs, sizeof(abs), "%s/%s", DIR_V, NAME_B); + TEST("the same file opens through an absolute path"); + EXPECT_TRUE(content_at(AT_FDCWD, abs, "rel") == 0, + "absolute spelling reached a different file"); + + /* The directory holds one entry per guest name and no more: a spelling that + * skipped translation would show up here as a second entry. + */ + TEST("two names, two entries"); + EXPECT_EQ(dir_entry_count("."), 2, "entry count"); + + /* The same through a real directory descriptor, which is how a tree walker + * reaches every name it touches. + */ + TEST("open a dirfd on the sysroot directory"); + EXPECT_TRUE( + chdir("/") == 0 && (dirfd = open(DIR_V, O_RDONLY | O_DIRECTORY)) >= 0, + "open dirfd"); + + TEST("create through a dirfd"); + EXPECT_TRUE(write_at(dirfd, NAME_C, "dfd") == 0, "create"); + + snprintf(abs, sizeof(abs), "%s/%s", DIR_V, NAME_C); + TEST("the dirfd-created file opens absolutely"); + EXPECT_TRUE(content_at(AT_FDCWD, abs, "dfd") == 0, + "dirfd spelling reached a different file"); + + TEST("the absolute-created file opens through the dirfd"); + EXPECT_TRUE(content_at(dirfd, NAME_A, "abs") == 0, + "dirfd lookup reached a different file"); + + TEST("three names, three entries"); + EXPECT_EQ(dir_entry_count(DIR_V), 3, "entry count"); + + /* Metadata and mutation must agree with the lookups above. */ + { + struct stat st; + TEST("fstatat through the dirfd"); + EXPECT_TRUE(fstatat(dirfd, NAME_A, &st, 0) == 0, "fstatat"); + } + + TEST("renameat through the dirfd"); + EXPECT_TRUE(renameat(dirfd, NAME_C, dirfd, "Delta.Four") == 0, "renameat"); + TEST("the renamed file opens absolutely under its new name"); + snprintf(abs, sizeof(abs), "%s/Delta.Four", DIR_V); + EXPECT_TRUE(content_at(AT_FDCWD, abs, "dfd") == 0, "renamed content"); + + TEST("mkdirat through the dirfd"); + EXPECT_TRUE(mkdirat(dirfd, "Sub.Dir", 0755) == 0, "mkdirat"); + TEST("the new directory is visible absolutely"); + { + struct stat st; + snprintf(abs, sizeof(abs), "%s/Sub.Dir", DIR_V); + EXPECT_TRUE(stat(abs, &st) == 0 && S_ISDIR(st.st_mode), "stat dir"); + } + + TEST("unlinkat through the dirfd removes the file the absolute name saw"); + EXPECT_TRUE(unlinkat(dirfd, "Delta.Four", 0) == 0, "unlinkat"); + snprintf(abs, sizeof(abs), "%s/Delta.Four", DIR_V); + TEST("and it is gone absolutely"); + EXPECT_ERRNO(open(abs, O_RDONLY), ENOENT, "should be gone"); + + /* Fixtures for the second walker: a directory whose name needs escaping + * holding a file whose name does too, and a symlink (itself needing + * escaping) to a second directory. + * + * That second directory and its file are deliberately all-lowercase, so + * they are stored under their own spelling. A symlink records the target + * the guest gave it, and nothing rewrites those bytes, so a link pointing + * at a name that is stored escaped cannot be followed by the host at all. + * Pointing it at a fold-stable name keeps this case about the walker's + * spelling of the link, which is what is under test, instead of about the + * link's own target. + */ + TEST("stage fixtures for the openat2 walker"); + { + int sub = -1, plain = -1; + EXPECT_TRUE( + mkdirat(dirfd, "Walk.Dir", 0755) == 0 && + (sub = openat(dirfd, "Walk.Dir", O_RDONLY | O_DIRECTORY)) >= + 0 && + write_at(sub, "Leaf.File", "leaf") == 0 && + mkdirat(dirfd, "walkdir", 0755) == 0 && + (plain = openat(dirfd, "walkdir", O_RDONLY | O_DIRECTORY)) >= + 0 && + write_at(plain, "leaf", "plain") == 0 && + symlinkat("walkdir", dirfd, "Walk.Link") == 0 && + symlinkat("/proc", dirfd, "Cross.Link") == 0, + "stage"); + if (sub >= 0) + close(sub); + if (plain >= 0) + close(plain); + } + + section_openat2_no_symlinks(dirfd); + section_trailing_slash(); + section_below_non_directory(); + + close(dirfd); + + section_outside_sysroot(argc > 1 ? argv[1] : NULL); + + SUMMARY("test-sysroot-name-relative"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-name-unique.c b/tests/test-sysroot-name-unique.c new file mode 100644 index 00000000..05f99ad9 --- /dev/null +++ b/tests/test-sysroot-name-unique.c @@ -0,0 +1,212 @@ +/* + * One representation per guest name + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * On a case-folding sysroot a name whose spelling the volume cannot hold is + * stored escaped, so a directory can end up holding a mixture of literal and + * escaped entries. What must hold throughout is that each guest name is + * reachable through exactly one of them: every spelling opens its own file, + * a spelling that was never created reports ENOENT, and a listing reports each + * name once and never leaks an on-disk spelling. + * + * The sequence below is adversarial on purpose. It creates the members of a + * case-colliding set in an order that makes each one take a different kind of + * slot, then deletes and recreates them so the literal slot changes hands + * while the others stay put. No assertion here may depend on *which* spelling + * won the literal slot: that follows arrival order and is not part of the + * contract. + * + * Code under test: the resolver in src/syscall/casefold-walk.c reached through + * src/syscall/path.c, and the mutating handlers in src/syscall/fs.c that use + * its result. A regression shows up as one spelling opening another's file, a + * name surviving its own unlink, or a listing that disagrees with what can be + * opened. + * + * Run under --sysroot. The host-side shape check in the make recipe asserts + * the on-disk half. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DIR_R "/name-unique" + +/* Sorted, comma-joined listing of DIR_R, so an expectation reads literally and + * a duplicate or a leaked on-disk spelling shows up in the diff. + */ +static const char *listing(void) +{ + static char out[512]; + char names[32][NAME_MAX + 1]; + int n = 0; + DIR *d = opendir(DIR_R); + struct dirent *de; + size_t len = 0; + + out[0] = '\0'; + if (!d) + return ""; + while ((de = readdir(d)) && n < 32) { + if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) + continue; + snprintf(names[n++], NAME_MAX + 1, "%s", de->d_name); + } + closedir(d); + + for (int i = 1; i < n; i++) { + char key[NAME_MAX + 1]; + int j = i - 1; + snprintf(key, sizeof(key), "%s", names[i]); + while (j >= 0 && strcmp(names[j], key) > 0) { + snprintf(names[j + 1], NAME_MAX + 1, "%s", names[j]); + j--; + } + snprintf(names[j + 1], NAME_MAX + 1, "%s", key); + } + for (int i = 0; i < n; i++) + len += (size_t) snprintf(out + len, sizeof(out) - len, "%s%s", + i ? "," : "", names[i]); + return out; +} + +static void expect_listing(const char *label, const char *want) +{ + const char *got = listing(); + + TEST(label); + if (strcmp(got, want)) { + printf("\n got %s\n expected %s\n ", got, want); + FAIL("listing mismatch"); + return; + } + PASS(); +} + +static void expect_absent(const char *label, const char *path) +{ + TEST(label); + EXPECT_ERRNO(open(path, O_RDONLY), ENOENT, "should not resolve"); +} + +static void expect_content(const char *label, + const char *path, + const char *want) +{ + TEST(label); + EXPECT_TRUE(file_content_is(path, want) == 0, "wrong content"); +} + +int main(void) +{ + TEST("fixture mkdir"); + EXPECT_TRUE(mkdir(DIR_R, 0755) == 0 || errno == EEXIST, "mkdir"); + + /* The first member takes the literal slot on a folding volume, or simply + * its own name on a byte-exact one. Either way only that spelling exists. + */ + TEST("create Foo"); + EXPECT_TRUE(file_write(DIR_R "/Foo", "A") == 0, "create Foo"); + expect_absent("foo absent before it is made", DIR_R "/foo"); + expect_absent("FOO absent before it is made", DIR_R "/FOO"); + expect_listing("listing holds Foo alone", "Foo"); + + /* The second member cannot take the same slot, so it is stored escaped, + * and the guest must not be able to tell. + */ + TEST("create foo beside Foo"); + EXPECT_TRUE(file_write(DIR_R "/foo", "B") == 0, "create foo"); + expect_content("Foo keeps its own content", DIR_R "/Foo", "A"); + expect_content("foo has its own content", DIR_R "/foo", "B"); + expect_absent("FOO still absent", DIR_R "/FOO"); + expect_listing("listing holds both", "Foo,foo"); + + /* A third spelling nobody created must not resolve to either of them. */ + TEST("create FOO exclusively"); + EXPECT_TRUE(open(DIR_R "/FOO", O_CREAT | O_EXCL | O_WRONLY, 0644) >= 0, + "O_EXCL create of a third spelling"); + EXPECT_TRUE(file_write(DIR_R "/FOO", "C") == 0, "write FOO"); + expect_content("Foo unaffected", DIR_R "/Foo", "A"); + expect_content("foo unaffected", DIR_R "/foo", "B"); + expect_listing("listing holds all three", "FOO,Foo,foo"); + + TEST("O_EXCL on an existing spelling"); + EXPECT_ERRNO(open(DIR_R "/foo", O_CREAT | O_EXCL | O_WRONLY, 0644), EEXIST, + "should already exist"); + + /* Removing whichever member holds the literal slot must leave the others + * reachable under their own names. + */ + TEST("unlink Foo"); + EXPECT_TRUE(unlink(DIR_R "/Foo") == 0, "unlink Foo"); + expect_absent("Foo gone", DIR_R "/Foo"); + expect_content("foo survives", DIR_R "/foo", "B"); + expect_content("FOO survives", DIR_R "/FOO", "C"); + expect_listing("listing holds the survivors", "FOO,foo"); + + /* Recreating it must not disturb the survivors, whichever slot it lands in + * the second time round. + */ + TEST("recreate Foo"); + EXPECT_TRUE(file_write(DIR_R "/Foo", "D") == 0, "recreate Foo"); + expect_content("Foo has its new content", DIR_R "/Foo", "D"); + expect_content("foo still its own", DIR_R "/foo", "B"); + expect_content("FOO still its own", DIR_R "/FOO", "C"); + expect_listing("listing holds all three again", "FOO,Foo,foo"); + + /* Rename across the collision set: the source name goes, the destination + * takes the source's content, and the third member is untouched. + */ + TEST("rename Foo onto foo"); + EXPECT_TRUE(rename(DIR_R "/Foo", DIR_R "/foo") == 0, "rename"); + expect_absent("Foo gone after rename", DIR_R "/Foo"); + expect_content("foo took the content", DIR_R "/foo", "D"); + expect_content("FOO untouched by the rename", DIR_R "/FOO", "C"); + expect_listing("listing after rename", "FOO,foo"); + + /* A hard link is a second name for one file, and both must resolve to it + * even when one is stored literally and the other escaped. + */ + TEST("hard link fOO to foo"); + EXPECT_TRUE(link(DIR_R "/foo", DIR_R "/fOO") == 0, "link"); + expect_content("the link reads the same file", DIR_R "/fOO", "D"); + { + struct stat a, b; + TEST("link shares an inode"); + EXPECT_TRUE(stat(DIR_R "/foo", &a) == 0 && + stat(DIR_R "/fOO", &b) == 0 && a.st_ino == b.st_ino && + a.st_nlink == 2, + "link should share the inode"); + } + expect_listing("listing after link", "FOO,fOO,foo"); + + /* Taking the set apart one at a time: each removal leaves the rest + * readable under their own names. + */ + TEST("unlink fOO"); + EXPECT_TRUE(unlink(DIR_R "/fOO") == 0, "unlink fOO"); + expect_content("foo survives the link removal", DIR_R "/foo", "D"); + TEST("unlink FOO"); + EXPECT_TRUE(unlink(DIR_R "/FOO") == 0, "unlink FOO"); + expect_content("foo is the last one standing", DIR_R "/foo", "D"); + expect_listing("listing at the end", "foo"); + TEST("unlink foo"); + EXPECT_TRUE(unlink(DIR_R "/foo") == 0, "unlink foo"); + expect_listing("listing is empty", ""); + + SUMMARY("test-sysroot-name-unique"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-util.h b/tests/test-util.h index 5a1a1a71..f37633e8 100644 --- a/tests/test-util.h +++ b/tests/test-util.h @@ -8,14 +8,34 @@ #pragma once +#include #include #include +#include #include #include +#include +#include #include #include "raw-syscall.h" +/* openat2(2) scaffolding for tests that drive the second walker directly. + * Static libcs predate the syscall's wrapper and uapi header, so the number, + * the struct, and the resolve bits have to be spelled out by hand; they live + * here once so every test that needs them agrees on the ABI. + */ +#ifndef SYS_openat2 +#define SYS_openat2 437 +#endif + +struct open_how { + unsigned long long flags, mode, resolve; +}; + +#define RESOLVE_NO_XDEV 0x01 +#define RESOLVE_NO_SYMLINKS 0x04 + static inline ssize_t read_fd_all_nul(int fd, char *buf, size_t bufsz) { if (bufsz == 0) @@ -86,6 +106,84 @@ static inline int write_fd_all(int fd, const void *buf, size_t len) return 0; } +/* Directory- and file-level helpers for tests that assert on names. + * + * These take a full path rather than a name plus a directory, so a test that + * works in one place is not rewritten to work in another. Shared rather than + * per-test because the filename suite turns on what these compare: a private + * copy that checked a prefix where its siblings check the whole contents would + * make two lanes disagree about what "the file reads back correctly" means. + */ +static inline int file_write(const char *path, const char *text) +{ + size_t len = strlen(text); + int fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0644); + int rc; + + if (fd < 0) + return -1; + rc = write_fd_all(fd, text, len); + close(fd); + return rc; +} + +/* 0 when @path holds exactly @want, -1 otherwise or on any error. */ +static inline int file_content_is(const char *path, const char *want) +{ + char buf[256]; + + if (read_file_nul(path, buf, sizeof(buf)) < 0) + return -1; + return strcmp(buf, want) ? -1 : 0; +} + +/* 0 when @path begins with @want. For fixtures staged by a shell recipe, which + * appends a newline that the assertion is not about. + */ +static inline int file_content_starts_with(const char *path, const char *want) +{ + char buf[256]; + + if (read_file_nul(path, buf, sizeof(buf)) < 0) + return -1; + return strncmp(buf, want, strlen(want)) ? -1 : 0; +} + +/* Entries in @dir excluding "." and "..", or -1 if it cannot be opened. */ +static inline int dir_entry_count(const char *dir) +{ + DIR *d = opendir(dir); + struct dirent *de; + int n = 0; + + if (!d) + return -1; + while ((de = readdir(d))) { + if (strcmp(de->d_name, ".") && strcmp(de->d_name, "..")) + n++; + } + closedir(d); + return n; +} + +static inline bool dir_contains(const char *dir, const char *name) +{ + DIR *d = opendir(dir); + struct dirent *de; + bool found = false; + + if (!d) + return false; + while ((de = readdir(d))) { + if (!strcmp(de->d_name, name)) { + found = true; + break; + } + } + closedir(d); + return found; +} + static inline void test_unreachable(void) { abort(); From b253dc172945aa65f600e70c1a1ecfe450f9353c Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 29 Jul 2026 23:28:47 +0200 Subject: [PATCH 04/11] Cover the sysroot naming contracts 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-outside-names pins the other half of that contract, that with a sysroot configured a host directory reached through the fallback still lists, opens, and creates by its own bytes, with an in-sysroot control name proving the volume folded during the run; 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. --- Makefile | 5 + docs/filenames.md | 5 + mk/tests.mk | 221 +++++++++++++++++ tests/test-matrix.sh | 16 +- tests/test-nosysroot-literal-names.c | 77 ++++++ tests/test-sysroot-chdir.c | 60 +++++ tests/test-sysroot-corpus.c | 138 +++++++++++ tests/test-sysroot-name-i18n.c | 341 +++++++++++++++++++++++++++ tests/test-sysroot-name-length.c | 262 ++++++++++++++++++++ tests/test-sysroot-name-race.c | 191 +++++++++++++++ tests/test-sysroot-name-soak.c | 321 +++++++++++++++++++++++++ tests/test-sysroot-name-staged.c | 215 +++++++++++++++++ tests/test-sysroot-outside-names.c | 132 +++++++++++ tests/test-sysroot-pathmax.c | 227 ++++++++++++++++++ tests/test-sysroot-root.c | 81 +++++++ 15 files changed, 2291 insertions(+), 1 deletion(-) create mode 100644 tests/test-nosysroot-literal-names.c create mode 100644 tests/test-sysroot-corpus.c create mode 100644 tests/test-sysroot-name-i18n.c create mode 100644 tests/test-sysroot-name-length.c create mode 100644 tests/test-sysroot-name-race.c create mode 100644 tests/test-sysroot-name-soak.c create mode 100644 tests/test-sysroot-name-staged.c create mode 100644 tests/test-sysroot-outside-names.c create mode 100644 tests/test-sysroot-pathmax.c create mode 100644 tests/test-sysroot-root.c diff --git a/Makefile b/Makefile index cd9cf505..1dad3a0d 100644 --- a/Makefile +++ b/Makefile @@ -238,6 +238,11 @@ $(BUILD_DIR)/test-pthread: tests/test-pthread.c | $(BUILD_DIR) @echo " CROSS $< (with -lpthread)" $(Q)$(CROSS_COMPILE)gcc -D_GNU_SOURCE -static -O2 -o $@ $< -lpthread +# test-sysroot-name-soak churns from worker threads plus forked children +$(BUILD_DIR)/test-sysroot-name-soak: tests/test-sysroot-name-soak.c | $(BUILD_DIR) + @echo " CROSS $< (with -lpthread)" + $(Q)$(CROSS_COMPILE)gcc -D_GNU_SOURCE -static -O2 -o $@ $< -lpthread + # test-process-lifecycle creates a worker to verify that process PIDs and # thread TIDs share one namespace-wide allocator across fork children. $(BUILD_DIR)/test-process-lifecycle: tests/test-process-lifecycle.c src/utils.h | $(BUILD_DIR) diff --git a/docs/filenames.md b/docs/filenames.md index 554c49a8..113ddea1 100644 --- a/docs/filenames.md +++ b/docs/filenames.md @@ -198,6 +198,11 @@ make probe-volume-naming # against a temp directory build/probe-volume-naming /Volumes/cs-image # against any other volume ``` +`make test-sysroot-name-race` exercises the claim in "Why nothing is locked" +directly: several processes sharing one sysroot, each creating a different +member of a case-colliding set. It is a scheduling test and is repeated, so a +pass does not prove there is no race; only a failure proves there is one. + The probe reports what a volume does, including behavior elfuse is immune to. The facts the design actually depends on are asserted separately by `make test-casefold-host`, which fails the build if a future macOS release diff --git a/mk/tests.mk b/mk/tests.mk index eb841085..d64758fd 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -20,6 +20,12 @@ test-linkat-symlink-fallback test-casefold-host \ test-casefold-walk-host test-sysroot-name-unique \ test-sysroot-name-relative \ + test-nosysroot-literal-names test-sysroot-outside-names \ + test-sysroot-root \ + test-sysroot-name-i18n test-sysroot-name-length \ + test-sysroot-name-staged test-sysroot-name-race \ + test-sysroot-pathmax test-sysroot-corpus \ + test-sysroot-name-soak check-soak \ probe-volume-naming perf ## Build and run the assembly hello world test @@ -105,6 +111,12 @@ check-sanitizer: $(ELFUSE_BIN) $(TEST_DEPS) \ @$(MAKE) --no-print-directory test-sysroot-name-unique @printf "\n$(BLUE)━━━ relative and dirfd-relative names ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-name-relative + @printf "\n$(BLUE)━━━ non-ASCII guest filenames ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-i18n + @printf "\n$(BLUE)━━━ guest filenames at full length ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-length + @printf "\n$(BLUE)━━━ host-staged escape-shaped names ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-staged ## Run the unit test suite plus busybox applet validation check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @@ -134,6 +146,18 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-sysroot-name-unique @printf "\n$(BLUE)━━━ relative and dirfd-relative names ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-name-relative + @printf "\n$(BLUE)━━━ non-ASCII guest filenames ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-i18n + @printf "\n$(BLUE)━━━ guest filenames at full length ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-length + @printf "\n$(BLUE)━━━ host-staged escape-shaped names ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-staged + @printf "\n$(BLUE)━━━ concurrent creation of colliding names ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-name-race + @printf "\n$(BLUE)━━━ guest paths at the host path ceiling ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-pathmax + @printf "\n$(BLUE)━━━ frozen on-disk spelling corpus ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-corpus @printf "\n$(BLUE)━━━ shebang parser unit test ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-shebang-host @printf "\n$(BLUE)━━━ proctitle argv-tail regression ━━━$(RESET)\n" @@ -154,6 +178,16 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-sysroot-case-exact @printf "\n$(BLUE)━━━ sysroot relative-dirfd symlink escape validation ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-symlink-escape + @printf "\n$(BLUE)━━━ sysroot mounted at / ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-root + @printf "\n$(BLUE)━━━ literal names without a sysroot ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-nosysroot-literal-names + @printf "\n$(BLUE)━━━ literal names outside the sysroot ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-outside-names + @printf "\n$(BLUE)━━━ guest-visible working directory ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-chdir + @printf "\n$(BLUE)━━━ case collisions on a folding sysroot ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-case-collision-fallback @printf "\n$(BLUE)━━━ Alpine sysroot FUSE validation ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-fuse-alpine @printf "\n$(BLUE)━━━ timeout=0 validation ━━━$(RESET)\n" @@ -413,6 +447,193 @@ test-sysroot-name-relative: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-relativ exit 1; \ fi +## The degenerate sysroot: a one-character host prefix +test-sysroot-root: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-root + $(ELFUSE_BIN) --sysroot / $(BUILD_DIR)/test-sysroot-root + +## Escape-shaped host names must mean themselves when there is no sysroot +test-nosysroot-literal-names: $(ELFUSE_BIN) $(BUILD_DIR)/test-nosysroot-literal-names + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + printf 'literal\n' > "$$tmpdir/.ef=464f4f"; \ + printf 'other\n' > "$$tmpdir/plain"; \ + $(ELFUSE_BIN) $(BUILD_DIR)/test-nosysroot-literal-names "$$tmpdir" + +## Escape-shaped host names must mean themselves outside the sysroot +test-sysroot-outside-names: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-outside-names + @set -e; \ + srdir=$$(mktemp -d); \ + outdir=$$(mktemp -d); \ + trap 'rm -rf "$$srdir" "$$outdir"' EXIT; \ + printf 'literal\n' > "$$outdir/.ef=464f4f"; \ + $(ELFUSE_BIN) --sysroot "$$srdir" \ + $(BUILD_DIR)/test-sysroot-outside-names "$$outdir"; \ + if [ -z "$$(find "$$srdir" -maxdepth 1 -name '.ef=*' -print -quit)" ]; then \ + printf "$(RED)FAIL$(RESET) the control name was not escaped, so the volume did not fold\n"; \ + ls -Ab "$$srdir"; \ + exit 1; \ + fi + +# A Linux filename is a byte string and a guest may use any of them, but the +# volume underneath decides which two byte strings are the same name, and it +# folds in ways no simple rule predicts. Every pair here is one it considers +# equal and the guest must see as two files. +## Non-ASCII, normalization and case-folding guest filenames +test-sysroot-name-i18n: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-i18n + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + printf 'staged\n' > "$$tmpdir/$$(printf '\346\226\207\346\241\243')-host.txt"; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-sysroot-name-i18n; \ + if [ ! -e "$$tmpdir/$$(printf '\346\226\207\346\241\243')-host.txt" ]; then \ + printf "$(RED)FAIL$(RESET) a host-staged non-ASCII name was disturbed\n"; \ + exit 1; \ + fi + +# Linux allows a 255-byte component and a guest is entitled to all of them, +# including for a name stored escaped, which is longer on disk than the name it +# stands for. Nothing below the Linux maximum may be refused. +## Guest filenames at their full length, both stored forms +test-sysroot-name-length: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-length + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-sysroot-name-length + +# Component length is a guest budget; whole-path length is a host one, and the +# host's is smaller (macOS PATH_MAX 1024 against Linux's 4096). A guest path +# past the host ceiling reports ENAMETOOLONG and is never truncated; see +# docs/filenames.md, "Whole paths". Not in the qemu matrix: a real Linux +# kernel has no 1024-byte ceiling, so the boundary this pins does not exist +# there. +## Guest paths that cross the host path ceiling report ENAMETOOLONG +test-sysroot-pathmax: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-pathmax + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + printf 'probe\n' > "$$tmpdir/pathmax-probe"; \ + mode=exact; \ + if [ -e "$$tmpdir/PATHMAX-PROBE" ]; then mode=fold; fi; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-pathmax "$$mode" + +# Names the guest cannot create for itself, because elfuse would store them +# under a different spelling. A well-formed escape means the name it decodes +# to whoever wrote it; anything that merely resembles one means itself. +# +# No two fixtures here may differ only by case: the staging happens on the host +# with no translation, so a folding volume would merge them and the guest would +# see one file where the recipe meant two. That is why the uppercase-hex case +# uses a payload whose lowercase form is not also staged. +## Host-staged escape-shaped names in a sysroot +test-sysroot-name-staged: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-staged + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + d="$$tmpdir/staged"; \ + mkdir -p "$$d"; \ + printf 'plain\n' > "$$d/Plain.Host"; \ + printf 'escaped-foo\n' > "$$d/.ef=464f4f"; \ + printf 'literal-upper\n' > "$$d/.ef=5A5A"; \ + printf 'literal-odd\n' > "$$d/.ef=464f4"; \ + printf 'literal-nonhex\n' > "$$d/.ef=zzzz"; \ + printf 'literal-slash\n' > "$$d/.ef=2f"; \ + printf 'literal-dotdot\n' > "$$d/.ef=2e2e"; \ + printf 'literal-bare\n' > "$$d/.ef="; \ + printf 'literal-legacy\n' > "$$d/.ef_464f4f"; \ + printf 'literal-bar\n' > "$$d/Bar"; \ + printf 'shadowed\n' > "$$d/.ef=426172"; \ + nm="r2probe-$$$$"; \ + Nm="R2Probe-$$$$"; \ + mkdir -p "$$tmpdir/tmp/$$Nm"; \ + mkdir -p "/tmp/$$nm"; \ + printf 'HOST-LEAK\n' > "/tmp/$$nm/planted"; \ + esc="/private/tmp/elfuse-staged-$$$$"; \ + mkdir -p "$$esc/folded" "$$tmpdir$$esc/Folded"; \ + printf 'HOST-LEAK\n' > "$$esc/folded/planted"; \ + trap 'rm -rf "$$tmpdir" "/tmp/'"$$nm"'" "'"$$esc"'"' EXIT; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-sysroot-name-staged \ + "/tmp/$$nm" "$$esc/folded"; \ + if [ -e "/tmp/$$nm/created" ] || [ -e "$$esc/folded/created" ] || \ + [ -n "$$(find "$$tmpdir" -name created)" ]; then \ + printf "$(RED)FAIL$(RESET) a create landed under a folded component\n"; \ + exit 1; \ + fi + +# A sysroot written by an older elfuse holds the spellings that build froze, +# and the current build must keep reading them. The literals staged here are +# copies of rows in tests/casefold-vectors.h. Keep them in step; the guest +# opens strictly by guest name, so a literal that drifts from the header +# fails at runtime rather than silently testing nothing. Staging happens on +# the host so nothing here derives from the codec under test. Escapes mean +# their guest names only where the escape is active, so a case-sensitive +# scratch volume is a SKIP, not a pass. +## Read a corpus of frozen on-disk spellings staged host-side +test-sysroot-corpus: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-corpus + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + printf 'p\n' > "$$tmpdir/CaseProbe"; \ + if [ ! -e "$$tmpdir/caseprobe" ]; then \ + printf "$(YELLOW)SKIP$(RESET) test-sysroot-corpus (scratch volume is case-sensitive)\n"; \ + exit 0; \ + fi; \ + rm -f "$$tmpdir/CaseProbe"; \ + c="$$tmpdir/corpus"; \ + mkdir -p "$$c"; \ + printf 'Foo\n' > "$$c/.ef=466f6f"; \ + printf 'README\n' > "$$c/.ef=524541444d45"; \ + printf 'caf\303\251\n' > "$$c/.ef=636166c3a9"; \ + mkdir -p "$$c/.ef=4775657374446972"; \ + printf 'New.File\n' > "$$c/.ef=4775657374446972/.ef=4e65772e46696c65"; \ + long=".ef=$$(printf '\344\271\276')"; \ + i=0; \ + while [ $$i -lt 42 ]; do \ + long="$$long$$(printf '\345\216\205\345\231\230')"; \ + i=$$((i + 1)); \ + done; \ + awk 'BEGIN { for (i = 0; i < 126; i++) printf "X"; printf "\n" }' \ + > "$$c/$$long"; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-sysroot-corpus + +# The volume counterpart of test-sysroot-name-race: minutes of threaded and +# forked churn over one colliding set instead of ten processes aimed at one +# window. Excluded from check for its runtime, so the guest runs with +# --timeout 0; a pass is only the absence of a reproducer today. The header +# of tests/test-sysroot-name-soak.c states the invariants. +## Soak colliding-name churn for SECS seconds (default 120) +test-sysroot-name-soak: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-soak + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(ELFUSE_BIN) --timeout 0 --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-name-soak $(or $(SECS),120) + +## Alias for test-sysroot-name-soak +check-soak: test-sysroot-name-soak + +# Nothing serializes name creation in a sysroot, because the on-disk spelling of +# a guest name is a function of that name alone. fork(2) under elfuse spawns a +# separate host process, so the children really are separate processes sharing +# one sysroot. Repeated, because a single round can miss a narrow window; a pass +# does not prove there is no race, only a failure proves there is one. +## Concurrent creation of case-colliding names, repeated +test-sysroot-name-race: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-race + @set -e; \ + i=0; \ + tmpdir=""; \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + while [ $$i -lt 10 ]; do \ + tmpdir=$$(mktemp -d); \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-name-race > "$$tmpdir/.out" 2>&1 || { \ + cat "$$tmpdir/.out"; rm -rf "$$tmpdir"; exit 1; }; \ + rm -rf "$$tmpdir"; \ + i=$$((i + 1)); \ + done; \ + printf "test-sysroot-name-race: 10 rounds - PASS\n" + # Build APFS-side dirents whose UTF-8 byte length exceeds Linux # NAME_MAX (255). 89 copies of U+3042 (3-byte UTF-8) plus a 1-byte # ASCII tag = 268 bytes per name; the guest cannot forge this via diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index d043a170..9eebe701 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -365,6 +365,9 @@ QEMU_SKIP=" ELFUSE_SKIP=" test-sysroot-name-unique test-sysroot-name-relative + test-sysroot-name-i18n + test-sysroot-name-length + test-sysroot-name-race " # Whitespace-separated membership test, shared by the per-runner skip lists so a @@ -833,11 +836,22 @@ run_unit_tests() # rather than beliefs, because the VM's / and /tmp are tmpfs: byte-exact and # case-sensitive. Each cleans up after itself, so repeated runs in one boot # are safe. + # + # test-sysroot-name-staged is deliberately absent: it stages the on-disk + # spellings elfuse produces on a folding volume, which is not a concept a + # Linux kernel has, and it fails 10 of its 11 assertions here for exactly + # that reason. printf "\nFilenames\n" test_check "$runner" "test-sysroot-name-unique" "0 failed" \ "$bindir/test-sysroot-name-unique" test_check "$runner" "test-sysroot-name-relative" "0 failed" \ "$bindir/test-sysroot-name-relative" + test_check "$runner" "test-sysroot-name-i18n" "0 failed" \ + "$bindir/test-sysroot-name-i18n" + test_check "$runner" "test-sysroot-name-length" "0 failed" \ + "$bindir/test-sysroot-name-length" + test_check "$runner" "test-sysroot-name-race" "0 failed" \ + "$bindir/test-sysroot-name-race" } run_coreutils_tests() @@ -1267,7 +1281,7 @@ run_suite() # detector does not recognize yet. EXPECTED_BASELINES=( "elfuse-aarch64|238|0" - "qemu-aarch64|220|0" + "qemu-aarch64|223|0" "elfuse-x86_64:apple-m1-m2|71|0" "elfuse-x86_64:apple-m3-plus|71|0" "elfuse-x86_64:apple-unknown|71|0" diff --git a/tests/test-nosysroot-literal-names.c b/tests/test-nosysroot-literal-names.c new file mode 100644 index 00000000..6bb3f00b --- /dev/null +++ b/tests/test-nosysroot-literal-names.c @@ -0,0 +1,77 @@ +/* + * Escape-shaped host names are ordinary names without a sysroot + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The escape encoding exists to let a case-folding sysroot hold names Linux + * keeps apart. Without --sysroot there is no sysroot and no escaping: the guest + * is looking straight at the host filesystem, where a file named ".ef=464f4f" + * is a file named ".ef=464f4f" and nothing else. Decoding it would invent a + * name the directory does not contain, and the guest would then be unable to + * open the entry under either spelling: not the name it was shown, which is + * not on disk, nor the name on disk, which it was never shown. + * + * Code under test: path_translate_dirent_name in src/syscall/path.c, reached + * from the getdents64 loop in src/syscall/fs.c. A regression shows up as a + * listing that reports a name no open() can then resolve. + * + * argv[1] is a host directory staged by the make recipe, because a test running + * without a sysroot has nowhere of its own to write. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +/* The escape of "FOO" on a folding sysroot. Here it must mean itself. */ +#define ESCAPE_SHAPED ".ef=464f4f" +#define DECODES_TO "FOO" + +int main(int argc, char **argv) +{ + char path[PATH_MAX]; + int fd; + + if (argc < 2) { + printf("test-nosysroot-literal-names: no directory given\n"); + return 1; + } + + printf("test-nosysroot-literal-names: literal names without a sysroot\n"); + + TEST("an escape-shaped host name is listed under its own bytes"); + EXPECT_TRUE(dir_contains(argv[1], ESCAPE_SHAPED), + "should appear as written"); + + /* The decisive one: if the listing decoded the name, the guest was shown + * DECODES_TO, which no directory entry matches. + */ + TEST("the listing does not invent the decoded name"); + EXPECT_TRUE(!dir_contains(argv[1], DECODES_TO), + "nothing on disk has that name"); + + TEST("the name the listing reported can be opened"); + snprintf(path, sizeof(path), "%s/%s", argv[1], ESCAPE_SHAPED); + fd = open(path, O_RDONLY); + EXPECT_TRUE(fd >= 0, "the listed name must resolve"); + if (fd >= 0) + close(fd); + + TEST("the decoded name resolves to nothing"); + snprintf(path, sizeof(path), "%s/%s", argv[1], DECODES_TO); + EXPECT_ERRNO(open(path, O_RDONLY), ENOENT, "should not exist"); + + SUMMARY("test-nosysroot-literal-names"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-chdir.c b/tests/test-sysroot-chdir.c index 9e6dd717..ae53bb65 100644 --- a/tests/test-sysroot-chdir.c +++ b/tests/test-sysroot-chdir.c @@ -3,9 +3,23 @@ * * Copyright 2026 elfuse contributors * SPDX-License-Identifier: Apache-2.0 + * + * The guest's working directory is tracked as a guest path, not a host one, so + * getcwd(3) and /proc/self/cwd must report where the guest thinks it is rather + * than where the file actually sits. Two things can break that: the sysroot + * prefix leaking out, and (on a folding volume) a component whose on-disk + * spelling is escaped being reported as stored. + * + * Code under test: proc_cwd_refresh in src/syscall/proc-state.c and the + * host-to-guest conversion in src/syscall/path.c. A regression shows up as a + * cwd the guest cannot chdir back into, because the path it was handed names + * nothing in its own namespace. + * + * Run under --sysroot. */ #include +#include #include #include #include @@ -68,6 +82,52 @@ int main(void) } } + /* A directory whose name the volume cannot hold as itself is stored under + * an escape. The cwd is reported to the guest, so it has to be reported in + * the guest's spelling; handing back the escape names a directory the guest + * never created and cannot chdir into. + */ + TEST("getcwd reports the guest spelling of an escaped directory"); + { + ssize_t len; + + if (mkdir("/Cwd.Dir", 0755) < 0 && errno != EEXIST) { + FAIL("mkdir failed"); + } else if (chdir("/Cwd.Dir") < 0) { + FAIL("chdir failed"); + } else if (!getcwd(cwd, sizeof(cwd))) { + FAIL("getcwd failed"); + } else if (strcmp(cwd, "/Cwd.Dir")) { + FAIL("getcwd leaked the on-disk spelling"); + } else if ((len = readlink("/proc/self/cwd", proc_cwd, + sizeof(proc_cwd) - 1)) < 0) { + FAIL("readlink /proc/self/cwd failed"); + } else { + proc_cwd[len] = '\0'; + if (strcmp(proc_cwd, "/Cwd.Dir")) + FAIL("/proc/self/cwd leaked the on-disk spelling"); + else + PASS(); + } + } + + /* The cwd must be usable, not merely printable: a guest that reads it and + * chdirs back has to arrive where it started. + */ + TEST("the reported cwd can be returned to"); + { + if (chdir("/") < 0) { + FAIL("chdir / failed"); + } else if (chdir(cwd) < 0) { + FAIL("the reported cwd does not resolve"); + } else if (!getcwd(proc_cwd, sizeof(proc_cwd)) || + strcmp(proc_cwd, "/Cwd.Dir")) { + FAIL("round trip landed somewhere else"); + } else { + PASS(); + } + } + SUMMARY("test-sysroot-chdir"); return fails > 0 ? 1 : 0; } diff --git a/tests/test-sysroot-corpus.c b/tests/test-sysroot-corpus.c new file mode 100644 index 00000000..17c48578 --- /dev/null +++ b/tests/test-sysroot-corpus.c @@ -0,0 +1,138 @@ +/* + * Decode of a host-staged escape corpus + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The recipe stages a small tree of on-disk spellings copied byte-for-byte + * from tests/casefold-vectors.h (the frozen format), and this guest opens + * every entry strictly by its guest name. That direction is what an existing + * sysroot exercises after an elfuse upgrade: the disk holds spellings an + * older build wrote, and the current build must keep reading them. The codec + * unit test asserts the same table in-process; this asserts it end to end, + * through staging the recipe cannot derive from the codec under test. + * + * test-sysroot-name-staged is the neighbor with a different question: it pins + * what escape-shaped and escape-resembling names mean when a host stages + * them. Here every staged spelling is a well-formed escape from the frozen + * table, and the assertion is that its guest name, and nothing else, reaches + * it. + * + * Each staged file's content is its own guest name, so a resolve that lands + * anywhere unexpected is caught by the first read. A regression shows up as + * ENOENT on a guest name whose spelling is on disk (the decoder moved off + * the frozen format), as an escape spelling leaking into a listing, or as + * content that names a different file. + * + * Run under --sysroot on a folding volume; the recipe probes and skips + * elsewhere, since staged escapes only mean their guest names where the + * escape is active. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DIR_C "/corpus" + +/* The staged entry must answer to its guest name and carry it as content; + * the trailing newline the recipe appends is not part of the assertion. + */ +static void check_reads_itself(const char *label, const char *guest) +{ + char path[PATH_MAX]; + + TEST(label); + snprintf(path, sizeof(path), DIR_C "/%s", guest); + if (file_content_starts_with(path, guest) < 0) { + FAIL("guest name did not reach the staged spelling and content"); + return; + } + PASS(); +} + +int main(void) +{ + char longx[NAME_MAX + 1]; + + memset(longx, 'X', 126); + longx[126] = '\0'; + + check_reads_itself("hex tier, uppercase", "Foo"); + check_reads_itself("hex tier, all uppercase", "README"); + check_reads_itself("hex tier, non-ascii", "caf\xc3\xa9"); + check_reads_itself("long tier, 126 bytes", longx); + + TEST("nested escaped directories resolve"); + if (file_content_starts_with(DIR_C "/GuestDir/New.File", "New.File") < 0) + FAIL("a nested guest path under an escaped directory"); + else + PASS(); + + TEST("the on-disk spelling is not a guest name"); + { + struct stat st; + EXPECT_ERRNO(stat(DIR_C "/.ef=466f6f", &st), ENOENT, "host spelling"); + } + + TEST("the listing holds guest spellings only"); + { + DIR *d = opendir(DIR_C); + struct dirent *de; + bool saw_long = false; + bool leaked = false; + int entries = 0; + + if (!d) { + FAIL("opendir"); + } else { + while ((de = readdir(d))) { + if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) + continue; + entries++; + if (!strncmp(de->d_name, ".ef=", 4)) + leaked = true; + if (!strcmp(de->d_name, longx)) + saw_long = true; + } + closedir(d); + if (leaked) + FAIL("an escape spelling leaked into the listing"); + else if (!saw_long) + FAIL("the 126-byte name is missing or truncated"); + else if (entries != 5) + FAIL("unexpected entry count"); + else + PASS(); + } + } + + TEST("stat by name matches the opened file"); + { + struct stat by_name, by_fd; + int fd = open(DIR_C "/Foo", O_RDONLY); + + if (fd < 0 || stat(DIR_C "/Foo", &by_name) < 0 || + fstat(fd, &by_fd) < 0 || by_name.st_ino != by_fd.st_ino || + by_name.st_dev != by_fd.st_dev) + FAIL("stat and fstat disagree about the staged file"); + else + PASS(); + if (fd >= 0) + close(fd); + } + + SUMMARY("test-sysroot-corpus"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-name-i18n.c b/tests/test-sysroot-name-i18n.c new file mode 100644 index 00000000..b543a58e --- /dev/null +++ b/tests/test-sysroot-name-i18n.c @@ -0,0 +1,341 @@ +/* + * Non-ASCII guest filenames + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A Linux filename is a byte string, and a guest is entitled to use any of + * them. The volume a sysroot sits on may disagree about which two byte strings + * are the same name, and it disagrees in ways no simple rule predicts: the + * German sharp s matches "ss", so a fold can change length; Greek final sigma + * matches medial sigma, so a fold can depend on position; compatibility + * mappings apply, so the fi ligature matches "fi". Each pair below is one the + * volume considers equal, and each must stay two files to the guest. + * + * Scripts with no case and no normalization forms (Chinese, Thai, emoji) are + * here too, because the rule escapes them as well and they have to survive + * the round trip unchanged. + * + * Every name is created by the guest. On a folding sysroot the escape delivers + * the Linux result for all of them. A case-sensitive APFS sysroot is different: + * the volume still folds canonical normalization and refuses names that are + * not well-formed UTF-8, and with case folding absent the escape is inactive, + * so canonically-equal spellings alias (a write to one clobbers the other) and + * ill-formed names fail with EILSEQ. That divergence is documented in + * docs/filenames.md; run with argv[1] "csapfs" the test pins it exactly, so a + * future change that closes the gap turns these expectations red and updates + * them deliberately. Case pairs and compatibility-only pairs stay two files + * there: the volume folds neither. + * + * Code under test: src/syscall/casefold.c decides which of these names can be + * stored as themselves, and src/syscall/casefold-walk.c resolves them. A + * regression shows up as two names the volume folds together collapsing into + * one file, or a name whose bytes change on the way back out. + * + * Run under --sysroot. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +/* True when the sysroot sits on case-sensitive APFS, where the escape is off + * but the volume still folds canonical normalization and rejects ill-formed + * UTF-8. Set by the recipe via argv, like the volume mode in + * test-sysroot-pathmax: the guest cannot probe this itself, because hiding + * volume behavior is exactly what the escape does when it is active. + */ +static bool vol_csapfs; + +#define DIR_I "/name-i18n" + +/* Everything in this lane lives under one directory and is addressed by name, + * so the shared helpers are reached through a path built here. Only the + * composition is local; the I/O is not duplicated. + */ +static int write_file(const char *name, const char *text) +{ + char path[PATH_MAX]; + + snprintf(path, sizeof(path), "%s/%s", DIR_I, name); + return file_write(path, text); +} + +static int read_back(const char *name, char *buf, size_t bufsz) +{ + char path[PATH_MAX]; + int fd; + ssize_t n; + + snprintf(path, sizeof(path), "%s/%s", DIR_I, name); + fd = open(path, O_RDONLY); + if (fd < 0) + return -1; + n = read(fd, buf, bufsz - 1); + close(fd); + if (n < 0) + return -1; + buf[n] = '\0'; + return 0; +} + +static bool in_listing(const char *name) +{ + DIR *d = opendir(DIR_I); + struct dirent *de; + bool found = false; + + if (!d) + return false; + while ((de = readdir(d))) { + if (!strcmp(de->d_name, name)) { + found = true; + break; + } + } + closedir(d); + return found; +} + +/* Create one name, read it back, and require it to appear in a listing spelled + * with exactly the bytes it was created with. @label carries the script so a + * failure says which one broke. + */ +static void check_roundtrip(const char *label, const char *name) +{ + char got[64]; + + TEST(label); + if (write_file(name, label) < 0) { + FAIL("create"); + return; + } + if (read_back(name, got, sizeof(got)) < 0) { + FAIL("reopen under the same bytes"); + return; + } + if (strcmp(got, label)) { + FAIL("content came back wrong"); + return; + } + if (!in_listing(name)) { + FAIL("listing does not report the name byte-exactly"); + return; + } + PASS(); +} + +/* Two names the volume matches against each other. They must be two files: + * distinct content, both listed, and removing one leaving the other. + */ +static void check_pair(const char *label, const char *a, const char *b) +{ + char got[64]; + + TEST(label); + if (write_file(a, "first") < 0 || write_file(b, "second") < 0) { + FAIL("create both"); + return; + } + if (read_back(a, got, sizeof(got)) < 0 || strcmp(got, "first")) { + FAIL("first name reads the wrong file"); + return; + } + if (read_back(b, got, sizeof(got)) < 0 || strcmp(got, "second")) { + FAIL("second name reads the wrong file"); + return; + } + if (!in_listing(a) || !in_listing(b)) { + FAIL("both spellings must appear in a listing"); + return; + } + PASS(); +} + +/* A canonically-equal pair. On a folding sysroot this is check_pair; on + * case-sensitive APFS the volume folds the two spellings together with the + * escape off, so the documented divergence is pinned instead: the second + * write lands in the first file, both spellings read it, and the listing + * holds one entry under the first writer's spelling. + */ +static void check_canonical_pair(const char *label, + const char *a, + const char *b) +{ + char got[64]; + + if (!vol_csapfs) { + check_pair(label, a, b); + return; + } + + TEST(label); + if (write_file(a, "first") < 0 || write_file(b, "second") < 0) { + FAIL("create both"); + return; + } + if (read_back(a, got, sizeof(got)) < 0 || strcmp(got, "second") || + read_back(b, got, sizeof(got)) < 0 || strcmp(got, "second")) { + FAIL("spellings did not alias to the second write"); + return; + } + if (!in_listing(a) || in_listing(b)) { + FAIL("listing should hold one entry, spelled as first written"); + return; + } + PASS(); +} + +/* A name that is not well-formed UTF-8. On a folding sysroot the escape + * stores it; on case-sensitive APFS the volume refuses it and the guest sees + * EILSEQ, which is the divergence to pin. + */ +static void check_invalid_utf8(const char *label, const char *name) +{ + if (!vol_csapfs) { + check_roundtrip(label, name); + return; + } + + TEST(label); + errno = 0; + if (write_file(name, "x") == 0 || errno != EILSEQ) + FAIL("an ill-formed name should be refused with EILSEQ"); + else + PASS(); +} + +int main(int argc, char **argv) +{ + vol_csapfs = argc > 1 && !strcmp(argv[1], "csapfs"); + + TEST("fixture mkdir"); + EXPECT_TRUE(mkdir(DIR_I, 0755) == 0 || errno == EEXIST, "mkdir"); + + /* Scripts with no case and no normalization forms. Nothing here collides; + * the rule escapes them anyway, so they exercise the round trip. + */ + check_roundtrip("chinese simplified", "\xe6\x96\x87\xe6\xa1\xa3.txt"); + check_roundtrip("chinese traditional", "\xe6\xaa\x94\xe6\xa1\x88.txt"); + check_roundtrip("thai", "\xe0\xb9\x84\xe0\xb8\x97\xe0\xb8\xa2"); + check_roundtrip("arabic", "\xd9\x85\xd9\x84\xd9\x81"); + check_roundtrip("emoji", "\xf0\x9f\x9a\x80.log"); + check_roundtrip("emoji zwj sequence", + "\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x92\xbb"); + check_roundtrip("mixed script", "a-\xe6\x96\x87-z"); + + /* Case folding beyond ASCII. */ + check_pair("accented latin case", "\xc3\x89t\xc3\xa9", "\xc3\xa9t\xc3\xa9"); + check_pair("cyrillic case", "\xd0\x94\xd0\x90", "\xd0\xb4\xd0\xb0"); + check_pair("greek sigma case", "\xce\xa3o\xcf\x82", "\xcf\x83o\xcf\x82"); + check_pair("greek final vs medial sigma", "\xcf\x83q\xcf\x82", + "\xcf\x83q\xcf\x83"); + check_pair("german sharp s vs ss", + "stra\xc3\x9f" + "e", + "strasse"); + check_pair("deseret, cased beyond the BMP", "\xf0\x90\x90\x80y", + "\xf0\x90\x90\xa8y"); + + /* Normalization. The canonical pairs (composed against decomposed, + * singletons included) go through check_canonical_pair, because + * case-sensitive APFS folds canonical equivalence even with case folding + * off. The fi ligature is compatibility-only and stays distinct there. + */ + check_canonical_pair("french NFC vs NFD", "caf\xc3\xa9", "cafe\xcc\x81"); + check_canonical_pair("german umlaut NFC vs NFD", + "\xc3\xbc" + "ber", + "u\xcc\x88" + "ber"); + check_canonical_pair("japanese kana NFC vs NFD", "\xe3\x81\x8c", + "\xe3\x81\x8b\xe3\x82\x99"); + check_canonical_pair("korean hangul NFC vs jamo", "\xed\x95\x9c", + "\xe1\x84\x92\xe1\x85\xa1\xe1\x86\xab"); + check_canonical_pair("vietnamese, two combining marks", + "\xe1\xbb\x87" + "d", + "e\xcc\xa3\xcc\x82" + "d"); + check_canonical_pair("devanagari NFC vs NFD", + "\xe0\xa4\xa9" + "e", + "\xe0\xa4\xa8\xe0\xa4\xbc" + "e"); + check_canonical_pair("ohm sign vs omega", + "\xe2\x84\xa6" + "a", + "\xce\xa9" + "a"); + check_canonical_pair("angstrom sign vs A-ring", + "\xe2\x84\xab" + "c", + "\xc3\x85" + "c"); + check_pair("fi ligature vs fi", + "\xef\xac\x81" + "b", + "fib"); + check_canonical_pair("hebrew presentation form", + "\xef\xac\xae" + "f", + "\xd7\x90\xd6\xb7" + "f"); + + /* Turkish dotless i is a distinct letter, not a case variant of ASCII i. + * The rule escapes it because it is non-ASCII, and "id" stays literal, so + * they cannot interfere however the volume treats them. + */ + check_pair("turkish dotless i vs ascii i", + "\xc4\xb1" + "d", + "id"); + + /* Names that are not valid UTF-8 at all. The volume refuses to store them + * as themselves, so escaping is the only way they can exist. + */ + check_invalid_utf8("invalid utf-8, high bytes", + "bad\xff\xfe" + "name"); + check_invalid_utf8("invalid utf-8, lone continuation", + "lone\x80" + "byte"); + check_invalid_utf8("invalid utf-8, truncated sequence", "trunc\xe3\x81"); + check_invalid_utf8("invalid utf-8, surrogate", + "sur\xed\xa0\x80" + "rogate"); + + /* Everything created above must still be reachable, so the escapes have + * not collided with each other. + */ + TEST("every name is still readable"); + { + /* The ill-formed name exists only where the escape stored it; the + * caf\xc3\xa9 spellings read the same file on csapfs and different + * ones elsewhere, but both must resolve either way. + */ + char got[64]; + EXPECT_TRUE( + read_back("\xe6\x96\x87\xe6\xa1\xa3.txt", got, sizeof(got)) == 0 && + read_back("caf\xc3\xa9", got, sizeof(got)) == 0 && + read_back("cafe\xcc\x81", got, sizeof(got)) == 0 && + (vol_csapfs || read_back("bad\xff\xfe" + "name", + got, sizeof(got)) == 0), + "a name became unreachable"); + } + + SUMMARY("test-sysroot-name-i18n"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-name-length.c b/tests/test-sysroot-name-length.c new file mode 100644 index 00000000..0f99e895 --- /dev/null +++ b/tests/test-sysroot-name-length.c @@ -0,0 +1,262 @@ +/* + * Guest filenames at their full length + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Linux allows a path component of 255 bytes, and a guest is entitled to all + * of them, including for a name that has to be stored escaped, which is + * longer on disk than the name it stands for. The volume underneath measures + * its own limit in UTF-16 code units rather than bytes, which is what leaves + * room for the escape; this asserts the guest-visible consequence, that no + * length below the Linux maximum is refused and 256 bytes is. + * + * The interesting lengths are the two tier boundaries the encoding has and the + * Linux maximum. A guest must not be able to tell where a tier changes, so the + * expectations either side of it are identical. + * + * Code under test: the two payload tiers in src/syscall/casefold.c and the + * length accounting in src/syscall/casefold-walk.c. A regression shows up as + * ENAMETOOLONG for a name Linux allows (most likely at whichever tier + * boundary the encoding grew), or as a colliding pair at full length + * collapsing into one file. + * + * Run under --sysroot. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DIR_L "/name-length" +#define GUEST_NAME_MAX 255 + +/* Everything in this lane lives under one directory and is addressed by name, + * so the shared helpers are reached through a path built here. Only the + * composition is local; the I/O is not duplicated. + */ +static int content_is(const char *name, const char *want) +{ + char path[PATH_MAX]; + + snprintf(path, sizeof(path), "%s/%s", DIR_L, name); + return file_content_is(path, want); +} + +static bool listed(const char *name) +{ + return dir_contains(DIR_L, name); +} + +static void build(char *buf, size_t n, char c) +{ + memset(buf, c, n); + buf[n] = '\0'; +} + +/* Build a name of @bytes bytes out of a repeated multi-byte character, so the + * byte length is what a Linux guest sees while the volume counts characters. + */ +static void build_utf8(char *buf, size_t bytes, const char *unit) +{ + size_t ulen = strlen(unit); + size_t n = 0; + + while (n + ulen <= bytes) { + memcpy(buf + n, unit, ulen); + n += ulen; + } + buf[n] = '\0'; +} + +static int create(const char *name, const char *text) +{ + char path[PATH_MAX]; + int fd; + ssize_t n; + + snprintf(path, sizeof(path), "%s/%s", DIR_L, name); + fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0644); + if (fd < 0) + return -1; + n = write(fd, text, strlen(text)); + close(fd); + return n == (ssize_t) strlen(text) ? 0 : -1; +} + +/* One name of a given length: create it, read it back, and require a listing + * to report the same bytes. @mixed picks whether it needs escaping. + */ +static void check_length(size_t bytes, bool mixed) +{ + char name[GUEST_NAME_MAX + 2]; + char label[64]; + + snprintf(label, sizeof(label), "%zu-byte %s name", bytes, + mixed ? "mixed-case" : "lowercase"); + build(name, bytes, mixed ? 'Q' : 'q'); + + TEST(label); + if (create(name, "x") < 0) { + FAIL("create"); + return; + } + if (content_is(name, "x") < 0) { + FAIL("reopen"); + return; + } + if (!listed(name)) { + FAIL("listing does not report the name"); + return; + } + PASS(); +} + +/* A colliding pair at a given length. Both members must exist as separate + * files, which is the case a side-table-free mapping has to earn: the escape + * is longer than the name, so this is where a length limit would bite. + */ +static void check_pair(size_t bytes) +{ + char lower[GUEST_NAME_MAX + 2]; + char upper[GUEST_NAME_MAX + 2]; + char label[64]; + + snprintf(label, sizeof(label), "%zu-byte colliding pair", bytes); + build(lower, bytes, 'a'); + build(upper, bytes, 'A'); + + TEST(label); + if (create(lower, "lower") < 0 || create(upper, "upper") < 0) { + FAIL("create both"); + return; + } + if (content_is(lower, "lower") < 0 || content_is(upper, "upper") < 0) { + FAIL("the two names are not separate files"); + return; + } + if (!listed(lower) || !listed(upper)) { + FAIL("both must appear in a listing"); + return; + } + PASS(); +} + +/* openat2(RESOLVE_NO_SYMLINKS) is answered by a walker that resolves the whole + * path itself and therefore sees host spellings, which for an escaped name run + * past the guest limit. This is where a walker sized to the guest limit refuses + * a name Linux allows, with ENAMETOOLONG for a file openat opens fine. Reuses + * the file check_length left behind. + */ +static void check_openat2(size_t bytes) +{ + struct open_how how = { + .flags = O_RDONLY, .mode = 0, .resolve = RESOLVE_NO_SYMLINKS}; + char name[GUEST_NAME_MAX + 2]; + char path[PATH_MAX]; + char label[64]; + long fd; + + snprintf(label, sizeof(label), "openat2 walks a %zu-byte escaped name", + bytes); + build(name, bytes, 'Q'); + snprintf(path, sizeof(path), "%s/%s", DIR_L, name); + + TEST(label); + errno = 0; + fd = syscall(SYS_openat2, AT_FDCWD, path, &how, sizeof(how)); + if (fd >= 0) { + close((int) fd); + PASS(); + } else { + FAIL("a walker refused a name Linux allows"); + } +} + +int main(void) +{ + char name[GUEST_NAME_MAX + 2]; + char over[GUEST_NAME_MAX + 3]; + char path[PATH_MAX]; + + TEST("fixture mkdir"); + EXPECT_TRUE(mkdir(DIR_L, 0755) == 0 || errno == EEXIST, "mkdir"); + + /* A name stored as itself is bounded only by Linux. */ + check_length(1, false); + check_length(GUEST_NAME_MAX, false); + + /* A name stored escaped is longer on disk than the name it stands for, so + * these are the lengths that would fail if the escape had a ceiling. The + * encoding changes shape partway through this range; the guest cannot see + * where, so the expectations do not either. + */ + check_length(1, true); + check_length(124, true); + check_length(125, true); + check_length(126, true); + check_length(127, true); + check_length(254, true); + check_length(GUEST_NAME_MAX, true); + + /* The same lengths through the second walker, which must not have a + * ceiling of its own. + */ + check_openat2(126); + check_openat2(GUEST_NAME_MAX); + + /* Both members of a colliding pair, at the maximum. One of them has to be + * stored under a spelling that is more than twice as long. + */ + check_pair(8); + check_pair(125); + check_pair(126); + check_pair(GUEST_NAME_MAX); + + /* A multi-byte name at the Linux maximum. The volume would allow three + * times as many bytes here, but Linux would not, and it is Linux the guest + * is entitled to. + */ + build_utf8(name, GUEST_NAME_MAX, "\xe6\x96\x87"); + TEST("255 bytes of CJK"); + EXPECT_TRUE(create(name, "cjk") == 0 && content_is(name, "cjk") == 0 && + listed(name), + "should round trip"); + + { + char nfc[GUEST_NAME_MAX + 2]; + char nfd[GUEST_NAME_MAX + 2]; + + build_utf8(nfc, 250, "caf\xc3\xa9"); + build_utf8(nfd, 250, "cafe\xcc\x81"); + TEST("long normalization twins stay separate"); + EXPECT_TRUE(create(nfc, "nfc") == 0 && create(nfd, "nfd") == 0 && + content_is(nfc, "nfc") == 0 && + content_is(nfd, "nfd") == 0, + "two files"); + } + + /* One byte past what Linux allows is refused, and nothing is created. */ + build(over, GUEST_NAME_MAX + 1, 'z'); + snprintf(path, sizeof(path), "%s/%s", DIR_L, over); + TEST("256-byte name is refused"); + EXPECT_ERRNO(open(path, O_CREAT | O_WRONLY, 0644), ENAMETOOLONG, + "should exceed NAME_MAX"); + TEST("256-byte mkdir is refused"); + EXPECT_ERRNO(mkdir(path, 0755), ENAMETOOLONG, "should exceed NAME_MAX"); + + SUMMARY("test-sysroot-name-length"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-name-race.c b/tests/test-sysroot-name-race.c new file mode 100644 index 00000000..9b83478f --- /dev/null +++ b/tests/test-sysroot-name-race.c @@ -0,0 +1,191 @@ +/* + * Concurrent creation of colliding names + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Nothing serializes name creation in a sysroot, and the reason is that the + * on-disk spelling of a guest name is a function of that name alone: two + * processes creating names that the volume would fold together are writing + * different entries, so they never contend. This asserts the consequence + * rather than the mechanism. + * + * fork(2) under elfuse spawns a separate host process, so the children below + * really are separate processes sharing one sysroot with no shared state + * between them. + * + * Two rounds. In the first every child creates a different member of one + * case-colliding set, and all of them must survive with their own content: a + * lost update or a create landing on a sibling's entry shows up as wrong + * content or a missing name. In the second every child races for the *same* + * name under O_EXCL, where Linux guarantees exactly one winner. + * + * Code under test: casefold_needs_escape and casefold_escape in + * src/syscall/casefold.c, which decide the target entry without consulting the + * directory, reached through src/syscall/casefold-walk.c. A regression shows up + * as two children writing the same file, a create reporting EEXIST for a name + * nobody else took, or more than one winner of the O_EXCL round. + * + * A pass does not prove the absence of a race: it is a scheduling test, and the + * make recipe repeats it because a single round can miss a narrow window. What + * a failure proves is that one exists. Run under --sysroot. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DIR_R "/name-race" +#define KIDS 4 + +/* One case-colliding set: the volume matches all four against each other, so + * each has to end up in a different on-disk entry. + */ +static const char *const spellings[KIDS] = {"Race", "race", "RACE", "rAcE"}; + +/* Everything in this lane lives under one directory and is addressed by name, + * so the shared helpers are reached through a path built here. Only the + * composition is local; the I/O is not duplicated. + */ +static int write_file(const char *name, const char *text) +{ + char path[PATH_MAX]; + + snprintf(path, sizeof(path), "%s/%s", DIR_R, name); + return file_write(path, text); +} + +static int content_is(const char *name, const char *want) +{ + char path[PATH_MAX]; + + snprintf(path, sizeof(path), "%s/%s", DIR_R, name); + return file_content_is(path, want); +} + +/* Fork KIDS children and run @body in each, recording each child's exit code + * in @code (-1 for a child that could not be forked or reaped, or that died + * abnormally). One fork/wait implementation for both rounds, so the reaping + * bookkeeping cannot drift between them. + */ +static void run_children(int (*body)(int), int code[KIDS]) +{ + pid_t pid[KIDS]; + + for (int i = 0; i < KIDS; i++) { + pid[i] = fork(); + if (pid[i] == 0) + _exit(body(i)); + } + for (int i = 0; i < KIDS; i++) { + int status = 0; + + code[i] = -1; + if (pid[i] < 0) + continue; + if (waitpid(pid[i], &status, 0) < 0) + continue; + if (WIFEXITED(status)) + code[i] = WEXITSTATUS(status); + } +} + +/* The count of children whose exit code was @want. */ +static int exited_with(const int code[KIDS], int want) +{ + int n = 0; + + for (int i = 0; i < KIDS; i++) + if (code[i] == want) + n++; + return n; +} + +static int create_own_spelling(int i) +{ + char text[8]; + + snprintf(text, sizeof(text), "%d", i); + return write_file(spellings[i], text) == 0 ? 0 : 1; +} + +/* Every child races for one name under O_EXCL. Linux gives it to exactly one, + * so a child reports success only if it created the file, and EEXIST is the + * expected answer for the rest. + */ +static int claim_shared_name(int i) +{ + char path[PATH_MAX]; + int fd; + + /* Every child races for the same name, so which child this is does not + * enter into it; run_children's signature supplies the index regardless. + */ + (void) i; + snprintf(path, sizeof(path), "%s/Contended", DIR_R); + fd = open(path, O_CREAT | O_EXCL | O_WRONLY, 0644); + if (fd >= 0) { + close(fd); + return 0; + } + return errno == EEXIST ? 1 : 2; +} + +int main(void) +{ + TEST("fixture mkdir"); + EXPECT_TRUE(mkdir(DIR_R, 0755) == 0 || errno == EEXIST, "mkdir"); + + TEST("every child creates its own spelling"); + { + int code[KIDS]; + + run_children(create_own_spelling, code); + EXPECT_EQ(exited_with(code, 0), KIDS, "all children succeed"); + } + + /* Nobody may have landed on a sibling's entry: each spelling holds the + * index of the child that wrote it. + */ + for (int i = 0; i < KIDS; i++) { + char want[8]; + char label[64]; + + snprintf(want, sizeof(want), "%d", i); + snprintf(label, sizeof(label), "spelling %d kept its own content", i); + TEST(label); + EXPECT_TRUE(content_is(spellings[i], want) == 0, + "a concurrent create landed on the wrong entry"); + } + + TEST("the directory holds exactly one entry per spelling"); + EXPECT_EQ(dir_entry_count(DIR_R), KIDS, "entry count"); + + /* Racing for one name is ordinary O_EXCL: the kernel picks a winner and + * everyone else gets EEXIST, with no elfuse-level arbitration involved. + */ + TEST("exactly one child wins a contended O_EXCL create"); + { + int code[KIDS]; + + run_children(claim_shared_name, code); + EXPECT_TRUE( + exited_with(code, 0) == 1 && exited_with(code, 1) == KIDS - 1, + "exactly one winner, the rest EEXIST"); + } + + SUMMARY("test-sysroot-name-race"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-name-soak.c b/tests/test-sysroot-name-soak.c new file mode 100644 index 00000000..e41d92fc --- /dev/null +++ b/tests/test-sysroot-name-soak.c @@ -0,0 +1,321 @@ +/* + * Sustained churn of case-colliding names + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * test-sysroot-name-race aims ten processes at one narrow window; this is the + * volume counterpart. Eight threads and two forked children hammer eight + * directories with creates, renames, unlinks, stats, and listing scans over + * one colliding set (four case spellings plus an NFC/NFD pair) for a + * deadline given in seconds as argv[1]. Nothing serializes name creation (the + * spelling is a function of the name alone), so this is the shape that would + * surface a lost update or a decode landing on a sibling's entry. + * + * Two invariants hold at every step, however the operations interleave: + * every syscall returns success or the ENOENT/EEXIST that a concurrent + * unlink or create legitimately produces, and every listing is a + * duplicate-free subset of the colliding set: a duplicate means two disk + * entries decoded to one guest name, an escape spelling means a decode was + * skipped. After the deadline, workers join and a quiescent sweep checks + * that whatever survived reads back a member's content and unlinks cleanly. + * + * A pass does not prove the absence of a race; it is evidence that sustained + * churn lacks a reproducer today, and only a failure proves anything. Kept + * out of `make check` for its runtime; run via test-sysroot-name-soak (or + * check-soak) with --timeout 0. + * + * Code under test: the create/rename/unlink translation paths in + * src/syscall/fs.c, the case-exact walk in src/syscall/casefold-walk.c, and + * the dirent decode in src/syscall/path.c under contention. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +#define N_DIRS 8 +#define N_THREADS 8 +#define N_FORKS 2 +#define DEFAULT_SECS 60 + +static const char *const names[] = { + "Race", "race", "RACE", "rAcE", "caf\xc3\xa9", "cafe\xcc\x81", +}; +#define N_NAMES (sizeof(names) / sizeof(names[0])) + +/* Monotonic seconds, so a wall-clock step during the run cannot stretch or + * cut the soak (time(2) tracks CLOCK_REALTIME). + */ +static time_t mono_now(void) +{ + struct timespec ts; + + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec; +} + +static time_t deadline; +static atomic_int worker_failures; + +/* One line per anomaly, not per operation: a soak that floods the log buries + * the first failure, which is the one that names the bug. + */ +static void soak_fail(const char *what, const char *dir, const char *name) +{ + fprintf(stderr, "soak: %s (dir %s, name %s, errno %d)\n", what, dir, name, + errno); + atomic_fetch_add(&worker_failures, 1); +} + +static uint64_t prng_next(uint64_t *s) +{ + *s ^= *s << 13; + *s ^= *s >> 7; + *s ^= *s << 17; + return *s; +} + +static void dir_path(char *out, size_t outsz, int d) +{ + snprintf(out, outsz, "/soak-%d", d); +} + +static void entry_path(char *out, size_t outsz, int d, const char *name) +{ + snprintf(out, outsz, "/soak-%d/%s", d, name); +} + +static bool name_in_set(const char *n) +{ + for (size_t i = 0; i < N_NAMES; i++) { + if (!strcmp(n, names[i])) + return true; + } + return false; +} + +/* A listing must be a duplicate-free subset of the set at every instant: + * entries come and go under churn, but two entries for one guest name mean + * two disk spellings decoded onto it, and an escape prefix means one was + * not decoded at all. + */ +static void scan_listing(const char *dir) +{ + char seen[N_NAMES] = {0}; + DIR *d = opendir(dir); + struct dirent *de; + + if (!d) { + soak_fail("listing did not open", dir, "-"); + return; + } + while ((de = readdir(d))) { + if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) + continue; + if (!strncmp(de->d_name, ".ef=", 4)) { + soak_fail("escape spelling leaked into a listing", dir, de->d_name); + break; + } + if (!name_in_set(de->d_name)) { + soak_fail("listing holds a name outside the set", dir, de->d_name); + break; + } + for (size_t i = 0; i < N_NAMES; i++) { + if (strcmp(de->d_name, names[i])) + continue; + if (seen[i]) { + soak_fail("guest name listed twice", dir, de->d_name); + break; + } + seen[i] = 1; + } + } + closedir(d); +} + +static void worker_loop(uint64_t seed) +{ + uint64_t s = seed | 1; + + while (mono_now() < deadline && !atomic_load(&worker_failures)) { + uint64_t r = prng_next(&s); + int d = (int) (r % N_DIRS); + const char *name = names[(r >> 8) % N_NAMES]; + char path[PATH_MAX], dst[PATH_MAX]; + + entry_path(path, sizeof(path), d, name); + switch ((r >> 16) % 5) { + case 0: { + int fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0644); + if (fd < 0) { + soak_fail("create refused", path, name); + break; + } + /* The content is the creating spelling, so any read landing on + * a sibling's file is visible to the quiescent sweep. + */ + if (write(fd, name, strlen(name)) < 0) + soak_fail("write refused", path, name); + close(fd); + break; + } + case 1: { + const char *to = names[(r >> 24) % N_NAMES]; + + entry_path(dst, sizeof(dst), d, to); + if (rename(path, dst) < 0 && errno != ENOENT) + soak_fail("rename refused", path, to); + break; + } + case 2: + if (unlink(path) < 0 && errno != ENOENT) + soak_fail("unlink refused", path, name); + break; + case 3: { + struct stat st; + + if (stat(path, &st) < 0 && errno != ENOENT) + soak_fail("stat refused", path, name); + break; + } + default: { + char dir[64]; + + dir_path(dir, sizeof(dir), d); + scan_listing(dir); + break; + } + } + } +} + +static void *thread_main(void *arg) +{ + worker_loop((uint64_t) (uintptr_t) arg); + return NULL; +} + +int main(int argc, char **argv) +{ + long secs = argc > 1 ? strtol(argv[1], NULL, 10) : DEFAULT_SECS; + pthread_t threads[N_THREADS]; + pid_t kids[N_FORKS]; + + if (secs <= 0) + secs = DEFAULT_SECS; + deadline = mono_now() + secs; + + TEST("fixture directories"); + { + bool ok = true; + for (int d = 0; d < N_DIRS; d++) { + char dir[64]; + + dir_path(dir, sizeof(dir), d); + if (mkdir(dir, 0755) < 0 && errno != EEXIST) + ok = false; + } + EXPECT_TRUE(ok, "mkdir"); + } + + /* Forked children first so they inherit no thread state; each is a full + * elfuse host process sharing the sysroot, which is the cross-process + * half of the churn. + */ + /* Seeds are fixed so a failing interleaving can at least be retried + * with the same operation streams; distinct per worker so no two + * workers replay each other. The multiplier is the usual 64-bit + * golden-ratio scatter constant. + */ + for (int i = 0; i < N_FORKS; i++) { + kids[i] = fork(); + if (kids[i] == 0) { + worker_loop(0x9E3779B97F4A7C15ULL * (uint64_t) (i + 1)); + _exit(atomic_load(&worker_failures) ? 1 : 0); + } + } + for (int i = 0; i < N_THREADS; i++) + pthread_create(&threads[i], NULL, thread_main, + (void *) (uintptr_t) (0xA5A5A5A5ULL + (uint64_t) i)); + + for (int i = 0; i < N_THREADS; i++) + pthread_join(threads[i], NULL); + + TEST("forked workers exit clean"); + { + bool ok = true; + for (int i = 0; i < N_FORKS; i++) { + int st = 0; + + if (kids[i] < 0 || waitpid(kids[i], &st, 0) != kids[i] || + !WIFEXITED(st) || WEXITSTATUS(st) != 0) + ok = false; + } + EXPECT_TRUE(ok, "a forked worker failed or died"); + } + + TEST("no worker reported an anomaly"); + EXPECT_TRUE(atomic_load(&worker_failures) == 0, "see soak: lines above"); + + TEST("quiescent sweep"); + { + bool ok = true; + + for (int d = 0; d < N_DIRS && ok; d++) { + char dir[64]; + + dir_path(dir, sizeof(dir), d); + scan_listing(dir); + for (size_t i = 0; i < N_NAMES; i++) { + char path[PATH_MAX], got[NAME_MAX + 1]; + int fd; + ssize_t n; + + entry_path(path, sizeof(path), d, names[i]); + fd = open(path, O_RDONLY); + if (fd < 0) { + if (errno != ENOENT) + ok = false; + continue; + } + n = read(fd, got, sizeof(got) - 1); + close(fd); + if (n < 0) { + ok = false; + continue; + } + got[n] = '\0'; + /* Content is whichever member spelling last wrote the file; + * anything else means a write landed through the wrong + * entry. + */ + if (!name_in_set(got)) + ok = false; + if (unlink(path) < 0 && errno != ENOENT) + ok = false; + } + } + EXPECT_TRUE(ok && atomic_load(&worker_failures) == 0, + "post-churn state is inconsistent"); + } + + SUMMARY("test-sysroot-name-soak"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-name-staged.c b/tests/test-sysroot-name-staged.c new file mode 100644 index 00000000..c2633059 --- /dev/null +++ b/tests/test-sysroot-name-staged.c @@ -0,0 +1,215 @@ +/* + * Host-staged names the guest could not have created + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A sysroot is an ordinary directory tree and anything may write into it, so + * the guest view has to be well defined for names elfuse itself would never + * produce. Two questions matter. + * + * A name that is a well-formed escape means the name it decodes to, wherever + * it came from, which is what makes the mapping a property of the name and + * not of who wrote it. A name that merely resembles one means itself: + * uppercase hex, an odd number of digits, a payload decoding to "/" or ".." + * are all ordinary files, and the guest must be able to open them under the + * bytes they are spelled with. + * + * Code under test: casefold_is_escaped and casefold_to_guest in + * src/syscall/casefold.c, reached through the directory-entry and lookup paths + * in src/syscall/path.c. A regression shows up as a staged file the guest + * cannot open under any name, or as a name that resembles an escape being + * decoded into something else. + * + * The recipe stages all of this host-side, because a guest cannot create a + * name that elfuse would store under a different spelling. Run under + * --sysroot; the fixtures are staged by the make recipe. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DIR_S "/staged" + +/* Everything in this lane lives under one directory and is addressed by name, + * so the shared helpers are reached through a path built here. Only the + * composition is local; the I/O is not duplicated. + */ +static int content_is(const char *name, const char *want) +{ + char path[PATH_MAX]; + + /* The recipe stages these with printf, which appends a newline the + * assertion is not about, so the comparison is a prefix. + */ + snprintf(path, sizeof(path), "%s/%s", DIR_S, name); + return file_content_starts_with(path, want); +} + +static bool listed(const char *name) +{ + return dir_contains(DIR_S, name); +} + +static void expect_reachable(const char *label, + const char *name, + const char *want) +{ + TEST(label); + if (content_is(name, want) < 0) { + FAIL("should be readable under this spelling"); + return; + } + if (!listed(name)) { + FAIL("should appear in a listing under this spelling"); + return; + } + PASS(); +} + +/* Absent has to mean absent both ways, because expect_reachable requires both. + * An ENOENT from open alone would also be satisfied by a name the listing still + * advertises, which is the worse failure of the two: a guest that reads the + * directory is then told about a file it cannot open. + */ +static void expect_absent(const char *label, const char *name) +{ + char path[PATH_MAX]; + + snprintf(path, sizeof(path), "%s/%s", DIR_S, name); + TEST(label); + if (listed(name)) { + FAIL("still advertised by the listing"); + return; + } + EXPECT_ERRNO(open(path, O_RDONLY), ENOENT, "should not resolve"); +} + +/* A name whose slot is taken by a differently-spelled sibling is absent to + * Linux, and must stay inside the sysroot rather than falling through to the + * host filesystem. The sysroot holds the other spelling and the host holds a + * real file at the very path the fallback would reach, so a leak reads as that + * file's contents instead of ENOENT. + * + * Run against two guest paths, because a path that misses in the sysroot takes + * one of two very different exits: paths under /tmp, /var/tmp and ccache are + * forced back into the sysroot, and everything else falls through to the host. + * Only the second can write outside the sysroot, and only the first can write + * into the folded entry, so one path exercises one branch and proves nothing + * about the other. The guard under test sits above the split; running both is + * what keeps a later change from sliding it below. + * + * @guest_path comes from argv because the recipe has to pick a name unique to + * the run before it can stage the host side of it. The fall-through path has to + * be entirely lowercase so every component above the folded one resolves + * exactly, and must avoid both the redirect list and the guest system + * directories; /private/tmp is the one macOS location that is all three. + */ +static void section_folded_stays_inside(const char *guest_path, + const char *which) +{ + char child[PATH_MAX]; + char label[128]; + int fd; + + /* Visible, so a manual run without the recipe's fixture paths reads as + * fewer tests run, not as the section passing. + */ + if (!guest_path || !guest_path[0]) { + printf(" (folded-name section skipped: no %s path given)\n", which); + return; + } + + snprintf(label, sizeof(label), "%s: a folded name does not reach the host", + which); + TEST(label); + snprintf(child, sizeof(child), "%s/planted", guest_path); + fd = open(child, O_RDONLY); + if (fd >= 0) { + close(fd); + FAIL("a wrong-case lookup reached the host filesystem"); + } else { + EXPECT_ERRNO(fd, ENOENT, "should be ENOENT, not the host file"); + } + + /* Creating below the same folded component must fail the same way. Lookup + * and create are answered by separate resolvers, so closing the hole for + * one does not close it for the other, and a create does damage a lookup + * does not: it writes. + */ + snprintf(label, sizeof(label), + "%s: a create below a folded component fails", which); + TEST(label); + snprintf(child, sizeof(child), "%s/created", guest_path); + fd = open(child, O_CREAT | O_WRONLY, 0644); + if (fd >= 0) { + close(fd); + FAIL("a create landed under a folded component"); + } else { + EXPECT_ERRNO(fd, ENOENT, "the named parent does not exist"); + } +} + +int main(int argc, char **argv) +{ + /* The control: an ordinary host-staged name, mixed case, kept under its + * real spelling. This is what makes a rootfs unpacked from a tarball + * reachable at all. + */ + expect_reachable("plain host-staged name", "Plain.Host", "plain"); + + /* A well-formed escape staged by the host decodes like any other, so the + * guest sees the name it stands for. + */ + expect_reachable("staged escape decodes", "FOO", "escaped-foo"); + + /* And the on-disk spelling is not itself a guest name: asking for it names + * something else entirely, which is absent. + */ + expect_absent("the on-disk spelling is not a guest name", ".ef=464f4f"); + + /* Shapes that only resemble an escape mean themselves. Each is staged + * host-side under exactly these bytes. + */ + expect_reachable("uppercase hex is not an escape", ".ef=5A5A", + "literal-upper"); + expect_reachable("odd digit count is not an escape", ".ef=464f4", + "literal-odd"); + expect_reachable("non-hex payload is not an escape", ".ef=zzzz", + "literal-nonhex"); + expect_reachable("a payload decoding to a slash is not an escape", ".ef=2f", + "literal-slash"); + expect_reachable("a payload decoding to dotdot is not an escape", + ".ef=2e2e", "literal-dotdot"); + expect_reachable("the bare prefix is not an escape", + ".ef=", "literal-bare"); + expect_reachable("a different separator is not an escape", ".ef_464f4f", + "literal-legacy"); + + /* Both spellings of one name staged together. Only something outside + * elfuse can produce this, and the rule is that a lookup takes the literal + * spelling: the escaped one is then unreachable under any guest name. The + * listing reports the name twice, which is the visible cost of letting a + * host tool write a tree elfuse also manages. + */ + expect_reachable("literal wins over an escape of the same name", "Bar", + "literal-bar"); + + section_folded_stays_inside(argc > 1 ? argv[1] : NULL, "redirected"); + section_folded_stays_inside(argc > 2 ? argv[2] : NULL, "host-fallback"); + + SUMMARY("test-sysroot-name-staged"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-outside-names.c b/tests/test-sysroot-outside-names.c new file mode 100644 index 00000000..5a2feec5 --- /dev/null +++ b/tests/test-sysroot-outside-names.c @@ -0,0 +1,132 @@ +/* + * Escape-shaped names are literal in directories the sysroot does not own + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The escape encoding is scoped to the sysroot: only there did elfuse choose + * the stored spelling, so only there may a listing decode one. A host + * directory reached through the fallback holds names elfuse never wrote. A + * file named ".ef=464f4f" in such a directory is a file named ".ef=464f4f" + * and nothing else; decoding it would report a name the directory does not + * contain and that no open() can resolve, while hiding the entry's real name + * behind it. Lookups already treat these directories literally, so a decoding + * listing also disagrees with the resolver about what the directory holds. + * + * Code under test: path_translate_dirent_name and its per-directory scoping + * in src/syscall/path.c, reached from the getdents64 loop in + * src/syscall/fs.c via the host fallback in proc_resolve_sysroot_path_flags. + * A regression shows up as ls of a host directory inventing a name no open() + * resolves, and as two real entries collapsing into one listed name. + * + * argv[1] is a host directory staged by the make recipe, outside the sysroot + * the recipe also creates. Run under --sysroot on a case-folding volume; the + * recipe asserts host-side that the control file the guest creates inside + * the sysroot was stored escaped, so a pass on a byte-exact volume cannot be + * vacuous. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +/* The escape of "FOO" on a folding sysroot. Here it must mean itself. */ +#define ESCAPE_SHAPED ".ef=464f4f" +#define DECODES_TO "FOO" + +/* dir_contains answers membership only; the collapse regression needs the + * count, because the decoded spelling of one entry equals the literal + * spelling of another. + */ +static int dir_count_name(const char *dir, const char *name) +{ + DIR *d = opendir(dir); + struct dirent *de; + int n = 0; + + if (!d) + return -1; + while ((de = readdir(d))) + if (!strcmp(de->d_name, name)) + n++; + closedir(d); + return n; +} + +int main(int argc, char **argv) +{ + char path[PATH_MAX]; + int fd; + + if (argc < 2) { + printf("test-sysroot-outside-names: no directory given\n"); + return 1; + } + + printf("test-sysroot-outside-names: literal names outside the sysroot\n"); + + TEST("an escape-shaped name outside the sysroot lists as its own bytes"); + EXPECT_TRUE(dir_contains(argv[1], ESCAPE_SHAPED), + "should appear as written"); + + /* The decisive one: if the listing decoded the name, the guest was shown + * DECODES_TO, which no directory entry matches. + */ + TEST("the listing does not invent the decoded name"); + EXPECT_TRUE(!dir_contains(argv[1], DECODES_TO), + "nothing on disk has that name"); + + TEST("the name the listing reported can be opened"); + snprintf(path, sizeof(path), "%s/%s", argv[1], ESCAPE_SHAPED); + fd = open(path, O_RDONLY); + EXPECT_TRUE(fd >= 0, "the listed name must resolve"); + if (fd >= 0) + close(fd); + + TEST("the decoded name resolves to nothing"); + snprintf(path, sizeof(path), "%s/%s", argv[1], DECODES_TO); + EXPECT_ERRNO(open(path, O_RDONLY), ENOENT, "should not exist"); + + /* Creates outside the sysroot are literal too, so after this the + * directory really holds both spellings. A decoding listing folds them + * into two entries named DECODES_TO: the file just written plus the + * decode of the staged escape, with the escape's own bytes gone. + */ + TEST("a created literal name does not collapse with the escape"); + fd = open(path, O_CREAT | O_WRONLY, 0644); + if (fd < 0) { + FAIL("create"); + } else { + close(fd); + EXPECT_TRUE(dir_count_name(argv[1], DECODES_TO) == 1 && + dir_count_name(argv[1], ESCAPE_SHAPED) == 1, + "each spelling must list exactly once"); + unlink(path); + } + + /* Host-side control for the recipe: stored escaped only if the volume + * folds and the sysroot scope still decodes, proving the assertions + * above did not pass merely because nothing was escaping anywhere. + */ + TEST("a control name inside the sysroot still escapes"); + fd = open("/Ctrl", O_CREAT | O_WRONLY, 0644); + EXPECT_TRUE(fd >= 0, "create /Ctrl in the sysroot"); + if (fd >= 0) { + close(fd); + EXPECT_TRUE(dir_contains("/", "Ctrl"), + "the sysroot listing still decodes"); + } + + SUMMARY("test-sysroot-outside-names"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-pathmax.c b/tests/test-sysroot-pathmax.c new file mode 100644 index 00000000..3fbcfc91 --- /dev/null +++ b/tests/test-sysroot-pathmax.c @@ -0,0 +1,227 @@ +/* + * Guest paths at the host path ceiling + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Linux allows a path of 4096 bytes; macOS stops at 1024 (PATH_MAX, + * sys/syslimits.h), and an escaped component roughly doubles on disk, so a + * guest path the kernel it emulates would accept can exceed what the host + * kernel will take. The documented policy (docs/filenames.md, "Whole paths") + * is that such a path reports ENAMETOOLONG and is never truncated, because a + * truncated path names a different file. This pins the guest-visible + * consequences of that policy at the boundary. + * + * Two lanes: literal components (host length ~ guest length + sysroot + * prefix, so the ceiling is crossed on any volume) and escaped components + * (guest path well under 1024; only the escape expansion crosses, so this + * lane exercises the case-exact walk specifically). Both assert the same + * contract: every level either works fully (create, stat, list back) + * or fails with exactly ENAMETOOLONG, monotonically once the ceiling is + * crossed, with no level truncating into a sibling that a listing would + * expose. + * + * Code under test: the accumulated-path checks in + * src/syscall/casefold-walk.c and the translation exits in + * src/syscall/path.c. A regression shows up as a create succeeding past the + * ceiling under a spelling other than its own (truncation), as a wrong + * errno, or as a success-after-failure flip while descending. + * + * On a byte-exact volume the escaped lane stores names literally and stays + * short of the ceiling; its probes then assert plain success, so the test + * passes on both volume kinds. Run under --sysroot. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +/* One directory level per step, sized so the guest path stays far inside the + * Linux limit while the host path crosses 1024 after a few dozen levels. + */ +#define STEP_LEN 40 +#define MAX_DEPTH 60 + +/* The escaped lane doubles on disk: 30 guest bytes become ~64 host bytes per + * level, so the host ceiling arrives near depth 16 while the guest path is + * still under 600 bytes. + */ +#define ESC_STEP_LEN 30 +#define ESC_MAX_DEPTH 24 + +static void step_name(char *out, size_t outsz, int depth, bool escaped) +{ + /* A distinct leading digit per level keeps every component unique, so a + * truncated path cannot happen to name an existing shallower entry. + */ + int n = snprintf(out, outsz, "%c%02d", escaped ? 'D' : 'd', depth % 100); + memset(out + n, escaped ? 'A' : 'a', + (size_t) ((escaped ? ESC_STEP_LEN : STEP_LEN) - n)); + out[escaped ? ESC_STEP_LEN : STEP_LEN] = '\0'; +} + +/* The recipe probes the volume host-side and passes "fold" or "exact": a + * guest cannot tell the volume kinds apart (hiding that difference is what + * the escape is for), but whether the escaped lane must cross the ceiling + * depends on it, and an expectation that accepts both outcomes on a folding + * volume would let the interesting lane pass vacuously. + */ +static bool volume_folds; + +/* Descend one level at a time under @root, requiring each mkdir to either + * succeed completely or fail with ENAMETOOLONG, and never to succeed again + * once a level has failed. Returns the deepest path that succeeded in @path. + */ +static void descend(const char *root, bool escaped) +{ + char path[8192]; + char deepest[8192]; + char name[STEP_LEN + 1]; + bool hit_ceiling = false; + int depth_limit = escaped ? ESC_MAX_DEPTH : MAX_DEPTH; + + snprintf(path, sizeof(path), "%s", root); + snprintf(deepest, sizeof(deepest), "%s", root); + + TEST(escaped ? "escaped descent is clean at the ceiling" + : "literal descent is clean at the ceiling"); + for (int depth = 0; depth < depth_limit; depth++) { + size_t len = strlen(path); + + step_name(name, sizeof(name), depth, escaped); + snprintf(path + len, sizeof(path) - len, "/%s", name); + + if (mkdir(path, 0755) == 0) { + if (hit_ceiling) { + FAIL( + "mkdir succeeded past a level that reported " + "ENAMETOOLONG"); + return; + } + snprintf(deepest, sizeof(deepest), "%s", path); + continue; + } + if (errno != ENAMETOOLONG) { + FAIL("mkdir failed with an errno other than ENAMETOOLONG"); + return; + } + hit_ceiling = true; + /* The failed level must not exist under any spelling: a truncated + * create would leave an entry the parent listing exposes. + */ + path[len] = '\0'; + } + PASS(); + + TEST(escaped ? "escaped ceiling is where the math says" + : "literal ceiling was reached"); + if (escaped && !hit_ceiling) { + /* A byte-exact volume stores the escaped lane literally, and + * ESC_MAX_DEPTH * (ESC_STEP_LEN + 1) stays under 1024 there; not + * crossing is the correct outcome on such a volume, and the only + * legal one on a folding volume is crossing. + */ + struct stat st; + if (volume_folds) + FAIL("escape expansion never crossed the host ceiling"); + else if (stat(deepest, &st) == 0 && S_ISDIR(st.st_mode)) + PASS(); + else + FAIL("deepest directory did not stat back"); + } else if (!escaped && !hit_ceiling) { + FAIL("literal descent never crossed the host ceiling"); + } else { + PASS(); + } + + TEST(escaped ? "escaped deepest level lists exactly one child" + : "literal deepest level lists exactly one child"); + { + /* The deepest surviving directory holds at most the single child the + * next (failed) level would have created, which is none. Any entry + * here is a truncated spelling of a deeper create. + */ + DIR *d = opendir(deepest); + struct dirent *de; + int extras = 0; + + if (!d) { + FAIL("deepest directory did not open"); + } else { + while ((de = readdir(d))) { + if (strcmp(de->d_name, ".") && strcmp(de->d_name, "..")) + extras++; + } + closedir(d); + if (extras) + FAIL("a failed level left an entry behind"); + else + PASS(); + } + } + + TEST(escaped ? "escaped create past the ceiling reports ENAMETOOLONG" + : "literal create past the ceiling reports ENAMETOOLONG"); + { + size_t len = strlen(deepest); + char file[8192]; + int fd; + + snprintf(file, sizeof(file), "%s", deepest); + step_name(name, sizeof(name), depth_limit, escaped); + snprintf(file + len, sizeof(file) - len, "/%s", name); + /* Fill the remaining guest budget so the host spelling is over the + * ceiling whichever lane this is. + */ + for (int i = 0; i < 3; i++) { + size_t flen = strlen(file); + step_name(name, sizeof(name), depth_limit + 1 + i, escaped); + snprintf(file + flen, sizeof(file) - flen, "/%s", name); + } + fd = open(file, O_CREAT | O_WRONLY, 0644); + if (fd >= 0) { + close(fd); + FAIL("open(O_CREAT) succeeded on a path past the ceiling"); + } else if (errno == ENAMETOOLONG || errno == ENOENT) { + /* ENOENT is legal when an intermediate level is the one past + * the ceiling on a byte-exact volume: the parent chain stops + * existing before the name gets too long. + */ + PASS(); + } else { + FAIL("wrong errno for a create past the ceiling"); + } + } +} + +int main(int argc, char **argv) +{ + volume_folds = argc > 1 && !strcmp(argv[1], "fold"); + + if (mkdir("/pathmax-lit", 0755) < 0 && errno != EEXIST) { + FAIL("setup: mkdir /pathmax-lit"); + SUMMARY("test-sysroot-pathmax"); + return 1; + } + if (mkdir("/pathmax-esc", 0755) < 0 && errno != EEXIST) { + FAIL("setup: mkdir /pathmax-esc"); + SUMMARY("test-sysroot-pathmax"); + return 1; + } + + descend("/pathmax-lit", false); + descend("/pathmax-esc", true); + + SUMMARY("test-sysroot-pathmax"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-root.c b/tests/test-sysroot-root.c new file mode 100644 index 00000000..622704ad --- /dev/null +++ b/tests/test-sysroot-root.c @@ -0,0 +1,81 @@ +/* + * A sysroot at the filesystem root + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * "--sysroot /" is degenerate but legal, and it is the one configuration where + * the host prefix is a single separator. Path arithmetic that assumes the + * prefix is longer than that produces an empty parent instead of the root, and + * an empty path fails every containment check, so a create directly below the + * root reports ELOOP, a diagnosis about symlinks for a path containing none. + * + * Code under test: proc_resolve_sysroot_create_path and + * proc_resolve_sysroot_path_flags in src/syscall/proc-state.c: the parent + * split off the walk's recorded offsets, and the all-slash guard the + * containment check needs. A regression shows up as ELOOP where the host's + * own answer should come through, which sends a caller looking for a link + * loop that does not exist. + * + * Nothing here writes: the macOS root is read-only, and what is asserted is + * that the guest is told so rather than being told something untrue. Run under + * --sysroot /. + */ + +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +#define UNWRITABLE "/elfuse-sysroot-root-probe" + +int main(void) +{ + struct stat st; + int fd; + + printf("test-sysroot-root: sysroot at the filesystem root\n"); + + /* The root is read-only on macOS, so the create must fail, but with the + * host's reason. ELOOP would mean the path arithmetic broke before the + * kernel ever saw the request. + */ + TEST("a create below the root reports the host's own error"); + fd = open(UNWRITABLE, O_CREAT | O_WRONLY, 0644); + if (fd >= 0) { + close(fd); + unlink(UNWRITABLE); + PASS(); + } else { + EXPECT_TRUE(errno != ELOOP, "ELOOP for a path with no symlink in it"); + } + + TEST("a lookup below the root still resolves"); + EXPECT_TRUE(stat("/etc/hosts", &st) == 0, "stat /etc/hosts"); + + TEST("the root itself resolves"); + EXPECT_TRUE(stat("/", &st) == 0 && S_ISDIR(st.st_mode), "stat /"); + + /* The nofollow spelling of the same question. The lookup resolver's + * containment check splits a parent off the resolved path, and with the + * one-character prefix that parent is the root itself; treating the shape + * as impossible reported ELOOP for lstat("/"), which no Linux kernel can + * produce: "/" is a directory, and nofollow only changes the answer for + * a symlink. + */ + TEST("the root itself resolves without following"); + EXPECT_TRUE(lstat("/", &st) == 0 && S_ISDIR(st.st_mode), "lstat /"); + + TEST("fstatat nofollow agrees"); + EXPECT_TRUE(fstatat(AT_FDCWD, "/", &st, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISDIR(st.st_mode), + "fstatat AT_SYMLINK_NOFOLLOW /"); + + SUMMARY("test-sysroot-root"); + return fails > 0 ? 1 : 0; +} From 9271b525775fe5617c6d64d50e48e19fd29155da Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Sat, 25 Jul 2026 22:36:47 +0200 Subject: [PATCH 05/11] Document how a guest filename reaches the disk 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. --- README.md | 6 ++ docs/filenames.md | 239 +++++++++++++++++++++++++++++++++++++++++++++- docs/internals.md | 18 ++-- 3 files changed, 253 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 99bc6a2d..a7cb827f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ linker resolved against an external sysroot via `--sysroot`. signals, timers, futexes (incl. PI ops), and polling - Guest reads and writes the macOS filesystem directly; no overlay or volume mount layer +- Linux byte-exact filename semantics under `--sysroot`, including + case-colliding names on the default case-folding APFS (see + [docs/filenames.md](docs/filenames.md)) - Synthetic `/proc` and selected `/dev` emulation for user-space probes - Guest-internal FUSE: `/dev/fuse` and `mount("fuse")` work without macFUSE / FUSE-T / FSKit @@ -109,6 +112,9 @@ The build signs `build/elfuse` before use. Override the signing identity with - [docs/testing.md](docs/testing.md): build prerequisites, the `make check` flow, the QEMU and Rosetta cross-check matrices, and fixture handling. +- [docs/filenames.md](docs/filenames.md): how a guest filename becomes a + name on disk and back: case folding and normalization on the sysroot + volume, the escape encoding, and the length limits both systems impose. - [docs/internals.md](docs/internals.md): canonical technical reference -- runtime lifecycle, HVF constraints, EL1 shim and HVC protocol, page-table splitting, syscall translation tables, threads diff --git a/docs/filenames.md b/docs/filenames.md index 113ddea1..ba9dfc79 100644 --- a/docs/filenames.md +++ b/docs/filenames.md @@ -31,9 +31,33 @@ The first is answered by asking the volume for a name's stored spelling and comparing bytes. The second needs one of the two names to be stored on disk under a different spelling, and that is what the rest of this document is about. -A volume that compares byte-exactly (one made by `--create-sysroot`, or any -volume that probes case-sensitive) needs neither, and nothing here applies to -it. +A volume that probes case-sensitive (one made by `--create-sysroot`) needs +neither for case, and the escape is inactive there. It is not byte-exact, +though: no APFS variant is. Case-sensitive APFS still matches names +canonical-normalization-blind and refuses names that are not well-formed +UTF-8, and with the escape off those two divergences reach the guest. Thirty +seconds on any Mac reproduce both: + +```sh +dmg=$(mktemp -u).dmg +hdiutil create -size 8m -fs "Case-sensitive APFS" -volname probe -quiet "$dmg" +mnt=$(hdiutil attach "$dmg" -nobrowse | awk '/\/Volumes\//{print $NF}') +cd "$mnt" +printf 'nfc\n' > "caf$(printf '\303\251')" # U+00E9, composed +printf 'nfd\n' > "cafe$(printf '\314\201')" # e + U+0301, decomposed +ls | cat # one entry: the second create hit the +cat "caf$(printf '\303\251')" # first file: prints "nfd", data clobbered +printf 'x\n' > "bad$(printf '\377')" # refused: illegal byte sequence +``` + +So on a `--create-sysroot` sysroot, two guest names that differ only in +canonical normalization alias each other (a write to one lands in the other), +and a create whose name is not valid UTF-8 fails with `EILSEQ` where Linux +succeeds. These are accepted divergences of that configuration, pinned exactly +by `tests/test-sysroot-name-i18n.c` in its `csapfs` mode so a change in either +direction is deliberate. A folding volume has neither divergence: the escape +below handles normalization and ill-formed names along with case. Everything +else in this document concerns that folding case. ## What the folding table actually does @@ -118,6 +142,205 @@ A name that is not a well-formed escape means itself. `.ef=464F4F` (uppercase hex), `.ef=464f4` (odd length), `.ef=2f` (decodes to `/`) and `.ef=` (empty) are all ordinary files. +## Resolving a path + +A guest path is resolved one component at a time, and each component is decided +by asking the volume for the name as stored and comparing bytes. A plain `stat` +cannot answer the question: it reports success for a spelling that is not what +is stored, while Linux resolution is byte-exact and owes `ENOENT` for that. + +For each component, given the parent already spelled: + +| The volume says | The component is | +|---|---| +| an entry exists, spelled as asked | the literal name | +| an entry exists, spelled differently | the escape, whose slot is therefore free | +| it will not hold this name at all | the escape | +| nothing is there | the escape if that exists, otherwise the name's own rule | + +A component whose literal slot is taken by a different spelling resolves to its +escape *even though nothing is there yet*. That name provably does not exist and +cannot fold onto the sibling occupying the slot, so the caller's own syscall +returns Linux's `ENOENT` for a wrong-case lookup with no separate rejection path +back into the resolver. Once a component is absent nothing below it can be +probed, and nothing needs to be, because escaping depends only on the name. + +The probe takes a path rather than a directory descriptor, so the walk builds +the host spelling as a string and opens nothing at all. It cannot probe the +whole path at once: the volume validates only the last component, so a +wrong-case parent folds away silently and every prefix has to be asked about +separately. + +A path is resolved this way only when the sysroot volume folds case. On a +byte-exact volume the guest spelling is the host spelling, and one concatenation +and one existence probe answer both questions. + +## Which volume, and which side of the sysroot + +Everything above describes what happens when the sysroot volume folds case. Two +independent facts decide whether any of it runs for a given name: what the +sysroot volume does with case, and whether the path lands inside the sysroot at +all. + +### The volume decides whether escaping happens at all + +At startup elfuse asks the sysroot's volume how it treats case +(`sysroot_probe_case_sensitivity`, `src/core/sysroot.c:320`), preferring +`pathconf(_PC_CASE_SENSITIVE)` and falling back to `getattrlist` with +`ATTR_VOL_CAPABILITIES`. Escaping is enabled only for a volume that preserves +case but does not distinguish it (`src/main.c:627`): only, that is, when the +volume would otherwise merge two names Linux keeps apart. + +| Sysroot volume | Escaping | On-disk names | +|---|---|---| +| default APFS, which folds | on | escaped wherever the name is not fold-stable | +| case-sensitive APFS, such as a sparsebundle | off | the guest's own bytes | +| the probe fails | off | the guest's own bytes | + +A **sparsebundle** is a disk image that grows on demand, and macOS can format +one case-sensitive even when the boot volume is not. Pointing the sysroot at one +turns the codec off completely: `casefold_active` +(`src/syscall/casefold-walk.c:30`) is false, no walk runs, and resolution is one +`snprintf` plus one existence probe. The volume already behaves the way Linux +does, so there is nothing to work around, and `ls` inside the sysroot shows the +guest's names exactly as the guest wrote them. + +```sh +hdiutil create -size 20g -fs "Case-sensitive APFS" -type SPARSEBUNDLE \ + -volname elfuse-root elfuse-root.sparsebundle +hdiutil attach elfuse-root.sparsebundle +elfuse --sysroot /Volumes/elfuse-root ./program +``` + +### The sysroot boundary decides whose rules apply + +Inside the sysroot elfuse owns the tree and can promise Linux naming. Outside it +the guest is looking at the real macOS filesystem, whose files elfuse did not +create and must not rename. A path that misses inside the sysroot falls through +to the host under its own spelling, which is what lets a guest read the user's +own files. + +``` +guest says /usr/lib/libc.so /Users/henry/project/README + | | + inside the sysroot? yes no + | | + /Volumes/elfuse-root/usr/lib/libc.so /Users/henry/project/README + case-sensitive sparsebundle boot volume, folds case + elfuse guarantees Linux naming macOS rules apply unchanged +``` + +Two exceptions keep that fall-through from doing damage. Guest system +directories (`/usr`, `/bin`, `/etc`, `/lib`, ...) never fall through, because +resolving them against macOS would read the host's own system files or fail on +SIP (`is_guest_system_path`, `src/syscall/proc-state.c:578`). And `/tmp`, +`/var/tmp` and ccache +directories are forced back into the sysroot even when absent, so a build that +creates case-colliding temporaries gets Linux semantics rather than the host's. + +### A worked example + +Sysroot on a case-sensitive sparsebundle at `/Volumes/elfuse-root`. The user's +own files are at `/Users/henry/project` on the ordinary boot volume, which folds +case. Note that the uppercase in `/Users` never matters to the sysroot: it is +part of a host path, and the sysroot spells names the guest's way. + +| The guest does | Where it lands | What happens | +|---|---|---| +| `open("/usr/lib/libc.so")` | `/Volumes/elfuse-root/usr/lib/libc.so` | byte-exact match on the sparsebundle | +| `open("/data/Foo", O_CREAT)` then `open("/data/foo", O_CREAT)` | two entries in the sysroot | two distinct files, spelled literally, as on Linux | +| `open("/usr/lib/LIBC.so")` | `/Volumes/elfuse-root/usr/lib/LIBC.so` | `ENOENT`; the volume distinguishes case, and a guest system path never falls through | +| `open("/Users/henry/project/README")` | `/Users/henry/project/README` | the real host file, read through macOS | +| `open("/Users/henry/project/Out.txt", O_CREAT)` | `/Users/henry/project/Out.txt` | created on the boot volume, spelled as asked | + +The last two rows carry a limitation worth stating plainly. On the host side the +boot volume still folds, so `Out.txt` and `out.txt` are one file there, and a +guest that creates both sees the second overwrite the first. Elfuse does not fix +the host filesystem, only the sysroot; a program that depends on case-distinct +names must keep them inside the sysroot, which is why the temporary directories +are redirected there. + +On a default, folding sysroot the same five rows behave the same way from the +guest's side. The difference is only on disk. Running one workload that creates +four case-colliding names against each kind of sysroot shows it directly: + +``` +case-sensitive sparsebundle default folding APFS + Contended .ef=436f6e74656e646564 = Contended + race race + rAcE .ef=72416345 = rAcE + Race .ef=52616365 = Race + RACE .ef=52414345 = RACE +``` + +The guest sees the same five files either way. On the left the volume keeps them +apart on its own, so every name is stored as itself. On the right only `race` is +fold-stable and the rest are escaped, and the listing the guest reads is decoded +back on the way out. Escaped names begin with a dot, so `ls` hides them unless +asked with `-A`. + +## One representation per name + +A guest name is reachable through exactly one on-disk entry. The rule that gets +there is the last row of the table above: an absent component prefers an +existing escape before falling back to its own spelling, so a lookup and a +create can never settle on different entries for the same name. + +If both spellings are present (which only something outside elfuse can +arrange, since elfuse writes one or the other), a lookup takes the literal one. +The escaped entry is then unreachable under any guest name, though a listing +still reports the name twice. + +## Why nothing is locked + +Which spelling a guest name takes is decided by the name, so a create is one +`openat`, a rename is one `renameat`, and an unlink is one `unlinkat`. Each is a +single kernel operation, which makes it atomic with no help from elfuse: there +is no second object recording what a name means, so there is no window in which +a file exists under neither its old name nor its new one, nothing to roll back +when one of two writes fails, and nothing for two processes sharing a sysroot to +coordinate over. + +Resolution is not quite a pure function of the name, and the exception is worth +being exact about. Two rows of the table above consult the directory: a name is +stored literally when the volume already holds it under that exact spelling, and +an absent name prefers an existing escape over its own rule. Both matter only +for a fold-stable name whose escape something outside elfuse staged, because for +every name elfuse itself writes the two branches name the same entry. The +concurrency argument is unaffected either way: two *different* colliding names +resolve to two different entries whatever the directory holds, and two processes +creating the *same* name race for one entry exactly as they would on Linux. + +Two processes creating names that collide are writing *different* names, because +which spelling a name takes is decided by the name and not by what the directory +already holds. Which of two colliding names ends up in the literal slot follows +arrival order and is not part of the contract. + +## Symlink targets + +A symlink stores the bytes the guest gave it, and nothing rewrites them. That is +required: `readlink` has to return what was written, so a target cannot be +translated on the way in. + +The consequence is that a target naming something stored escaped cannot be +followed by the host. `symlink("/d/Foo", "/d/link")` records `/d/Foo`, while the +directory holds `.ef=466f6f`, so when the host kernel resolves the link it looks +for a name that is not there. Following the link fails with `ENOENT` even though +both files exist and `readlink` reports the target correctly. + +Two cases are unaffected, and between them cover most real trees: + +- a target whose every component is fold-stable, which is stored under its own + spelling and so resolves normally +- any operation that does not follow the link: `lstat`, `readlink`, `unlink`, + `rename`, and `linkat` without `AT_SYMLINK_FOLLOW` + +An absolute target is the worst case, because the host resolves it from the host +root rather than from the sysroot, so it misses on two counts at once. + +Closing this means resolving link targets through the same walk that resolves +guest paths, and doing it for every following operation rather than one of them. + ## Name length Both platforms limit a name, but they do not count the same thing, and that @@ -209,3 +432,13 @@ The facts the design actually depends on are asserted separately by changes them: that the payload alphabet cannot fold, that everything the encoder emits can be created, and that the per-name budget is counted in UTF-16 units. That test also takes a directory, so it can be pointed at another volume. + +The escape itself is frozen in `tests/casefold-vectors.h`: a table pairing +guest names with the exact bytes stored for them, asserted in both directions +by the same test and staged host-side for `make test-sysroot-corpus` to read +back through a live sysroot. Those literals are the on-disk format. Every +sysroot ever written holds names spelled that way, so a row may change only +as part of a deliberate format migration, never to make a test pass. The +codec's other tests read their expectations back through the codec and stay +green across any self-consistent format change; the frozen table is the one +place such a change fails. diff --git a/docs/internals.md b/docs/internals.md index a8e6ea5f..57008f36 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -870,12 +870,14 @@ How it works: `AT_EXECFN` (`argv[0]`) in the auxiliary vector. 4. The entry point becomes `interp_entry + load_base`; the dynamic linker takes over from there. -5. `sys_openat()` redirects guest absolute paths through the sysroot: if - `--sysroot` is set, it tries `/` first, and falls back to - the literal host path when the sysroot does not hold it. The temp roots - (`/tmp`, `/var/tmp`, any `.ccache` directory) and the guest system - directories are excluded from that fallback and resolve in the sysroot - either way, so lookup and removal cannot disagree about where a path lives. +5. Guest absolute paths reach the host through `path_translate_at()` + (`src/syscall/path.c`), the single forward resolver every path-taking + handler uses; with `--sysroot` set it dispatches each path between the + sysroot and the host on existence. The temp roots (`/tmp`, `/var/tmp`, any + `.ccache` directory) and the guest system directories are exempt from that + dispatch and resolve in the sysroot either way, so lookup and removal cannot + disagree about where a path lives. [filenames.md](filenames.md) covers how + a name is spelled once it lands on the sysroot volume. The sysroot is inherited by fork children via IPC state transfer. `sys_execve` also loads the interpreter for dynamically linked targets, so @@ -885,7 +887,9 @@ correctly. `elf_resolve_interp()` in `src/core/elf.c` is shared between ### Known Limitations -None currently tracked for the aarch64-linux dynamic-linker path. +None specific to the aarch64-linux dynamic-linker path. Limitations in guest +path handling are covered by [filenames.md](filenames.md), which records what +the sysroot volume can and cannot represent. ## x86_64-via-Apple-Rosetta From caa739623678b0284125f37729e27251feea2bbc Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Mon, 27 Jul 2026 00:58:53 +0200 Subject: [PATCH 06/11] Follow symlink targets through the guest namespace A relative symlink target records the bytes the guest wrote: readlink(2) reports the stored target, so translating one on the way in would surface in every readback. 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. On disk an absolute target is stored relative to the link's own directory, so a native follow stays inside the sysroot and the tree survives being moved; readlink consequently reports the rewritten spelling, the one visible divergence, since nothing on disk tells a rewritten target from a relative one the guest wrote. 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. --- docs/filenames.md | 74 ++++-- mk/tests.mk | 21 ++ src/syscall/casefold-walk.c | 74 +++++- src/syscall/casefold-walk.h | 16 ++ src/syscall/path.c | 167 ++++++++++-- src/syscall/path.h | 23 ++ src/syscall/proc-state.c | 146 ++++++++++- tests/test-case-collision.c | 17 +- tests/test-casefold-walk-host.c | 40 ++- tests/test-sysroot-symlink-target.c | 380 ++++++++++++++++++++++++++++ 10 files changed, 880 insertions(+), 78 deletions(-) create mode 100644 tests/test-sysroot-symlink-target.c diff --git a/docs/filenames.md b/docs/filenames.md index ba9dfc79..e4e8534e 100644 --- a/docs/filenames.md +++ b/docs/filenames.md @@ -318,28 +318,58 @@ arrival order and is not part of the contract. ## Symlink targets -A symlink stores the bytes the guest gave it, and nothing rewrites them. That is -required: `readlink` has to return what was written, so a target cannot be -translated on the way in. - -The consequence is that a target naming something stored escaped cannot be -followed by the host. `symlink("/d/Foo", "/d/link")` records `/d/Foo`, while the -directory holds `.ef=466f6f`, so when the host kernel resolves the link it looks -for a name that is not there. Following the link fails with `ENOENT` even though -both files exist and `readlink` reports the target correctly. - -Two cases are unaffected, and between them cover most real trees: - -- a target whose every component is fold-stable, which is stored under its own - spelling and so resolves normally -- any operation that does not follow the link: `lstat`, `readlink`, `unlink`, - `rename`, and `linkat` without `AT_SYMLINK_FOLLOW` - -An absolute target is the worst case, because the host resolves it from the host -root rather than from the sysroot, so it misses on two counts at once. - -Closing this means resolving link targets through the same walk that resolves -guest paths, and doing it for every following operation rather than one of them. +A relative target stores the bytes the guest gave it, and nothing rewrites +them: `readlink` returns what was written. An absolute target cannot be stored +verbatim, because anything following the link natively resolves it from the +host root rather than the sysroot. Creation therefore rewrites it to a target +relative to the link's own directory, which names the same object inside the +sysroot and survives the tree being moved; `readlink` reports that rewritten +spelling, the one visible divergence, since nothing on disk tells a rewritten +target from a relative one the guest wrote. + +That leaves the stored bytes naming a *guest* path while the disk holds host +spellings, so following a link is done in the guest's namespace rather than by +handing the target to the host kernel. When the walk reaches a link it has to +pass through, it stops and says so; the resolver reads the target, joins a +relative one to the directory holding the link or lets an absolute one replace +the path outright, appends whatever was left, and resolves the result as an +ordinary guest path. Chains are followed the same way, bounded at +`MAXSYMLINKS` hops, `ELOOP` past that. + +Knowing a component is a link is free: the probe already asks the volume for +each component's stored name, and `ATTR_CMN_OBJTYPE` rides along on that same +request. + +Following is not atomic, and nothing here claims otherwise. The walk resolves by +path rather than by holding descriptors, so a component can be replaced between +being resolved and being used; adding link following lengthens that window +without changing its nature. The guarantee is over the operation, not the +resolution: the create, rename or unlink a caller finally issues is one kernel +call, so a name is never left half-moved. + +Which components are followed is POSIX's rule, not a choice: every intermediate +component is, and only the last one honors a caller's request not to. So +`lstat("/a/link/b")` follows `link` and reports on `b`. + +### A link may not leave the sysroot + +An absolute target resolves against the sysroot, exactly as the same path typed +by the guest would, which is what a chroot-like tree owes. It does not, +however, inherit the host fallback that a typed path gets. A path the guest +names itself may fall through to the host when the sysroot does not have it; a +path arrived at by following a link may not, because anything able to write a +symlink into the tree could otherwise hand the guest a file from outside it. + +A link whose target the sysroot does not hold therefore ends one of two ways: + +- the host has nothing there either, so the link simply dangles and the guest + gets `ENOENT` +- the host does have something there, which is the escape the rule exists to + stop, and the guest gets `ELOOP` + +Operations that do not follow are untouched throughout: `lstat`, `readlink`, +`unlink`, `rename`, and `linkat` without `AT_SYMLINK_FOLLOW` all keep seeing the +link itself, including when its target does not resolve. ## Name length diff --git a/mk/tests.mk b/mk/tests.mk index d64758fd..6fea280e 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -22,6 +22,7 @@ test-sysroot-name-relative \ test-nosysroot-literal-names test-sysroot-outside-names \ test-sysroot-root \ + test-sysroot-symlink-target \ test-sysroot-name-i18n test-sysroot-name-length \ test-sysroot-name-staged test-sysroot-name-race \ test-sysroot-pathmax test-sysroot-corpus \ @@ -178,6 +179,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-sysroot-case-exact @printf "\n$(BLUE)━━━ sysroot relative-dirfd symlink escape validation ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-symlink-escape + @printf "\n$(BLUE)━━━ escaped symlink-target resolution ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-symlink-target @printf "\n$(BLUE)━━━ sysroot mounted at / ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-root @printf "\n$(BLUE)━━━ literal names without a sysroot ━━━$(RESET)\n" @@ -447,6 +450,24 @@ test-sysroot-name-relative: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-relativ exit 1; \ fi +## Symlink targets are resolved in the guest namespace, not the host's +test-sysroot-symlink-target: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-symlink-target + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + had_target=0; [ -e "/symlink-target" ] && had_target=1; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-symlink-target; \ + if [ "$$had_target" = 0 ] && [ -e "/symlink-target" ]; then \ + printf "$(RED)FAIL$(RESET) a create through a link escaped to the host root\n"; \ + exit 1; \ + fi; \ + if [ -z "$$(find "$$tmpdir" -maxdepth 2 -name '.ef=*' -print -quit)" ]; then \ + printf "$(RED)FAIL$(RESET) no escaped entries in the sysroot\n"; \ + ls -Rb "$$tmpdir"; \ + exit 1; \ + fi + ## The degenerate sysroot: a one-character host prefix test-sysroot-root: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-root $(ELFUSE_BIN) --sysroot / $(BUILD_DIR)/test-sysroot-root diff --git a/src/syscall/casefold-walk.c b/src/syscall/casefold-walk.c index 27c1bc03..adcbbf7a 100644 --- a/src/syscall/casefold-walk.c +++ b/src/syscall/casefold-walk.c @@ -17,6 +17,8 @@ #include #include #include +/* fsobj_type_t and VLNK, for the ATTR_CMN_OBJTYPE the probe requests. */ +#include #include #include "utils.h" @@ -120,24 +122,52 @@ static probe_result_t probe_by_readdir(host_fd_t base_fd, */ static probe_result_t probe_exact(host_fd_t base_fd, const char *path, - const char *leaf) + const char *leaf, + bool *is_link) { + /* 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 + * this call exists for. FSOPT_NOFOLLOW below already asks about the entry + * rather than its target, so the answer is about the link itself. + */ struct attrlist al = { .bitmapcount = ATTR_BIT_MAP_COUNT, - .commonattr = ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_NAME, + .commonattr = + ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_OBJTYPE | ATTR_CMN_NAME, }; + /* Fixed-size attributes come back in ascending bit order, so ATTR_CMN_NAME + * (0x1) precedes ATTR_CMN_OBJTYPE (0x8) and the variable-length name data + * follows both. Ordering these fields any other way silently misreads every + * field after the first. Only attributes the volume actually returned are + * packed, so an absent one shifts every later field down by its width, + * which is why obj_type is read under the ATTR_CMN_NAME test below rather + * than beside it. + */ struct { u_int32_t length; attribute_set_t returned; attrreference_t name_ref; + fsobj_type_t obj_type; char name[CASEFOLD_STORED_NAME_MAX]; } __attribute__((aligned(4), packed)) attr_buf; + if (is_link) + *is_link = false; + if (getattrlistat(base_fd, path, &al, &attr_buf, sizeof(attr_buf), FSOPT_NOFOLLOW) == 0) { if (attr_buf.returned.commonattr & ATTR_CMN_NAME) { const char *stored = (const char *) &attr_buf.name_ref + attr_buf.name_ref.attr_dataoffset; + + /* Read here, not before the test: obj_type only sits at this + * offset because name_ref precedes it, and it does so only when + * the name was returned. With the name withheld the field would + * be read one attrreference_t past where the volume wrote it, and + * a garbage VLNK sends the walk chasing a link that is not there. + */ + if (is_link && (attr_buf.returned.commonattr & ATTR_CMN_OBJTYPE)) + *is_link = attr_buf.obj_type == VLNK; if (!strcmp(stored, leaf)) return PROBE_EXACT; /* A mismatch is not yet a fold. For a second hard link to a @@ -259,7 +289,8 @@ static probe_result_t resolve_component(host_fd_t base_fd, const char *guest, char *host, size_t hostsz, - bool *present) + bool *present, + bool *is_link) { char probe_path[LINUX_PATH_MAX]; size_t probe_len = len; @@ -281,7 +312,7 @@ static probe_result_t resolve_component(host_fd_t base_fd, guest) < 0) return PROBE_ERROR; - verdict = probe_exact(base_fd, probe_path, guest); + verdict = probe_exact(base_fd, probe_path, guest, is_link); if (verdict == PROBE_ERROR) return PROBE_ERROR; if (verdict == PROBE_EXACT) { @@ -324,7 +355,7 @@ static probe_result_t resolve_component(host_fd_t base_fd, if (append_component(probe_path, sizeof(probe_path), &probe_len, host) < 0) return PROBE_ERROR; - switch (probe_exact(base_fd, probe_path, host)) { + switch (probe_exact(base_fd, probe_path, host, is_link)) { case PROBE_EXACT: *present = true; return PROBE_EXACT; @@ -371,6 +402,8 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, walk->parent_found = true; walk->parent_offset = 0; + walk->link_rest_offset = 0; + walk->link_guest_offset = 0; walk->leaf_offset = 0; walk->folded = false; walk->notdir = false; @@ -407,11 +440,38 @@ casefold_verdict_t casefold_resolve_at(host_fd_t base_fd, if (name_by_rule(guest, host, sizeof(host)) < 0) return CASEFOLD_ERROR; } else { - probe_result_t verdict = resolve_component( - base_fd, out, len, guest, host, sizeof(host), &present); + bool is_link = false; + probe_result_t verdict = + resolve_component(base_fd, out, len, guest, host, sizeof(host), + &present, &is_link); if (verdict == PROBE_ERROR) return CASEFOLD_ERROR; + + /* A link the walk has to pass through stops it. That is every + * intermediate component, and the final one only when the caller + * asked to follow: path_resolution(7) applies nofollow to the last + * component alone. + * + * The host cannot be asked to follow it instead. A link records the + * bytes the guest wrote, and those name a guest path: a component + * of it may be stored escaped, and an absolute one starts at the + * sysroot rather than at the host root. Handing them to the kernel + * looks somewhere else entirely. + */ + if (present && is_link) { + const char *rest = scan; + + while (*rest == '/') + rest++; + if (*rest != '\0' || follow_final) { + if (append_leaf(out, outsz, &len, host, walk) < 0) + return CASEFOLD_ERROR; + walk->link_guest_offset = (size_t) (comp - guest_path); + walk->link_rest_offset = (size_t) (rest - guest_path); + return CASEFOLD_SYMLINK; + } + } /* A fold means the sysroot holds an entry where the guest asked, * under a spelling the guest did not use. Recorded for the caller * because it is the one absent verdict that must not fall through diff --git a/src/syscall/casefold-walk.h b/src/syscall/casefold-walk.h index 0e1dbb2f..720b088b 100644 --- a/src/syscall/casefold-walk.h +++ b/src/syscall/casefold-walk.h @@ -36,6 +36,13 @@ typedef enum { CASEFOLD_ERROR = -1, /* the probe failed; errno is set */ CASEFOLD_FOUND = 0, /* every component resolved; out names the object */ CASEFOLD_ABSENT = 1, /* out is where the object would have to live */ + /* A component the walk had to pass through is a symlink. out names that + * link in host spelling; the caller resolves the target in the guest + * namespace and comes back with the spliced path. The walk does not follow + * it itself, because where an absolute target lands is a dispatch question + * (sysroot or host), and the walk sees only the sysroot side. + */ + CASEFOLD_SYMLINK = 2, } casefold_verdict_t; typedef struct { @@ -69,6 +76,15 @@ typedef struct { * path as unclaimed and look for it on the host. */ bool notdir; + /* Set with CASEFOLD_SYMLINK: the byte offset in the guest path at which + * the components after the link begin, or the path length when the link + * was the final component. + */ + size_t link_rest_offset; + /* Offset in the guest path of the link component itself, so the caller can + * rebuild the directory a relative target is measured from. + */ + size_t link_guest_offset; } 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 f8ca1b46..c146b0fd 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -24,10 +24,6 @@ #include "syscall/path.h" #include "syscall/proc.h" -#ifndef MAXSYMLINKS -#define MAXSYMLINKS 40 -#endif - #define PROC_PATH_COMPONENTS_MAX (LINUX_PATH_MAX / 2) bool path_prefix_match(const char *path, const char *prefix, size_t plen) @@ -147,6 +143,42 @@ int path_check_intercept_access(const struct stat *st, int mode, int flags) return -1; } +/* Splice a symlink target back into a path being resolved: @target, then + * whatever of the original path was left unconsumed. @prefix is prepended only + * for a relative target, and names the directory the link sits in; a caller + * that re-anchors some other way (by resetting a descriptor, say) passes NULL. + * + * Shared because two walkers follow links and the concatenation is where the + * truncation checks live. A second copy of it would be a second place for a + * spliced path to be silently shortened into one naming a different file. + * + * Returns 0, or -1 with errno set to ENAMETOOLONG. + */ +int path_splice_link_target(const char *prefix, + size_t prefix_len, + const char *target, + const char *rest, + char *out, + size_t outsz) +{ + int n; + + while (*rest == '/') + rest++; + + if (target[0] == '/' || !prefix) + n = snprintf(out, outsz, "%s%s%s", target, *rest ? "/" : "", rest); + else + n = snprintf(out, outsz, "%.*s%s%s%s", (int) prefix_len, prefix, target, + *rest ? "/" : "", rest); + + if (n < 0 || (size_t) n >= outsz) { + errno = ENAMETOOLONG; + return -1; + } + return 0; +} + /* True when @path names a directory by ending in one or more separators. "/" * itself does not count: it is the root, not an assertion about a leaf. */ @@ -161,7 +193,11 @@ static bool path_has_trailing_slash(const char *path) static int path_check_relative_sysroot_containment(guest_fd_t dirfd, const char *path, unsigned int flags, - bool *in_sysroot); + bool *in_sysroot, + char *abs_out, + size_t abs_outsz, + char *host_out, + size_t host_outsz); int path_translate_at(guest_fd_t dirfd, const char *path, @@ -255,9 +291,12 @@ int path_translate_at(guest_fd_t dirfd, * target, before the prefix check runs. */ 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() && - path_check_relative_sysroot_containment(dirfd, tx->guest_path, flags, - &relative_in_sysroot) < 0) { + path_check_relative_sysroot_containment( + dirfd, tx->guest_path, flags, &relative_in_sysroot, relative_abs, + sizeof(relative_abs), relative_host, sizeof(relative_host)) < 0) { tx->host_path = NULL; if (errno == 0) errno = ELOOP; @@ -281,6 +320,14 @@ int path_translate_at(guest_fd_t dirfd, * spelling afterwards. */ if (tx->host_path && relative_in_sysroot && casefold_active()) { + /* The caller's follow decision applies to the final component here + * exactly as it does in the absolute resolvers: a create names the + * link, not the target (proc-state.c passes the same false), and + * everything else follows unless it asked not to. Stopping short + * unconditionally would hand the link's stored target bytes to the + * host kernel, which cannot spell them. + */ + bool follow_final = !(flags & (PATH_TR_NOFOLLOW | PATH_TR_CREATE)); host_fd_ref_t ref; casefold_walk_t walk; casefold_verdict_t verdict; @@ -290,11 +337,27 @@ int path_translate_at(guest_fd_t dirfd, return -1; } verdict = - casefold_resolve_at(ref.fd, "", tx->guest_path, false, tx->host_buf, - sizeof(tx->host_buf), &walk); + casefold_resolve_at(ref.fd, "", tx->guest_path, follow_final, + tx->host_buf, sizeof(tx->host_buf), &walk); host_fd_ref_close(&ref); if (verdict == CASEFOLD_ERROR) return -1; + /* A link on the way needs the target resolved in the guest namespace, + * and a target may be absolute, which a descriptor-relative walk has + * no anchor for. The containment check already resolved the + * reconstructed absolute path with the caller's own flag mapping and + * handed the host spelling back; using it keeps one mapping for both + * legs instead of a second copy that can drift. Only paths that + * actually cross a link take this arm; everything else keeps the + * descriptor-relative walk, whose descriptor is the anchor openat(2) + * semantics are measured from. + */ + if (verdict == CASEFOLD_SYMLINK && + str_copy_trunc(tx->host_buf, relative_host, sizeof(tx->host_buf)) >= + sizeof(tx->host_buf)) { + errno = ENAMETOOLONG; + return -1; + } tx->host_path = tx->host_buf; } @@ -431,6 +494,30 @@ int sys_path_has_symlink(guest_fd_t dirfd, const char *path) char sysroot_buf[LINUX_PATH_MAX]; if (path[0] == '/') { + /* The resolver splices an intermediate link into its target, so its + * output cannot reveal the link to the component walk below. Ask the + * case-exact walk first: it stops at exactly the link this precheck + * exists to refuse. An absent or folded path keeps the resolver's + * answer: no in-sysroot component of those is ever spliced, so the + * walk below still sees whatever the host side holds. + */ + if (casefold_active()) { + 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); + + if (verdict == CASEFOLD_ERROR) + return -1; + if (verdict == CASEFOLD_SYMLINK) { + errno = ELOOP; + return -1; + } + } + } const char *host_path = path_resolve_sysroot_nofollow_path( path, sysroot_buf, sizeof(sysroot_buf)); if (!host_path) { @@ -481,16 +568,27 @@ int sys_path_has_symlink(guest_fd_t dirfd, const char *path) bool in_sysroot = false; if (path_check_relative_sysroot_containment( - dirfd, path, PATH_TR_NOFOLLOW, &in_sysroot) < 0) { + dirfd, path, PATH_TR_NOFOLLOW, &in_sysroot, NULL, 0, NULL, 0) < + 0) { rc = -1; goto out; } if (in_sysroot) { casefold_walk_t walk; + casefold_verdict_t verdict = + casefold_resolve_at(current_fd, "", path, false, sysroot_buf, + sizeof(sysroot_buf), &walk); - if (casefold_resolve_at(current_fd, "", path, false, sysroot_buf, - sizeof(sysroot_buf), - &walk) == CASEFOLD_ERROR) { + if (verdict == CASEFOLD_ERROR) { + rc = -1; + goto out; + } + /* The walk stopping at a link is the answer this function exists + * to give: RESOLVE_NO_SYMLINKS refuses a path that passes through + * one, so there is nothing further to spell out. + */ + if (verdict == CASEFOLD_SYMLINK) { + errno = ELOOP; rc = -1; goto out; } @@ -1100,7 +1198,11 @@ static int dirfd_guest_base_path(guest_fd_t dirfd, char *out, size_t outsz) static int path_check_relative_sysroot_containment(guest_fd_t dirfd, const char *path, unsigned int flags, - bool *in_sysroot) + bool *in_sysroot, + char *abs_out, + size_t abs_outsz, + char *host_out, + size_t host_outsz) { char base[LINUX_PATH_MAX]; @@ -1149,6 +1251,23 @@ static int path_check_relative_sysroot_containment(guest_fd_t dirfd, * prefix of its own to make that call from. */ *in_sysroot = checked && 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. + */ + if (host_out && *in_sysroot && + str_copy_trunc(host_out, checked, host_outsz) >= host_outsz) { + errno = ENAMETOOLONG; + return -1; + } return checked ? 0 : -1; } @@ -1455,21 +1574,13 @@ int path_openat2_crosses_mount(guest_fd_t dirfd, } target[target_len] = '\0'; - char rest_buf[LINUX_PATH_MAX]; - const char *rest = walk; - while (*rest == '/') - rest++; - if (str_copy_trunc(rest_buf, rest, sizeof(rest_buf)) >= - sizeof(rest_buf)) { - errno = ENAMETOOLONG; + /* 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; - } - if (snprintf(pending, sizeof(pending), "%s%s%s", target, - rest_buf[0] ? "/" : "", - rest_buf) >= (int) sizeof(pending)) { - errno = ENAMETOOLONG; - goto out; - } walk = pending; if (target[0] == '/') { diff --git a/src/syscall/path.h b/src/syscall/path.h index 54ec02f4..d1f816c0 100644 --- a/src/syscall/path.h +++ b/src/syscall/path.h @@ -120,6 +120,29 @@ int path_translate_at(guest_fd_t dirfd, const char *path, unsigned int flags, path_translation_t *tx); +/* Longest symlink chain a resolution may follow before reporting ELOOP, as + * Linux does (include/linux/namei.h). Shared so the path layer and the sysroot + * resolvers cannot disagree about when a chain has gone on too long. + */ +#ifndef MAXSYMLINKS +#define MAXSYMLINKS 40 +#endif + +/* Splice a symlink target back into a path being resolved: @target followed by + * whatever of the original path was left unconsumed. @prefix is prepended for a + * relative target only, and names the directory holding the link; pass NULL + * when the caller re-anchors another way. One copy, because the concatenation + * is where a spliced path could be silently shortened into a different one. + * + * Returns 0, or -1 with errno set to ENAMETOOLONG. + */ +int path_splice_link_target(const char *prefix, + size_t prefix_len, + const char *target, + const char *rest, + char *out, + size_t outsz); + /* Convert a host path to the guest path naming the same object: strip the * sysroot prefix, and decode any component the volume made elfuse store under * an escape. The result is what the guest must be shown for its own cwd, and it diff --git a/src/syscall/proc-state.c b/src/syscall/proc-state.c index 15cc211d..92718026 100644 --- a/src/syscall/proc-state.c +++ b/src/syscall/proc-state.c @@ -664,6 +664,90 @@ static bool is_sysroot_backed_temp_path(const char *path) return false; } +/* Resolve @path under @sr, following every symlink the walk has to pass + * through, and report the guest path it finally names in @guest_out. + * + * A link records the bytes the guest wrote, and readlink(2) owes those bytes + * back, so a target cannot be rewritten on the way in. Following it therefore + * has to happen in the guest's namespace: a relative target is joined to the + * directory holding the link, an absolute one replaces the path outright, and + * either way the result re-enters resolution as an ordinary guest path. That is + * what makes an absolute target behave like the same path typed by the guest + * rather than like a host path, which is what the host kernel would make of it. + * + * Iterative because a chain may be MAXSYMLINKS deep and each step needs a whole + * path buffer; recursing would put 40 of them on the stack. + */ +static casefold_verdict_t resolve_through_links(const char *sr, + const char *path, + bool follow_final, + char *buf, + size_t bufsz, + casefold_walk_t *walk, + char *guest_out, + size_t guest_outsz) +{ + char swap[2][LINUX_PATH_MAX]; + const char *cur = path; + int which = 0; + + for (int depth = 0;; depth++) { + casefold_verdict_t verdict; + char target[LINUX_PATH_MAX]; + const char *rest; + char *next; + ssize_t n; + + if (depth > MAXSYMLINKS) { + errno = ELOOP; + return CASEFOLD_ERROR; + } + + verdict = casefold_resolve_at(AT_FDCWD, sr, cur, follow_final, buf, + bufsz, walk); + if (verdict != CASEFOLD_SYMLINK) { + if (str_copy_trunc(guest_out, cur, guest_outsz) >= guest_outsz) { + errno = ENAMETOOLONG; + return CASEFOLD_ERROR; + } + return verdict; + } + + /* buf names the link itself, in the spelling the volume stores, so the + * host can read it even when the name had to be escaped. + */ + n = readlink(buf, target, sizeof(target) - 1); + if (n < 0) + return CASEFOLD_ERROR; + target[n] = '\0'; + + rest = cur + walk->link_rest_offset; + next = swap[which]; + which ^= 1; + + /* A relative target is measured from the directory holding the link, + * which is the guest path up to the link component. + */ + if (path_splice_link_target(cur, walk->link_guest_offset, target, rest, + next, sizeof(swap[0])) < 0) + return CASEFOLD_ERROR; + cur = next; + } +} + +/* After a walk that may have followed links, point @path at the guest path it + * finally named. Reports whether a link was actually crossed, because the + * host-fallback decision downstream is different for a path the guest typed + * and one a link handed it; shared by both resolvers so the two cannot drift. + */ +static bool rebase_after_link(const char **path, const char *followed) +{ + if (!strcmp(*path, followed)) + return false; + *path = followed; + return true; +} + static const char *proc_resolve_sysroot_path_flags(const char *path, char *buf, size_t bufsz, @@ -686,10 +770,13 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, */ bool present; bool folded = false; + bool followed_link = false; + char followed[LINUX_PATH_MAX]; if (casefold_active()) { casefold_walk_t walk; - casefold_verdict_t verdict = casefold_resolve_at( - AT_FDCWD, sr, path, follow_final, buf, bufsz, &walk); + casefold_verdict_t verdict = + resolve_through_links(sr, path, follow_final, buf, bufsz, &walk, + followed, sizeof(followed)); if (verdict == CASEFOLD_ERROR) return NULL; @@ -714,6 +801,13 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, */ if (present && walk.leaf_offset == 0) return buf; + /* Everything below decides between the sysroot and the host, and after + * a link that decision belongs to where the link pointed, not to where + * the guest started. An absolute target is then treated exactly like + * the same absolute path from the guest: the sysroot when it is there, + * the host when it is not and the path is not a guest system directory. + */ + followed_link = rebase_after_link(&path, followed); } else { int n = snprintf(buf, bufsz, "%s%s", sr, path); if (n < 0) { @@ -779,6 +873,31 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, is_sysroot_backed_temp_path(path_to_check)) return buf; + /* A path reached by following a symlink never falls through to the host. + * An absolute target typed by the guest is one thing: the guest asked for + * it, and the host fallback is the documented answer. A target recorded + * inside the sysroot is another: honoring it would let anything that can + * write a symlink there hand the guest a file from outside the tree, which + * is the escape tests/test-sysroot-symlink-escape.c exists to prevent. + * Resolution stops at the sysroot spelling, so the caller's own syscall + * reports the path as missing, which is what it is in the guest's + * namespace. + */ + if (followed_link) { + /* The sysroot does not have it. If the host does, the link was a way + * to reach a file outside the tree, and refusing is the whole point; + * report ELOOP, which is what resolution stopping at a link means and + * what callers of this resolver already propagate. If the host has + * nothing either the link simply dangles, and buf lets the caller's own + * syscall say so. + */ + if (sysroot_path_exists(path, follow_final)) { + errno = ELOOP; + return NULL; + } + return buf; + } + return path; } @@ -816,12 +935,20 @@ const char *proc_resolve_sysroot_create_path(const char *path, * answers neither: a parent stored escaped reads as absent, and a * wrong-case one folds onto a directory the create would then land in. */ + bool followed_link = false; + char followed[LINUX_PATH_MAX]; if (casefold_active()) { casefold_walk_t walk; - if (casefold_resolve_at(AT_FDCWD, sr, path, false, buf, bufsz, &walk) == - CASEFOLD_ERROR) + /* Intermediate links have to be followed here as well. Lookup and + * create are separate resolvers, and a follow rule taught to only one + * of them lets a create below a link land beside the link instead of + * inside the directory the link names. + */ + if (resolve_through_links(sr, path, false, buf, bufsz, &walk, followed, + sizeof(followed)) == CASEFOLD_ERROR) return NULL; + followed_link = rebase_after_link(&path, followed); if (str_copy_trunc(parent, buf, sizeof(parent)) >= sizeof(parent)) { errno = ENAMETOOLONG; return NULL; @@ -914,8 +1041,17 @@ const char *proc_resolve_sysroot_create_path(const char *path, const char *path_to_check = has_norm ? norm_path : path; if (!is_sysroot_backed_temp_path(path_to_check) && - !is_guest_system_path(path_to_check)) + !is_guest_system_path(path_to_check)) { + /* As in the lookup resolver: a path reached by following a link may not + * fall through to the host, and after a link the guest path lives in a + * local buffer, so the input pointer is no longer the input. + */ + if (followed_link) { + errno = ELOOP; + return NULL; + } return path; + } if (!create_parents) { if (sysroot_validate_dir_prefix(parent) < 0) diff --git a/tests/test-case-collision.c b/tests/test-case-collision.c index fb37ad82..8f759ca0 100644 --- a/tests/test-case-collision.c +++ b/tests/test-case-collision.c @@ -460,18 +460,21 @@ int main(void) * that holds for an absolute target too: nothing has to resolve the * target to copy the link. * - * The followed target is relative on purpose. An absolute target is - * stored verbatim, because the guest has to read back the bytes it wrote, - * so when the host kernel follows the link it resolves those bytes - * against the host root rather than the sysroot and the target is not - * there. Following a relative target stays inside the translated parent - * and works. Using an absolute one here would test that gap instead of - * linkat. + * The followed target is spelled relative in one case and absolute in the + * other: a symlink stores the bytes the guest wrote, so the two spellings + * reach the target through different resolution paths (the relative one + * against the translated parent, the absolute one through the + * guest-namespace splice), and only running both shows linkat follows + * each. */ TEST("linkat AT_SYMLINK_FOLLOW links the target, not the symlink"); check_linkat(base, "real-target", "real-link", "REAL-HARD", false, AT_SYMLINK_FOLLOW, false); + TEST("linkat AT_SYMLINK_FOLLOW follows an absolute target too"); + check_linkat(base, "Abs.Target", "abs-follow-link", "ABS-FOLLOW-HARD", true, + AT_SYMLINK_FOLLOW, false); + TEST("linkat without AT_SYMLINK_FOLLOW links the symlink itself"); check_linkat(base, "abs-target", "abs-link", "ABS-HARD", true, 0, true); diff --git a/tests/test-casefold-walk-host.c b/tests/test-casefold-walk-host.c index d38492da..d9a7e1d0 100644 --- a/tests/test-casefold-walk-host.c +++ b/tests/test-casefold-walk-host.c @@ -254,17 +254,37 @@ static void section_symlink(void) char out[LINUX_PATH_MAX]; casefold_walk_t walk; - /* The probe stops at a link rather than following it, so a link is judged - * by its own name and an intermediate link is traversed by the kernel when - * the next prefix is probed. + /* A link the walk does not have to pass through is judged by its own name, + * which is what nofollow on the final component means. */ check("symlink resolved by its own name", "/link.to.lowdir", CASEFOLD_FOUND, "link.to.lowdir"); - check("through a symlinked intermediate", "/link.to.lowdir/inner.txt", - CASEFOLD_FOUND, "link.to.lowdir/inner.txt"); - /* A dangling link exists as a link but not as a target, so the answer - * depends on which the caller asked about. + /* A link the walk does have to pass through stops it, and the walk says so + * rather than letting the host follow the stored bytes: those name a + * guest path, whose components may be escaped and whose absolute form + * starts at the sysroot, so the host would look somewhere else. The caller + * resolves the target in the guest namespace and comes back. + * + * link_rest_offset points at what is left to resolve, and + * link_guest_offset at the link itself, so a relative target can be joined + * to the directory holding it. + */ + if (casefold_resolve_at(AT_FDCWD, root, "/link.to.lowdir/inner.txt", false, + out, sizeof(out), &walk) == CASEFOLD_SYMLINK && + !strcmp(out + strlen(root), "/link.to.lowdir") && + !strcmp("/link.to.lowdir/inner.txt" + walk.link_rest_offset, + "inner.txt") && + !strcmp("/link.to.lowdir/inner.txt" + walk.link_guest_offset, + "link.to.lowdir/inner.txt")) + ok(); + else + fail("an intermediate link stops the walk", + "expected CASEFOLD_SYMLINK"); + + /* A dangling link exists as a link. Asking about its target stops the walk + * at the link too: whether the target is there is a question about a guest + * path the caller has not resolved yet. */ if (casefold_resolve_at(AT_FDCWD, root, "/dangling", false, out, sizeof(out), &walk) == CASEFOLD_FOUND) @@ -272,10 +292,12 @@ static void section_symlink(void) else fail("dangling link exists without following", "expected found"); if (casefold_resolve_at(AT_FDCWD, root, "/dangling", true, out, sizeof(out), - &walk) == CASEFOLD_ABSENT) + &walk) == CASEFOLD_SYMLINK && + walk.link_rest_offset == strlen("/dangling")) ok(); else - fail("dangling link is absent when followed", "expected absent"); + fail("following a dangling link stops at the link", + "expected CASEFOLD_SYMLINK with nothing left to resolve"); /* A second hard link to a symlink is that same link under another name, * and it resolves by the name the caller used. The volume reports the diff --git a/tests/test-sysroot-symlink-target.c b/tests/test-sysroot-symlink-target.c new file mode 100644 index 00000000..4716804c --- /dev/null +++ b/tests/test-sysroot-symlink-target.c @@ -0,0 +1,380 @@ +/* + * Following a symlink whose target names an escaped file + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A relative symlink target stores the bytes the guest gave it, and + * readlink(2) hands them back. On a folding sysroot that puts the target and + * the disk out of step, because a name the volume cannot hold as itself is + * stored escaped; handing the stored bytes to the host kernel then looks for + * a name that is not there. An absolute target cannot even be stored + * verbatim: anything following the link natively resolves it from the host + * root rather than the sysroot, so creation rewrites it to a target relative + * to the link's own directory (sys_symlinkat in src/syscall/fs.c). readlink + * reports that rewritten spelling, the one visible divergence, because + * nothing on disk tells a rewritten target from a relative one the guest + * wrote. + * + * Following therefore happens in the guest's namespace: the target is resolved + * as a guest path, through the same sysroot-or-host dispatch every other guest + * path takes. An absolute target consequently behaves exactly like the same + * absolute path typed by the guest: inside the sysroot when it is there, on + * the host when it is not and the path is not a guest system directory. + * + * Code under test: the symlink handling in src/syscall/casefold-walk.c and the + * splice in src/syscall/proc-state.c. A regression shows up as ENOENT for a + * file the guest can see with readlink and lstat, which is how a rootfs with + * ordinary symlinks stops working. + * + * Intermediate components are followed whichever call is made; only the final + * one honors nofollow, which is what path_resolution(7) requires. Run under + * --sysroot. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DIR_T "/symlink-target" + +static void at(char *out, size_t outsz, const char *name) +{ + snprintf(out, outsz, "%s/%s", DIR_T, name); +} + +/* Create @name holding @text, and return the path it was created at. */ +static const char *make_file(const char *name, const char *text) +{ + static char path[PATH_MAX]; + + at(path, sizeof(path), name); + if (file_write(path, text) < 0) + return NULL; + return path; +} + +static int link_to(const char *target, const char *name) +{ + char path[PATH_MAX]; + + at(path, sizeof(path), name); + unlink(path); + return symlink(target, path); +} + +/* Read through @name, following whatever links it passes. */ +static int reads(const char *name, const char *want) +{ + char path[PATH_MAX]; + + at(path, sizeof(path), name); + return file_content_is(path, want); +} + +int main(void) +{ + char path[PATH_MAX]; + char buf[PATH_MAX]; + struct stat st; + ssize_t n; + + printf("test-sysroot-symlink-target: following escaped link targets\n"); + + TEST("fixture setup"); + EXPECT_TRUE(mkdir(DIR_T, 0755) == 0 || errno == EEXIST, "mkdir"); + + /* "Target.One" needs escaping; "plain" does not. Both are created by the + * guest, so the difference is purely how the volume stores them. + */ + TEST("stage an escaped target and a fold-stable one"); + EXPECT_TRUE( + make_file("Target.One", "escaped") && make_file("plain", "literal"), + "create targets"); + + TEST("a relative target naming an escaped file is followed"); + EXPECT_TRUE(link_to("Target.One", "rel-escaped") == 0, "symlink"); + TEST(" and reads through to it"); + EXPECT_TRUE(reads("rel-escaped", "escaped") == 0, "content"); + + /* The case that already worked. Kept so a fix cannot trade one for the + * other. + */ + TEST("a relative target naming a fold-stable file still follows"); + EXPECT_TRUE(link_to("plain", "rel-plain") == 0, "symlink"); + TEST(" and reads through to it"); + EXPECT_TRUE(reads("rel-plain", "literal") == 0, "content"); + + TEST("an absolute target inside the sysroot is followed"); + at(path, sizeof(path), "Target.One"); + EXPECT_TRUE(link_to(path, "abs-escaped") == 0, "symlink"); + TEST(" and reads through to it"); + EXPECT_TRUE(reads("abs-escaped", "escaped") == 0, "content"); + + /* readlink reports the disk. A relative target is stored verbatim, so + * the guest's own bytes come back; an absolute one was rewritten at + * symlink() time to the sysroot-relative spelling (see the header), and + * that spelling is what comes back. The link here sits one directory + * below the guest root, so the rewrite is ".." plus the absolute target. + */ + TEST("readlink returns a relative target verbatim"); + at(path, sizeof(path), "rel-escaped"); + n = readlink(path, buf, sizeof(buf) - 1); + if (n < 0) { + FAIL("readlink"); + } else { + buf[n] = '\0'; + EXPECT_TRUE(!strcmp(buf, "Target.One"), "bytes must round-trip"); + } + + TEST("readlink reports the rewrite of an absolute target"); + at(path, sizeof(path), "abs-escaped"); + n = readlink(path, buf, sizeof(buf) - 1); + if (n < 0) { + FAIL("readlink"); + } else { + char want[PATH_MAX]; + buf[n] = '\0'; + snprintf(want, sizeof(want), "..%s/%s", DIR_T, "Target.One"); + EXPECT_TRUE(!strcmp(buf, want), "expected the rewritten spelling"); + } + + TEST("lstat sees the link, not the target"); + EXPECT_TRUE(lstat(path, &st) == 0 && S_ISLNK(st.st_mode), "lstat"); + + TEST("stat sees the target, not the link"); + EXPECT_TRUE(stat(path, &st) == 0 && S_ISREG(st.st_mode), "stat"); + + /* An intermediate link is followed whatever the final component asks for, + * which is what separates "do not follow the last component" from "do not + * follow anything". + */ + TEST("a link used as an intermediate component is followed"); + EXPECT_TRUE(mkdir(DIR_T "/Sub.Dir", 0755) == 0 || errno == EEXIST, "mkdir"); + at(path, sizeof(path), "Sub.Dir/Leaf.Name"); + EXPECT_TRUE(file_write(path, "under") == 0, "create leaf"); + TEST(" through the link"); + EXPECT_TRUE(link_to("Sub.Dir", "dir-link") == 0 && + reads("dir-link/Leaf.Name", "under") == 0, + "content through an intermediate link"); + + TEST("nofollow still applies to the final component only"); + at(path, sizeof(path), "dir-link/Leaf.Name"); + EXPECT_TRUE(lstat(path, &st) == 0 && S_ISREG(st.st_mode), + "the intermediate link must still be followed"); + + TEST("a chain of three links is followed"); + EXPECT_TRUE(link_to("rel-escaped", "chain-b") == 0 && + link_to("chain-b", "chain-c") == 0 && + reads("chain-c", "escaped") == 0, + "content through a chain"); + + /* Bounded, and with the errno Linux uses. An unbounded walk would hang or + * exhaust a buffer instead. + */ + TEST("a self-referential link reports ELOOP"); + EXPECT_TRUE(link_to("self", "self") == 0, "symlink"); + at(path, sizeof(path), "self"); + EXPECT_ERRNO(open(path, O_RDONLY), ELOOP, "should be ELOOP"); + + TEST("a two-link cycle reports ELOOP"); + EXPECT_TRUE( + link_to("cyc-b", "cyc-a") == 0 && link_to("cyc-a", "cyc-b") == 0, + "symlinks"); + at(path, sizeof(path), "cyc-a"); + EXPECT_ERRNO(open(path, O_RDONLY), ELOOP, "should be ELOOP"); + + TEST("a dangling target reports ENOENT"); + EXPECT_TRUE(link_to("No.Such.Name", "dangling") == 0, "symlink"); + at(path, sizeof(path), "dangling"); + EXPECT_ERRNO(open(path, O_RDONLY), ENOENT, "should be ENOENT"); + + TEST("a dangling link is still visible to lstat and readlink"); + EXPECT_TRUE(lstat(path, &st) == 0 && S_ISLNK(st.st_mode) && + readlink(path, buf, sizeof(buf) - 1) > 0, + "the link itself exists"); + + /* Operations that do not follow must keep working on the link, since a + * fix that resolved targets everywhere would break removing a dangling one. + */ + TEST("unlink removes the link, not its target"); + at(path, sizeof(path), "rel-escaped"); + EXPECT_TRUE(unlink(path) == 0, "unlink"); + TEST(" and the target survives"); + EXPECT_TRUE(reads("Target.One", "escaped") == 0, "target still there"); + + /* Creating below an intermediate link has to follow it too, and the create + * resolver is separate code from the lookup one, the same split that let + * a wrong-case create escape once already. The new file must appear in the + * directory the link names, not beside the link. + */ + TEST("a create below an intermediate link follows it"); + at(path, sizeof(path), "dir-link/Made.Here"); + EXPECT_TRUE(file_write(path, "made") == 0, "create through the link"); + TEST(" and it landed in the directory the link names"); + EXPECT_TRUE(reads("Sub.Dir/Made.Here", "made") == 0, + "content at the target"); + + TEST("mkdir below an intermediate link follows it"); + at(path, sizeof(path), "dir-link/Made.Dir"); + EXPECT_TRUE(mkdir(path, 0755) == 0 || errno == EEXIST, "mkdir"); + TEST(" and it landed in the directory the link names"); + at(path, sizeof(path), "Sub.Dir/Made.Dir"); + EXPECT_TRUE(stat(path, &st) == 0 && S_ISDIR(st.st_mode), "dir at target"); + + /* And the same through a descriptor, which is the third resolver: a tree + * walker reaches every name this way and never builds an absolute path. + */ + TEST("a dirfd-relative path through a link follows it"); + { + int dirfd = open(DIR_T, O_RDONLY | O_DIRECTORY); + char buf2[64]; + ssize_t got; + int f = -1; + + if (dirfd < 0) { + FAIL("open dirfd"); + } else if ((f = openat(dirfd, "dir-link/Leaf.Name", O_RDONLY)) < 0) { + FAIL("openat through an intermediate link"); + } else if ((got = read(f, buf2, sizeof(buf2) - 1)) <= 0) { + FAIL("read"); + } else { + buf2[got] = '\0'; + EXPECT_TRUE(!strcmp(buf2, "under"), "wrong file through the dirfd"); + } + if (f >= 0) + close(f); + if (dirfd >= 0) + close(dirfd); + } + + /* The final component itself may be the link. The follow decision belongs + * to the caller (openat without O_NOFOLLOW follows, fstatat with + * AT_SYMLINK_NOFOLLOW does not), and the dirfd-relative walk has to honor + * it exactly as the absolute spellings above do. A regression hands the + * stored target bytes to the host kernel instead: ENOENT for an escaped + * target, and an absolute target resolved from the host's root. + */ + TEST("a dirfd-relative final link with a relative target is followed"); + { + int dirfd = open(DIR_T, O_RDONLY | O_DIRECTORY); + char buf2[64]; + struct stat st2; + ssize_t got; + int f = -1; + + if (dirfd < 0) { + FAIL("open dirfd"); + } else if (symlinkat("Target.One", dirfd, "final-rel") != 0) { + FAIL("symlinkat"); + } else if ((f = openat(dirfd, "final-rel", O_RDONLY)) < 0) { + FAIL("openat through a final link"); + } else if ((got = read(f, buf2, sizeof(buf2) - 1)) <= 0) { + FAIL("read"); + } else { + buf2[got] = '\0'; + EXPECT_TRUE(!strcmp(buf2, "escaped"), + "wrong file through the link"); + } + if (f >= 0) + close(f); + + TEST(" fstatat follows it"); + EXPECT_TRUE(dirfd >= 0 && fstatat(dirfd, "final-rel", &st2, 0) == 0 && + S_ISREG(st2.st_mode), + "fstatat should reach the target"); + + TEST(" and nofollow still sees the link itself"); + EXPECT_TRUE( + dirfd >= 0 && + fstatat(dirfd, "final-rel", &st2, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISLNK(st2.st_mode), + "nofollow should stop at the link"); + + TEST("a dirfd-relative final link with an absolute target is followed"); + f = -1; + if (dirfd < 0) { + FAIL("open dirfd"); + } else if (symlinkat(DIR_T "/Target.One", dirfd, "final-abs") != 0) { + FAIL("symlinkat"); + } else if ((f = openat(dirfd, "final-abs", O_RDONLY)) < 0) { + FAIL("openat through a final link"); + } else if ((got = read(f, buf2, sizeof(buf2) - 1)) <= 0) { + FAIL("read"); + } else { + buf2[got] = '\0'; + EXPECT_TRUE(!strcmp(buf2, "escaped"), + "wrong file through the link"); + } + if (f >= 0) + close(f); + + /* Creates through the descriptor must cross the link too. Create is + * a separate resolver from lookup, and the descriptor-relative walk + * is a separate entry point from the absolute one; the absolute + * create-below-a-link cases above pass while this one regresses + * whenever the two entry points map the create flag differently: + * the create then aims at the host-literal path instead of landing + * inside the sysroot, per openat(2)'s dirfd rule and + * path_resolution(7)'s follow rule for intermediate components. + */ + TEST("a dirfd-relative create below an intermediate link"); + f = -1; + if (dirfd < 0) { + FAIL("open dirfd"); + } else if ((f = openat(dirfd, "dir-link/New.File", O_CREAT | O_WRONLY, + 0644)) < 0) { + FAIL("openat O_CREAT through the link"); + } else if (write(f, "made-rel", 8) != 8) { + FAIL("write"); + } else { + TEST(" and it landed in the directory the link names"); + EXPECT_TRUE(reads("Sub.Dir/New.File", "made-rel") == 0, + "content at the target"); + } + if (f >= 0) + close(f); + + TEST("a dirfd-relative mkdir below an intermediate link"); + if (dirfd < 0) { + FAIL("open dirfd"); + } else if (mkdirat(dirfd, "dir-link/New.Dir", 0755) != 0) { + FAIL("mkdirat through the link"); + } else { + TEST(" and it landed in the directory the link names"); + at(path, sizeof(path), "Sub.Dir/New.Dir"); + EXPECT_TRUE(stat(path, &st) == 0 && S_ISDIR(st.st_mode), + "dir at the target"); + } + + /* Create and lookup must agree through the link: a name the lookup + * finds is one O_EXCL refuses. + */ + TEST("O_EXCL through the link sees the existing leaf"); + errno = 0; + f = openat(dirfd, "dir-link/Leaf.Name", O_CREAT | O_EXCL | O_WRONLY, + 0644); + if (f >= 0) { + close(f); + FAIL("O_EXCL created over an existing file"); + } else { + EXPECT_ERRNO(-1, EEXIST, "should be EEXIST"); + } + + if (dirfd >= 0) + close(dirfd); + } + + SUMMARY("test-sysroot-symlink-target"); + return fails > 0 ? 1 : 0; +} From 7e43568d15d524ea1eaa221a151bb345980852c4 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 29 Jul 2026 23:30:26 +0200 Subject: [PATCH 07/11] Translate inotify watches and event names 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 is scoped per directory, and the snapshot answers the same question getdents64 asks, whether the watched directory is one the sysroot owns, from the host descriptor it already holds, so a watch on a host directory outside the sysroot carries entry names as they are stored; the lane pins one such watch beside the decoding ones. 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. --- mk/tests.mk | 23 ++ src/syscall/inotify.c | 54 ++++- src/syscall/path.h | 7 + tests/test-sysroot-inotify-names.c | 346 +++++++++++++++++++++++++++++ 4 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 tests/test-sysroot-inotify-names.c diff --git a/mk/tests.mk b/mk/tests.mk index 6fea280e..88b51a2e 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -17,6 +17,7 @@ test-proctitle-host test-proctitle-low-stack \ test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \ test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \ + test-sysroot-inotify-names \ test-linkat-symlink-fallback test-casefold-host \ test-casefold-walk-host test-sysroot-name-unique \ test-sysroot-name-relative \ @@ -181,6 +182,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-sysroot-symlink-escape @printf "\n$(BLUE)━━━ escaped symlink-target resolution ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-symlink-target + @printf "\n$(BLUE)━━━ inotify names across the escape boundary ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-inotify-names @printf "\n$(BLUE)━━━ sysroot mounted at / ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-root @printf "\n$(BLUE)━━━ literal names without a sysroot ━━━$(RESET)\n" @@ -468,6 +471,26 @@ test-sysroot-symlink-target: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-symlink-tar exit 1; \ fi +# Watches resolve their path like every other guest path, and event names are +# decoded back to guest bytes. The recipe asserts the host-side half: the +# fixture the guest leaves behind must sit on disk only under its escape, so a +# passing guest lane proves the events were decoded rather than the names +# having been stored literally. +## inotify watches and event names cross the escape boundary intact +test-sysroot-inotify-names: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-inotify-names + @set -e; \ + tmpdir=$$(mktemp -d); \ + outdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir" "$$outdir"' EXIT; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-inotify-names "$$outdir"; \ + if [ -e "$$tmpdir/watch/CaseDir" ] && \ + [ -z "$$(find "$$tmpdir/watch" -maxdepth 1 -name '.ef=*' -print -quit)" ]; then \ + printf "$(RED)FAIL$(RESET) CaseDir was stored literally\n"; \ + ls -Ab "$$tmpdir/watch"; \ + exit 1; \ + fi + ## The degenerate sysroot: a one-character host prefix test-sysroot-root: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-root $(ELFUSE_BIN) --sysroot / $(BUILD_DIR)/test-sysroot-root diff --git a/src/syscall/inotify.c b/src/syscall/inotify.c index 3fe0070a..87b721b3 100644 --- a/src/syscall/inotify.c +++ b/src/syscall/inotify.c @@ -40,6 +40,7 @@ #include "syscall/abi.h" #include "syscall/inotify.h" #include "syscall/internal.h" +#include "syscall/path.h" #include "syscall/proc.h" /* proc_exit_group_requested */ static void inotify_close(int guest_fd); @@ -333,6 +334,12 @@ static bool dir_snapshot_fd(int dirfd, char ***out, int *n_out) int fd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); if (fd < 0) return false; + /* The snapshot answers the same per-directory ownership question + * getdents64 asks, from the host fd it already holds (before fdopendir + * takes ownership), so a watch on a directory outside the sysroot + * carries entry names as they are stored. + */ + const bool dir_holds_escapes = path_dirent_dir_holds_escapes(fd); DIR *d = fdopendir(fd); if (!d) { close(fd); @@ -356,6 +363,21 @@ static bool dir_snapshot_fd(int dirfd, char ***out, int *n_out) } if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue; + /* Snapshots feed named IN_CREATE/IN_DELETE events, so they carry + * guest-visible names: the same per-name decode getdents64 applies, + * through the same choke point. An over-long name is skipped exactly + * as getdents64 skips it (unrepresentable in an event's name field + * too), and any other failure keeps the previous baseline rather + * than diffing every decoded child as deleted. + */ + char guest_name[NAME_MAX + 1]; + if (path_translate_dirent_name(dir_holds_escapes, de->d_name, + guest_name, sizeof(guest_name)) < 0) { + if (errno == ENAMETOOLONG) + continue; + ok = false; + break; + } if (n == cap) { int ncap = cap ? cap * 2 : 16; char **tmp = realloc(names, (size_t) ncap * sizeof(char *)); @@ -366,7 +388,7 @@ static bool dir_snapshot_fd(int dirfd, char ***out, int *n_out) names = tmp; cap = ncap; } - names[n] = strdup(de->d_name); + names[n] = strdup(guest_name); if (!names[n]) { ok = false; break; @@ -638,11 +660,39 @@ int64_t sys_inotify_add_watch(guest_t *g, if (guest_read_str(g, path_gva, path, sizeof(path)) < 0) return -LINUX_EFAULT; + /* Watches are backed by kqueue on a host fd, so an object elfuse answers + * itself (with no host vnode behind it) cannot be watched at all. + * That is exactly a FUSE node and a synthetic /proc file, and refusing + * those beats watching an unrelated host path. + * + * Deliberately not gated on path_might_use_open_intercept: that predicate + * is a "might" prefilter for the open path, 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. Those all have a + * real host vnode (a /dev/shm leaf is redirected to one by the block + * above), and Linux grants a watch on each, so refusing them would trade + * this handler's ENOENT-on-a-missing-path for a blanket ENOSYS. + */ + path_translation_t tx; + if (path_translate_at(LINUX_AT_FDCWD, path, PATH_TR_NONE, &tx) < 0) + return linux_errno(); + if (tx.fuse_path || tx.proc_resolved != 0) + return -LINUX_ENOSYS; + /* Open the path for event monitoring. O_EVTONLY is macOS-specific: opens * for event notification only, does not prevent unmount or require read * access to the file contents. + * + * A shm leaf is opened nofollow on top of that. The guest may write a + * symlink into the backing directory, and a watch that followed one would + * report the existence of, and every change to, whatever it names. That + * includes 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 departs the same way; see + * dev_shm_resolve_path() in procemu.c. */ - int host_fd = open(path, O_EVTONLY); + int host_fd = open(tx.host_path, + tx.is_dev_shm ? (O_EVTONLY | O_NOFOLLOW) : O_EVTONLY); if (host_fd < 0) return linux_errno(); diff --git a/src/syscall/path.h b/src/syscall/path.h index d1f816c0..d51f17b7 100644 --- a/src/syscall/path.h +++ b/src/syscall/path.h @@ -163,6 +163,13 @@ int path_host_to_guest(const char *host_path, char *out, size_t outsz); */ bool path_dirent_dir_holds_escapes(host_fd_t host_dirfd); +/* Decode one on-disk entry name to the guest-visible spelling. + * @dir_holds_escapes is the caller's per-directory answer from + * path_dirent_dir_holds_escapes(); when false every name means itself. + * Returns 0, or -1 with errno set: ENAMETOOLONG for a host name no guest + * dirent or event buffer could carry, which is the one failure a caller with + * real arguments sees; a missing argument is EINVAL. + */ int path_translate_dirent_name(bool dir_holds_escapes, const char *host_name, char *guest_name, diff --git a/tests/test-sysroot-inotify-names.c b/tests/test-sysroot-inotify-names.c new file mode 100644 index 00000000..531d28be --- /dev/null +++ b/tests/test-sysroot-inotify-names.c @@ -0,0 +1,346 @@ +/* + * inotify names under a case-fold sysroot + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A guest name the volume cannot hold as itself is stored escaped, and the + * inotify emulation reads directory snapshots straight off the volume, so + * both halves of a watch cross the name boundary: the watched path must be + * resolved like every other guest path, and the names carried inside + * IN_CREATE/IN_DELETE events must be the guest's bytes, never the stored + * spelling. + * + * Linux contract pinned: inotify(7). The name field of an event is the + * filename within the watched directory, as the process would use it. A name + * the process never wrote and cannot stat is not that. + * + * Code under test: sys_inotify_add_watch and dir_snapshot_fd in + * src/syscall/inotify.c, routing through src/syscall/path.c. A regression + * shows up as inotify_add_watch reporting ENOENT for a directory the guest + * can open, or as an event naming an .ef= spelling the guest cannot resolve, + * which is how a file watcher (editors, build daemons) sees phantom files + * appear. + * + * The emulation pumps its event queue when the descriptor is read, so events + * are collected by polling a nonblocking fd with read(2), valid against a + * real kernel too. A pass therefore does not prove poll(2) reports readiness + * without an intervening read; that gap predates the name handling and is not + * what this lane pins. + * + * Run under --sysroot on a case-folding volume. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DIR_W "/watch" + +/* Collected event names, flattened; the emulation may batch or split reads, + * so assertions are about membership rather than arrival order. + */ +typedef struct { + char names[16][NAME_MAX + 1]; + uint32_t masks[16]; + int count; +} events_t; + +/* Set as soon as any drained event carries a stored spelling, across the + * whole run: leaking is a property of the stream, not of one lane. The one + * exception is the watch on a directory outside the sysroot, where the + * stored spelling is the correct name; the flag below suspends the check + * for exactly that section. + */ +static bool escape_leaked; +static bool stored_names_expected; + +/* Drain events until @want names arrived or @timeout_ms passed with nothing + * new. The snapshot diff behind IN_CREATE/IN_DELETE runs when the queue is + * pumped, so the fd is nonblocking and read in a paced loop rather than + * poll(2)ed; see the header. + */ +static void drain(int fd, events_t *ev, int want, int timeout_ms) +{ + char buf[2048]; + int waited_ms = 0; + + while (ev->count < want) { + ssize_t n = read(fd, buf, sizeof(buf)); + if (n <= 0) { + if (waited_ms >= timeout_ms) + return; + usleep(50 * 1000); + waited_ms += 50; + continue; + } + for (ssize_t off = 0; off < n;) { + struct inotify_event *e = (struct inotify_event *) (buf + off); + if (e->len > 0 && ev->count < 16) { + snprintf(ev->names[ev->count], sizeof(ev->names[0]), "%s", + e->name); + ev->masks[ev->count] = e->mask; + if (!stored_names_expected && !strncmp(e->name, ".ef=", 4)) + escape_leaked = true; + ev->count++; + } + off += (ssize_t) (sizeof(*e) + e->len); + } + } +} + +static bool saw(const events_t *ev, uint32_t mask, const char *name) +{ + for (int i = 0; i < ev->count; i++) + if ((ev->masks[i] & mask) && !strcmp(ev->names[i], name)) + return true; + return false; +} + +int main(int argc, char **argv) +{ + char path[PATH_MAX]; + events_t ev; + int fd, wd; + + printf("test-sysroot-inotify-names: guest names in inotify events\n"); + + TEST("fixture mkdir"); + EXPECT_TRUE(mkdir(DIR_W, 0755) == 0 || errno == EEXIST, "mkdir"); + + fd = inotify_init1(IN_NONBLOCK); + TEST("inotify_init1"); + EXPECT_TRUE(fd >= 0, "inotify_init1"); + + /* The watched path is a guest path: it exists in the sysroot and nowhere + * else, so a watch that bypasses translation opens the host's namespace + * and reports ENOENT for a directory the guest can chdir into. + */ + TEST("a watch on an absolute sysroot directory can be added"); + wd = inotify_add_watch(fd, DIR_W, IN_CREATE | IN_DELETE); + EXPECT_TRUE(wd >= 0, "inotify_add_watch"); + + /* The event name is the guest's spelling. On this volume the file below + * is stored escaped, so an undecoded snapshot diff would name .ef=..., + * bytes the guest never wrote and cannot stat. + */ + TEST("IN_CREATE carries the guest name for an escaped file"); + snprintf(path, sizeof(path), "%s/MixedCase.txt", DIR_W); + memset(&ev, 0, sizeof(ev)); + if (wd < 0) { + FAIL("no watch"); + } else if (file_write(path, "x") != 0) { + FAIL("create"); + } else { + drain(fd, &ev, 1, 2000); + EXPECT_TRUE(saw(&ev, IN_CREATE, "MixedCase.txt"), + "expected IN_CREATE MixedCase.txt"); + } + + TEST("IN_DELETE carries the guest name for an escaped file"); + memset(&ev, 0, sizeof(ev)); + if (wd < 0) { + FAIL("no watch"); + } else if (unlink(path) != 0) { + FAIL("unlink"); + } else { + drain(fd, &ev, 1, 2000); + EXPECT_TRUE(saw(&ev, IN_DELETE, "MixedCase.txt"), + "expected IN_DELETE MixedCase.txt"); + } + + /* Names differing only by case are distinct files, and their events must + * be distinct too: one stored literally, one escaped, both reported + * under the bytes the guest used. + */ + TEST("a colliding pair produces two distinctly named events"); + memset(&ev, 0, sizeof(ev)); + if (wd < 0) { + FAIL("no watch"); + } else { + char lower[PATH_MAX], upper[PATH_MAX]; + snprintf(lower, sizeof(lower), "%s/file", DIR_W); + snprintf(upper, sizeof(upper), "%s/File", DIR_W); + if (file_write(lower, "l") != 0 || file_write(upper, "u") != 0) { + FAIL("create pair"); + } else { + drain(fd, &ev, 2, 2000); + EXPECT_TRUE( + saw(&ev, IN_CREATE, "file") && saw(&ev, IN_CREATE, "File"), + "expected IN_CREATE for both spellings"); + } + } + + /* A directory whose own name is stored escaped is still watchable by its + * guest name, and events inside it decode the same way. + */ + TEST("a watch on an escaped directory can be added"); + snprintf(path, sizeof(path), "%s/CaseDir", DIR_W); + { + int wd2 = -1; + EXPECT_TRUE( + mkdir(path, 0755) == 0 && + (wd2 = inotify_add_watch(fd, path, IN_CREATE | IN_DELETE)) >= 0, + "mkdir + add_watch"); + + TEST(" and events inside it carry guest names"); + memset(&ev, 0, sizeof(ev)); + if (wd2 < 0) { + FAIL("no watch"); + } else { + snprintf(path, sizeof(path), "%s/CaseDir/Left.Behind", DIR_W); + if (file_write(path, "keep") != 0) { + FAIL("create"); + } else { + drain(fd, &ev, 1, 2000); + EXPECT_TRUE(saw(&ev, IN_CREATE, "Left.Behind"), + "expected IN_CREATE Left.Behind"); + } + } + } + + /* A relative watch resolves against the guest cwd, which sits inside the + * sysroot; the dirfd-relative leg of translation must land it on the same + * directory the absolute spelling names. + */ + TEST("a relative watch reaches the same directory"); + memset(&ev, 0, sizeof(ev)); + { + int wd3; + if (chdir(DIR_W) != 0 || + (wd3 = inotify_add_watch(fd, ".", IN_CREATE | IN_DELETE)) < 0) { + FAIL("chdir + add_watch"); + } else if (file_write("Rel.Made", "r") != 0) { + FAIL("create"); + } else { + drain(fd, &ev, 1, 2000); + EXPECT_TRUE(saw(&ev, IN_CREATE, "Rel.Made"), + "expected IN_CREATE Rel.Made"); + } + } + + /* A watch is refused only for an object kqueue cannot observe: a FUSE node + * or a synthetic /proc file, both of which elfuse answers itself with no + * host vnode behind them. /dev/shm is neither (the leaf is redirected to + * a real host file that kqueue watches like any other), so refusing it + * would deny a watch Linux grants on tmpfs. The same over-refusal reaches + * every path the open-intercept prefilter merely *might* claim: /etc/passwd + * with no sysroot copy, /sys/devices/system/cpu, and, because that filter + * compares four bytes, any name beginning "/dev". + */ + TEST("a watch on a /dev/shm leaf is granted, not refused"); + { + int shm = open("/dev/shm/inotify-probe", O_CREAT | O_RDWR, 0600); + int wd4 = -1; + + if (shm < 0) { + FAIL("open /dev/shm leaf"); + } else { + wd4 = inotify_add_watch(fd, "/dev/shm/inotify-probe", IN_ATTRIB); + EXPECT_TRUE(wd4 >= 0, "inotify_add_watch on a /dev/shm leaf"); + close(shm); + unlink("/dev/shm/inotify-probe"); + } + } + + /* The other half of that rule. A real shm leaf is watchable, but the guest + * can also write a symlink into the backing directory, and following one + * leads wherever its target names. is_guest_system_path() keeps /etc out + * of reach precisely so a guest cannot address the host's copy, and a + * watch on it hands back that file's existence and every change to it. + * Every other consumer of a shm leaf already refuses to follow: stat + * reports the link itself and open reports ELOOP. Watching was the one + * that followed, so a guest could observe any host path it could name as + * a link target. + */ + TEST("a watch does not follow a shm symlink out of the backing dir"); + { + unlink("/dev/shm/inotify-escape"); + if (symlink("/etc/passwd", "/dev/shm/inotify-escape") != 0) { + FAIL("symlink into /dev/shm"); + } else { + int wd5; + + errno = 0; + wd5 = inotify_add_watch(fd, "/dev/shm/inotify-escape", IN_ATTRIB); + if (wd5 >= 0) { + inotify_rm_watch(fd, wd5); + FAIL("watched a host file through a shm symlink"); + } else { + EXPECT_ERRNO(wd5, ELOOP, "should refuse to follow the link"); + } + unlink("/dev/shm/inotify-escape"); + } + } + + /* Decoding is scoped the way listings are: a watch on a host directory + * the sysroot does not own carries entry names as stored, because those + * names are the host's and an escape-shaped literal there means itself. + * A decoding snapshot would name a file the guest cannot stat in that + * directory, and would misreport a genuine literal ".ef=" file the guest + * itself created there. + */ + TEST("an outside-sysroot watch reports names as stored"); + if (argc < 2) { + FAIL("no outside directory given"); + } else { + int wd6; + + memset(&ev, 0, sizeof(ev)); + stored_names_expected = true; + if ((wd6 = inotify_add_watch(fd, argv[1], IN_CREATE | IN_DELETE)) < 0) { + FAIL("add_watch outside the sysroot"); + } else { + snprintf(path, sizeof(path), "%s/.ef=424152", argv[1]); + int outfd = open(path, O_CREAT | O_WRONLY, 0644); + if (outfd < 0) { + FAIL("create outside literal"); + } else { + close(outfd); + drain(fd, &ev, 1, 2000); + EXPECT_TRUE(saw(&ev, IN_CREATE, ".ef=424152") && + !saw(&ev, IN_CREATE, "BAR"), + "expected IN_CREATE under the literal bytes"); + + TEST(" and IN_DELETE names the same literal"); + memset(&ev, 0, sizeof(ev)); + if (unlink(path) != 0) { + FAIL("unlink outside literal"); + } else { + drain(fd, &ev, 1, 2000); + EXPECT_TRUE(saw(&ev, IN_DELETE, ".ef=424152") && + !saw(&ev, IN_DELETE, "BAR"), + "expected IN_DELETE under the literal bytes"); + } + } + inotify_rm_watch(fd, wd6); + } + stored_names_expected = false; + } + + /* The catch-all: whatever arrived above, nothing may look like a stored + * spelling. This is what fails first when the snapshot diff stops + * decoding. + */ + TEST("no event name was escape-shaped"); + EXPECT_TRUE(!escape_leaked, "an event leaked an .ef= spelling"); + + if (fd >= 0) + close(fd); + + SUMMARY("test-sysroot-inotify-names"); + return fails > 0 ? 1 : 0; +} From 1099b6eaca9b6cb28aa4164b105eae72fd8e9003 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 29 Jul 2026 23:30:54 +0200 Subject: [PATCH 08/11] Translate exec pathnames and reported identity 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. --- mk/tests.mk | 23 +++- src/runtime/procemu.c | 51 +++++--- src/syscall/exec.c | 15 ++- src/syscall/syscall.c | 23 +++- tests/test-sysroot-exec-names.c | 209 ++++++++++++++++++++++++++++++++ 5 files changed, 290 insertions(+), 31 deletions(-) create mode 100644 tests/test-sysroot-exec-names.c diff --git a/mk/tests.mk b/mk/tests.mk index 88b51a2e..a29ec82f 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -17,7 +17,7 @@ test-proctitle-host test-proctitle-low-stack \ test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \ test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \ - test-sysroot-inotify-names \ + test-sysroot-inotify-names test-sysroot-exec-names \ test-linkat-symlink-fallback test-casefold-host \ test-casefold-walk-host test-sysroot-name-unique \ test-sysroot-name-relative \ @@ -184,6 +184,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-sysroot-symlink-target @printf "\n$(BLUE)━━━ inotify names across the escape boundary ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-inotify-names + @printf "\n$(BLUE)━━━ exec identity across the escape boundary ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-exec-names @printf "\n$(BLUE)━━━ sysroot mounted at / ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-root @printf "\n$(BLUE)━━━ literal names without a sysroot ━━━$(RESET)\n" @@ -471,6 +473,25 @@ test-sysroot-symlink-target: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-symlink-tar exit 1; \ fi +# Exec crosses the escape boundary twice: the path being executed resolves +# like every other guest path, and the identity the kernel reports back +# (/proc/self/exe, /proc/self/fd/N) carries guest bytes. The recipe asserts +# the host-side half: the staged binary sits on disk only under its escape, +# so the passing exec lanes prove the resolution actually crossed it. +## exec paths and the reported exec identity stay in the guest namespace +test-sysroot-exec-names: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-exec-names + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-exec-names; \ + if [ -e "$$tmpdir/Apps" ] && \ + [ -z "$$(find "$$tmpdir" -maxdepth 1 -name '.ef=*' -print -quit)" ]; then \ + printf "$(RED)FAIL$(RESET) Apps was stored literally\n"; \ + ls -Ab "$$tmpdir"; \ + exit 1; \ + fi + # Watches resolve their path like every other guest path, and event names are # decoded back to guest bytes. The recipe asserts the host-side half: the # fixture the guest leaves behind must sit on disk only under its escape, so a diff --git a/src/runtime/procemu.c b/src/runtime/procemu.c index ed83fcc7..922fb34c 100644 --- a/src/runtime/procemu.c +++ b/src/runtime/procemu.c @@ -63,6 +63,7 @@ #include "syscall/fuse.h" #include "syscall/internal.h" #include "syscall/net-identity.h" +#include "syscall/path.h" #include "syscall/proc.h" #include "syscall/sys.h" @@ -3783,24 +3784,27 @@ static int proc_readlink_self_exe(char *buf, size_t bufsiz) } const char *exe = exe_buf; char exe_real[LINUX_PATH_MAX]; - char sysroot_snap[LINUX_PATH_MAX]; - if (proc_sysroot_snapshot(sysroot_snap, sizeof(sysroot_snap))) { - /* proc_set_sysroot stores a realpath()-canonicalized form, so - * canonicalize exe before the prefix check or the strip fails when /var - * -> /private/var (and similar macOS symlinks) make the two strings - * diverge. - */ - const char *exe_cmp = exe; - if (realpath(exe, exe_real)) - exe_cmp = exe_real; - size_t sr_len = strlen(sysroot_snap); - if (sr_len > 0 && !strncmp(exe_cmp, sysroot_snap, sr_len) && - (exe_cmp[sr_len] == '/' || exe_cmp[sr_len] == '\0')) { - exe = exe_cmp + sr_len; - if (*exe == '\0') - exe = "/"; - } - } + char exe_guest[LINUX_PATH_MAX]; + /* proc_set_sysroot stores a realpath()-canonicalized form, so canonicalize + * exe before the reverse map or the sysroot strip fails when /var -> + * /private/var (and similar macOS symlinks) make the two strings diverge. + * path_host_to_guest also decodes escaped components back to guest + * spellings, which a bare prefix strip would leak. Only an actual rewrite + * is adopted: an identity result keeps the original spelling, so a + * host-literal exe is not silently canonicalized (/tmp -> /private/tmp). + * + * With no sysroot configured the reverse map is an identity copy, so the + * canonicalization would be computed and then discarded; skip it there + * rather than pay one lstat per path component on every readlink. Testing + * proc_get_sysroot() for NULL without a snapshot is sanctioned (see + * proc.h). + */ + const char *exe_cmp = exe; + if (proc_get_sysroot() && realpath(exe, exe_real)) + exe_cmp = exe_real; + if (path_host_to_guest(exe_cmp, exe_guest, sizeof(exe_guest)) == 0 && + strcmp(exe_guest, exe_cmp)) + exe = exe_guest; size_t len = strlen(exe); if (len > bufsiz) len = bufsiz; @@ -3863,10 +3867,17 @@ int proc_intercept_readlink(const char *path, char *buf, size_t bufsiz) */ if (proc_rosetta_active() && !strcmp(fdpath, ROSETTA_PATH)) return proc_readlink_self_exe(buf, bufsiz); - size_t len = strlen(fdpath); + /* F_GETPATH reports the raw host path; the guest must see its own + * namespace (sysroot stripped, escaped components decoded). + */ + char guest_view[LINUX_PATH_MAX]; + const char *report = fdpath; + if (path_host_to_guest(fdpath, guest_view, sizeof(guest_view)) == 0) + report = guest_view; + size_t len = strlen(report); if (len > bufsiz) len = bufsiz; - memcpy(buf, fdpath, len); + memcpy(buf, report, len); return (int) len; } diff --git a/src/syscall/exec.c b/src/syscall/exec.c index 27b71dbb..a482b255 100644 --- a/src/syscall/exec.c +++ b/src/syscall/exec.c @@ -386,17 +386,16 @@ int64_t sys_execve(hv_vcpu_t vcpu, bool verbose, const char *host_path) { - /* Copy guest execve inputs before any state-reset point of no return. If - * host_path is provided (from execveat resolution), use it directly instead - * of reading from guest memory. This avoids writing host-resolved paths - * into guest address space. + /* Copy guest execve inputs before any state-reset point of no return. A + * provided host_path (from execveat resolution) is used directly for the + * exec open, but the guest-visible identity in path must carry the guest + * spelling: strip the sysroot and decode escaped components so + * /proc/self/exe never reports the private host namespace. */ char path[LINUX_PATH_MAX]; if (host_path) { - size_t len = strlen(host_path); - if (len >= LINUX_PATH_MAX) + if (path_host_to_guest(host_path, path, sizeof(path)) < 0) return -LINUX_ENAMETOOLONG; - memcpy(path, host_path, len + 1); } else if (guest_read_str(g, path_gva, path, sizeof(path)) < 0) { return -LINUX_EFAULT; } @@ -404,7 +403,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, log_debug("execve(\"%s\")", path); char path_host_buf[LINUX_PATH_MAX]; - const char *path_host = path; + const char *path_host = host_path ? host_path : path; bool path_host_temp = false; /* Whether path_host is a shm redirect leaf (drives O_NOFOLLOW on the exec * open). Re-evaluated whenever path_host is repointed to an interpreter. diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index 75d2209c..976f42b6 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -2177,10 +2177,29 @@ static int64_t sc_execveat(guest_t *g, char pathname[LINUX_PATH_MAX]; if (guest_read_str(g, x1, pathname, sizeof(pathname)) < 0) return -LINUX_EFAULT; + /* Translate like every other *at handler: under a casefold sysroot a + * guest-created entry exists on disk only under its escaped spelling, + * so handing the raw guest bytes to the host cannot resolve it (and + * could resolve a case-colliding host-literal file instead). The + * translated name stays dirfd-relative, so openat + F_GETPATH turn it + * into the absolute host path sys_execve needs. + */ + path_translation_t tx; + if (path_translate_at(dirfd, pathname, PATH_TR_NONE, &tx) < 0) + return linux_errno(); + if (tx.fuse_path || tx.proc_resolved != 0) + return -LINUX_ENOSYS; host_fd_ref_t dir_ref; - if (host_fd_ref_open(dirfd, &dir_ref) < 0) + if (host_dirfd_ref_open(dirfd, &dir_ref) < 0) return -LINUX_EBADF; - int tmp_fd = openat(dir_ref.fd, pathname, O_RDONLY); + /* O_CLOEXEC: the descriptor is closed a few lines below, but a + * concurrent execve on another vCPU thread inside that window would + * otherwise leak it into the new image. A shm redirect leaf forces + * nofollow; see dev_shm_resolve_path(). + */ + int tmp_fd = + openat(path_translation_dirfd(&tx, &dir_ref), tx.host_path, + O_RDONLY | O_CLOEXEC | (tx.is_dev_shm ? O_NOFOLLOW : 0)); if (tmp_fd < 0) { host_fd_ref_close(&dir_ref); return linux_errno(); diff --git a/tests/test-sysroot-exec-names.c b/tests/test-sysroot-exec-names.c new file mode 100644 index 00000000..036b3fc3 --- /dev/null +++ b/tests/test-sysroot-exec-names.c @@ -0,0 +1,209 @@ +/* + * exec identity under a case-fold sysroot + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * An executable under a case-protected directory exists on disk only under an + * escaped spelling, and exec crosses that boundary twice: the path being + * executed must be resolved like every other guest path, and the identity the + * kernel then reports (/proc/self/exe, /proc/self/fd/N) must carry the + * guest's bytes, never the stored spelling or the sysroot prefix. + * + * Linux contract pinned: execveat(2) resolves pathname relative to dirfd with + * the caller's namespace rules, and proc(5) says /proc/self/exe is a symlink + * to the executed binary as pathnames name it, a path the process can hand + * straight back to execve. + * + * Code under test: sc_execveat in src/syscall/syscall.c, sys_execve in + * src/syscall/exec.c, and proc_readlink_self_exe / proc_intercept_readlink in + * src/runtime/procemu.c. A regression shows up as execveat reporting ENOENT + * for a binary execve runs fine, or as /proc/self/exe naming an .ef= spelling, + * which is how a self-re-exec (busybox applets, watchdogs) stops working. + * + * Run under --sysroot on a case-folding volume. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +#ifndef SYS_execveat +#define SYS_execveat 281 +#endif +#ifndef AT_EMPTY_PATH +#define AT_EMPTY_PATH 0x1000 +#endif + +int passes = 0, fails = 0; + +#define DIR_A "/Apps" +#define RUNME DIR_A "/RunMe" + +/* Exit codes for the child lanes, chosen away from errno-shaped values. */ +#define EXIT_MATCH 0 +#define EXIT_MISMATCH 42 +#define EXIT_SETUP 43 + +/* Child lane: compare /proc/self/exe against argv[2]; an .ef= substring or a + * mismatch is a failure whoever spawned it. + */ +static int child_exe_is(const char *want, bool reexec) +{ + char buf[PATH_MAX]; + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + + if (n < 0) + return EXIT_SETUP; + buf[n] = '\0'; + if (strcmp(buf, want) || strstr(buf, ".ef=")) { + fprintf(stderr, "child: /proc/self/exe = %s, want %s\n", buf, want); + return EXIT_MISMATCH; + } + if (reexec) { + /* The reported identity must be live: hand it straight back to + * execve, as a self-re-exec does. + */ + char *argv2[] = {buf, "--exe-is", (char *) want, NULL}; + execve("/proc/self/exe", argv2, NULL); + return EXIT_SETUP; + } + return EXIT_MATCH; +} + +/* Copy this binary to @dst, mode 0755, through /proc/self/exe. */ +static int self_copy(const char *dst) +{ + char buf[65536]; + int in = open("/proc/self/exe", O_RDONLY); + int out; + ssize_t n; + + if (in < 0) + return -1; + out = open(dst, O_CREAT | O_WRONLY | O_TRUNC, 0755); + if (out < 0) { + close(in); + return -1; + } + while ((n = read(in, buf, sizeof(buf))) > 0) { + if (write(out, buf, (size_t) n) != n) { + n = -1; + break; + } + } + close(in); + close(out); + return n < 0 ? -1 : 0; +} + +/* Fork, run @fn in the child, and report its exit code. */ +static int wait_code(pid_t pid) +{ + int st; + + if (pid < 0 || waitpid(pid, &st, 0) != pid || !WIFEXITED(st)) + return -1; + return WEXITSTATUS(st); +} + +int main(int argc, char **argv) +{ + struct stat stbuf; + pid_t pid; + + if (argc == 3 && !strcmp(argv[1], "--exe-is")) + return child_exe_is(argv[2], false); + if (argc == 3 && !strcmp(argv[1], "--exe-is-reexec")) + return child_exe_is(argv[2], true); + + printf("test-sysroot-exec-names: exec identity in the guest namespace\n"); + + TEST("stage a copy of this binary under an escaped directory"); + EXPECT_TRUE((mkdir(DIR_A, 0755) == 0 || errno == EEXIST) && + self_copy(RUNME) == 0 && stat(RUNME, &stbuf) == 0 && + S_ISREG(stbuf.st_mode), + "stage"); + + /* The guard: plain execve of the staged copy, child checks its own + * identity. Held so a fix for the lanes below cannot trade this away. + */ + TEST("execve runs it and the child sees the guest path"); + pid = fork(); + if (pid == 0) { + char *argv2[] = {(char *) RUNME, "--exe-is", (char *) RUNME, NULL}; + execve(RUNME, argv2, NULL); + _exit(EXIT_SETUP); + } + EXPECT_EQ(wait_code(pid), EXIT_MATCH, "child exit"); + + /* execveat with a dirfd and a relative name is how fexecve-style runners + * reach a binary; the name is a guest name and must resolve like one. + */ + TEST("execveat(dirfd, name) runs an escaped binary"); + pid = fork(); + if (pid == 0) { + int dfd = open(DIR_A, O_RDONLY | O_DIRECTORY); + char *argv2[] = {(char *) RUNME, "--exe-is", (char *) RUNME, NULL}; + if (dfd < 0) + _exit(EXIT_SETUP); + syscall(SYS_execveat, dfd, "RunMe", argv2, NULL, 0); + _exit(errno == ENOENT ? EXIT_MISMATCH : EXIT_SETUP); + } + EXPECT_EQ(wait_code(pid), EXIT_MATCH, "child exit"); + + /* AT_EMPTY_PATH executes the fd itself; the identity the child then reads + * must still be the guest spelling, not the F_GETPATH host bytes. + */ + TEST("execveat(fd, \"\", AT_EMPTY_PATH) keeps the guest identity"); + pid = fork(); + if (pid == 0) { + int fd = open(RUNME, O_RDONLY); + char *argv2[] = {(char *) RUNME, "--exe-is-reexec", (char *) RUNME, + NULL}; + if (fd < 0) + _exit(EXIT_SETUP); + syscall(SYS_execveat, fd, "", argv2, NULL, AT_EMPTY_PATH); + _exit(EXIT_SETUP); + } + EXPECT_EQ(wait_code(pid), EXIT_MATCH, "child exit"); + + /* /proc/self/fd/N is the same reverse mapping on a different readlink. */ + TEST("readlink of /proc/self/fd/N reports the guest path"); + { + char proc_path[64]; + char buf[PATH_MAX]; + ssize_t n; + int fd = open(RUNME, O_RDONLY); + + if (fd < 0) { + FAIL("open"); + } else { + snprintf(proc_path, sizeof(proc_path), "/proc/self/fd/%d", fd); + n = readlink(proc_path, buf, sizeof(buf) - 1); + if (n < 0) { + FAIL("readlink"); + } else { + buf[n] = '\0'; + EXPECT_TRUE(!strcmp(buf, RUNME), + "fd path leaked a host spelling"); + if (strcmp(buf, RUNME)) + fprintf(stderr, " got %s\n", buf); + } + close(fd); + } + } + + SUMMARY("test-sysroot-exec-names"); + return fails > 0 ? 1 : 0; +} From 727d59b8748bae22dba668357bde6601d7caac7c Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 29 Jul 2026 21:45:26 +0200 Subject: [PATCH 09/11] Resolve PT_INTERP through the guest path walk The interpreter resolver kept its own three-strategy probe from the core loader: a literal sysroot concatenation checked with access(2), then /lib/, 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/ fallback still catches store-style interpreter paths. elf_resolve_interp stays with the core bootstrap, which probes its literal spellings first and already falls through to the same translation when both probes miss. 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. --- docs/internals.md | 16 +++++--- mk/tests.mk | 67 +++++++++++++++++++++++++++++++++ src/syscall/exec.c | 94 +++++++++++++++++++++++++++++++++++----------- tests/copy-arg.c | 52 +++++++++++++++++++++++++ tests/exec-arg.c | 28 ++++++++++++++ tests/mkdir-arg.c | 25 ++++++++++++ 6 files changed, 255 insertions(+), 27 deletions(-) create mode 100644 tests/copy-arg.c create mode 100644 tests/exec-arg.c create mode 100644 tests/mkdir-arg.c diff --git a/docs/internals.md b/docs/internals.md index 57008f36..c79afc3b 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -648,9 +648,9 @@ In `src/runtime/forkipc.c`: ### `execve` -`sys_execve` in `src/syscall/exec.c` reloads the ELF, loads the dynamic -interpreter for dynamically-linked targets via the shared -`elf_resolve_interp()` helper (also used at startup), rebuilds page tables, +`sys_execve` in `src/syscall/exec.c` reloads the ELF, resolves the dynamic +interpreter for dynamically-linked targets through `path_translate_at()` +like any other guest path, rebuilds page tables, and restarts the vCPU. Signal handlers are reset to `SIG_DFL` per POSIX: `SIG_IGN` stays `SIG_IGN`, and pending and blocked masks are preserved. This happens in `signal_reset_for_exec()` after `guest_reset`. @@ -882,8 +882,14 @@ How it works: The sysroot is inherited by fork children via IPC state transfer. `sys_execve` also loads the interpreter for dynamically linked targets, so tools that `execve` dynamic children (`env`, `nice`, `nohup`) work -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` in different orders. +Guest-issued execs route it through `path_translate_at()` like any other +guest path. The initial process is loaded by the core bootstrap +(`load_interpreter()` in `src/core/bootstrap.c`), which probes +`elf_resolve_interp()` (`src/core/elf.c`) first, a literal sysroot +concatenation plus a `/lib/` fallback, and only when both +probes miss does it fall through to `path_translate_at()`, materializing +a FUSE interpreter and refusing one in `/dev/shm`. ### Known Limitations diff --git a/mk/tests.mk b/mk/tests.mk index a29ec82f..d6f5cc32 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -18,6 +18,7 @@ test-sysroot-procfs-exec test-timeout-disable test-fuse-alpine \ test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \ test-sysroot-inotify-names test-sysroot-exec-names \ + test-sysroot-interp-fallback test-sysroot-interp-cased \ test-linkat-symlink-fallback test-casefold-host \ test-casefold-walk-host test-sysroot-name-unique \ test-sysroot-name-relative \ @@ -186,6 +187,10 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-sysroot-inotify-names @printf "\n$(BLUE)━━━ exec identity across the escape boundary ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-exec-names + @printf "\n$(BLUE)━━━ PT_INTERP /lib fallback ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-interp-fallback + @printf "\n$(BLUE)━━━ PT_INTERP through an escaped path ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-interp-cased @printf "\n$(BLUE)━━━ sysroot mounted at / ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-root @printf "\n$(BLUE)━━━ literal names without a sysroot ━━━$(RESET)\n" @@ -473,6 +478,68 @@ test-sysroot-symlink-target: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-symlink-tar exit 1; \ fi +# PT_INTERP names the loader by the guest's spelling, and a rootfs may ship +# it somewhere other than where the binary asks (store-style paths). The +# interp resolver falls back to /lib/ when the asked-for path does +# not resolve; this pins the fallback with an absent /nix-style prefix. +## PT_INTERP falls back to /lib/ for an absent store path +test-sysroot-interp-fallback: $(ELFUSE_BIN) + @if [ -z "$(SYSROOT_DIR)" ] || \ + [ ! -f "$(SYSROOT_DIR)/lib/ld-musl-aarch64.so.1" ] || \ + [ ! -f "$(DYNAMIC_COREUTILS_BIN)/echo" ]; then \ + printf "$(YELLOW)SKIP$(RESET) test-sysroot-interp-fallback (musl fixtures missing)\n"; \ + exit 0; \ + fi; \ + set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + cp "$(DYNAMIC_COREUTILS_BIN)/echo" "$$tmpdir/echo"; \ + off=$$(grep -abo '/lib/ld-musl-aarch64.so.1' "$$tmpdir/echo" | head -1 | cut -d: -f1); \ + [ -n "$$off" ]; \ + printf '/nix' | dd of="$$tmpdir/echo" bs=1 seek="$$off" conv=notrunc 2>/dev/null; \ + out=$$($(ELFUSE_BIN) --sysroot $(SYSROOT_DIR) "$$tmpdir/echo" interp-fallback-ok); \ + [ "$$out" = "interp-fallback-ok" ] + +# The loader itself may live under a case-protected path: the guest stages a +# copy of the musl loader below /NiX (stored escaped), and the patched binary +# asks for it by that spelling with a basename /lib does not carry, so only +# a resolution that walks the escape can launch it, and the /lib fallback +# cannot mask a regression. The exec goes through a guest trampoline because +# the initial process is loaded by the core bootstrap, which resolves +# PT_INTERP by literal concatenation plus the /lib fallback only. +## PT_INTERP resolves through an escaped path +test-sysroot-interp-cased: $(ELFUSE_BIN) $(BUILD_DIR)/mkdir-arg \ + $(BUILD_DIR)/copy-arg $(BUILD_DIR)/exec-arg + @if [ -z "$(SYSROOT_DIR)" ] || \ + [ ! -f "$(SYSROOT_DIR)/lib/ld-musl-aarch64.so.1" ] || \ + [ ! -f "$(DYNAMIC_COREUTILS_BIN)/echo" ]; then \ + printf "$(YELLOW)SKIP$(RESET) test-sysroot-interp-cased (musl fixtures missing)\n"; \ + exit 0; \ + fi; \ + set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + sysroot="$$tmpdir/sysroot"; \ + cp -R "$(SYSROOT_DIR)" "$$sysroot"; \ + mkdir -p "$$sysroot/bin"; \ + cp $(BUILD_DIR)/mkdir-arg "$$sysroot/bin/mkdir-arg"; \ + cp $(BUILD_DIR)/copy-arg "$$sysroot/bin/copy-arg"; \ + cp $(BUILD_DIR)/exec-arg "$$sysroot/bin/exec-arg"; \ + $(ELFUSE_BIN) --sysroot "$$sysroot" "$$sysroot/bin/mkdir-arg" /NiX; \ + $(ELFUSE_BIN) --sysroot "$$sysroot" "$$sysroot/bin/copy-arg" \ + /lib/ld-musl-aarch64.so.1 /NiX/xd-musl-aarch64.so.1; \ + cp "$(DYNAMIC_COREUTILS_BIN)/echo" "$$sysroot/echo-cased"; \ + off=$$(grep -abo '/lib/ld-musl-aarch64.so.1' "$$sysroot/echo-cased" | head -1 | cut -d: -f1); \ + [ -n "$$off" ]; \ + printf '/NiX/xd-musl' | dd of="$$sysroot/echo-cased" bs=1 seek="$$off" conv=notrunc 2>/dev/null; \ + out=$$($(ELFUSE_BIN) --sysroot "$$sysroot" "$$sysroot/bin/exec-arg" \ + /echo-cased echo interp-cased-ok); \ + [ "$$out" = "interp-cased-ok" ]; \ + if [ -e "$$sysroot/NiX" ]; then \ + printf "$(RED)FAIL$(RESET) NiX was stored literally\n"; \ + exit 1; \ + fi + # Exec crosses the escape boundary twice: the path being executed resolves # like every other guest path, and the identity the kernel reports back # (/proc/self/exe, /proc/self/fd/N) carries guest bytes. The recipe asserts diff --git a/src/syscall/exec.c b/src/syscall/exec.c index a482b255..1b972672 100644 --- a/src/syscall/exec.c +++ b/src/syscall/exec.c @@ -209,31 +209,85 @@ static int exec_resolve_guest_host_path(const char *guest_path, return 0; } -static int exec_resolve_interp_host_path(const char *sysroot, - const char *interp_guest_path, +/* A translated interpreter path is usable when it is a FUSE temp copy, or when + * translation rewrote the spelling AND the rewritten file actually exists. + * The existence probe is what separates a real sysroot hit from an escaped + * prefix whose loader suffix is absent: without it a differs-but-missing path + * would be accepted and the /lib/ fallback skipped, so a store-style + * interpreter (e.g. a /nix/.../ld-musl.so.1 that ships the loader under /lib) + * would fail to launch. + * + * A shm redirect is probed without following the leaf, because that is the + * rule exec_open_image then opens under. access(2) follows, so a symlink in + * the shm backing directory would answer "usable" for a path the O_NOFOLLOW + * open refuses with ELOOP, and the fallback that would have found the loader + * is skipped. Elsewhere following is right: an interpreter is routinely a + * symlink, as /lib/ld-musl-aarch64.so.1 is on musl images. + */ +static bool exec_translated_usable(const char *host, + const char *guest, + bool temp, + bool shm_nofollow) +{ + if (temp) + return true; + if (!strcmp(host, guest)) + return false; + if (shm_nofollow) { + struct stat st; + return lstat(host, &st) == 0 && !S_ISLNK(st.st_mode); + } + return access(host, F_OK) == 0; +} + +/* Resolve PT_INTERP through the same translation as every other guest path, + * so a sysroot interpreter is found with its stored spelling, containment, + * and FUSE materialization applied. The bootstrap loader differs: it probes + * elf_resolve_interp's literal spellings first and falls through to + * path_translate_at only when they miss; see load_interpreter in + * src/core/bootstrap.c. + */ +static int exec_resolve_interp_host_path(const char *interp_guest_path, char *interp_host_path, size_t interp_host_path_sz, bool *interp_host_temp, bool *shm_nofollow) { - char interp_candidate[LINUX_PATH_MAX]; - elf_resolve_interp(sysroot, interp_guest_path, interp_candidate, - sizeof(interp_candidate)); *shm_nofollow = false; - if (strcmp(interp_candidate, interp_guest_path) != 0) { - size_t len = str_copy_trunc(interp_host_path, interp_candidate, - interp_host_path_sz); - if (len >= interp_host_path_sz) { - errno = ENAMETOOLONG; - return -1; - } - *interp_host_temp = false; + if (exec_resolve_guest_host_path(interp_guest_path, interp_host_path, + interp_host_path_sz, interp_host_temp, + shm_nofollow) < 0) + return -1; + if (exec_translated_usable(interp_host_path, interp_guest_path, + *interp_host_temp, *shm_nofollow)) return 0; - } - return exec_resolve_guest_host_path(interp_guest_path, interp_host_path, - interp_host_path_sz, interp_host_temp, - shm_nofollow); + /* Literal fallback: before accepting it, try the image's /lib for the + * loader's basename. Store-style interpreter paths such as + * /nix/.../lib/ld-musl-aarch64.so.1 ship the loader under /lib. + */ + const char *base = strrchr(interp_guest_path, '/'); + base = base ? base + 1 : interp_guest_path; + char lib_guest[LINUX_PATH_MAX]; + int n = snprintf(lib_guest, sizeof(lib_guest), "/lib/%s", base); + if (n > 0 && (size_t) n < sizeof(lib_guest)) { + char lib_host[LINUX_PATH_MAX]; + bool lib_temp = false; + bool lib_shm = false; + if (exec_resolve_guest_host_path(lib_guest, lib_host, sizeof(lib_host), + &lib_temp, &lib_shm) == 0 && + exec_translated_usable(lib_host, lib_guest, lib_temp, lib_shm)) { + size_t len = + str_copy_trunc(interp_host_path, lib_host, interp_host_path_sz); + if (len >= interp_host_path_sz) { + errno = ENAMETOOLONG; + return -1; + } + *interp_host_temp = lib_temp; + *shm_nofollow = lib_shm; + } + } + return 0; } /* Read a NULL-terminated pointer array from guest memory. Each pointer in the @@ -761,11 +815,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, */ bool interp_shm = false; if (!target_is_rosetta && elf_info.interp_path[0] != '\0') { - char sysroot_snap[LINUX_PATH_MAX]; - bool have_sr = - proc_sysroot_snapshot(sysroot_snap, sizeof(sysroot_snap)); - if (exec_resolve_interp_host_path(have_sr ? sysroot_snap : NULL, - elf_info.interp_path, interp_resolved, + if (exec_resolve_interp_host_path(elf_info.interp_path, interp_resolved, sizeof(interp_resolved), &interp_host_temp, &interp_shm) < 0) { log_error("execve: failed to resolve interpreter: %s", diff --git a/tests/copy-arg.c b/tests/copy-arg.c new file mode 100644 index 00000000..3a469bec --- /dev/null +++ b/tests/copy-arg.c @@ -0,0 +1,52 @@ +/* + * copy helper + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Copy argv[1] to argv[2], mode 0755. Sysroot recipes run this as a guest so + * the destination is created through the guest: on a folding volume a + * case-protected destination is then stored under its escape, which is the + * on-disk shape a later resolution in the same recipe must cross. + */ + +#include +#include +#include +#include + +#include "test-util.h" + +int main(int argc, char **argv) +{ + char buf[65536]; + ssize_t n; + + if (argc != 3) { + fprintf(stderr, "usage: copy-arg SRC DST\n"); + return 1; + } + int in = open(argv[1], O_RDONLY); + if (in < 0) + return perror(argv[1]), 1; + int out = open(argv[2], O_CREAT | O_WRONLY | O_TRUNC, 0755); + if (out < 0) + return perror(argv[2]), 1; + /* write_fd_all, not a bare write: the destination goes through the FUSE + * write path, where a short count is a success that must be resumed, and + * write(2) leaves errno untouched on one. The read retries EINTR for the + * same reason: an interrupted copy is not a failed one. + */ + for (;;) { + n = read(in, buf, sizeof(buf)); + if (n < 0 && errno == EINTR) + continue; + if (n <= 0) + break; + if (write_fd_all(out, buf, (size_t) n) != 0) + return perror("write"), 1; + } + close(in); + close(out); + return n < 0 ? 1 : 0; +} diff --git a/tests/exec-arg.c b/tests/exec-arg.c new file mode 100644 index 00000000..3d4859c5 --- /dev/null +++ b/tests/exec-arg.c @@ -0,0 +1,28 @@ +/* + * execve helper + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Replace this process with argv[1], handing it argv[2..] as its own argv. + * argv[2] is the target's argv[0], separate from the path because a multicall + * binary dispatches on it. Sysroot recipes run this as a guest so the target + * goes through the guest execve path: the initial process is loaded by the + * core bootstrap, which resolves PT_INTERP by literal concatenation plus the + * /lib fallback only, so a lane about interpreter resolution through escaped + * spellings has to exec from inside the guest. + */ + +#include +#include + +int main(int argc, char **argv) +{ + if (argc < 3) { + fprintf(stderr, "usage: exec-arg PROG ARGV0 [ARGS...]\n"); + return 1; + } + execv(argv[1], argv + 2); + perror(argv[1]); + return 1; +} diff --git a/tests/mkdir-arg.c b/tests/mkdir-arg.c new file mode 100644 index 00000000..5154a128 --- /dev/null +++ b/tests/mkdir-arg.c @@ -0,0 +1,25 @@ +/* + * mkdir helper + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Create each argv path (mode 0755), tolerating EEXIST. Sysroot recipes run + * this as a guest so a directory is created through the guest: on a folding + * volume a case-protected name is then stored under its escape, which is the + * on-disk shape a later resolution in the same recipe must cross. + */ + +#include +#include +#include + +int main(int argc, char **argv) +{ + for (int i = 1; i < argc; i++) + if (mkdir(argv[i], 0755) != 0 && errno != EEXIST) { + perror(argv[i]); + return 1; + } + return 0; +} From 15c1de30fa2a71ae0bcc866e373bbe880748b9cf Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 29 Jul 2026 23:32:27 +0200 Subject: [PATCH 10/11] Translate pathname AF_UNIX socket addresses 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. --- docs/filenames.md | 16 ++ mk/tests.mk | 60 +++++ src/syscall/net-absock.c | 312 +++++++++++++++++++++- src/syscall/net-absock.h | 25 +- src/syscall/net-msg.c | 22 +- src/syscall/net.c | 19 +- tests/test-absock-cleanup.c | 206 +++++++++++++++ tests/test-sysroot-absock-names.c | 416 ++++++++++++++++++++++++++++++ 8 files changed, 1047 insertions(+), 29 deletions(-) create mode 100644 tests/test-absock-cleanup.c create mode 100644 tests/test-sysroot-absock-names.c diff --git a/docs/filenames.md b/docs/filenames.md index e4e8534e..c3a45d7a 100644 --- a/docs/filenames.md +++ b/docs/filenames.md @@ -291,6 +291,22 @@ arrange, since elfuse writes one or the other), a lookup takes the literal one. The escaped entry is then unreachable under any guest name, though a listing still reports the name twice. +## The escape never reaches the guest + +Everything the kernel reports back to the guest decodes stored spellings to +guest bytes first. Directory listings (`getdents64`) and inotify event names +decode each entry as it is read, but only for directories the sysroot holds: +a host directory reached through the fallback reports its entries as stored, +because those names are the host's and an escape-shaped literal there means +itself. `getcwd`, `/proc/self/cwd`, +`/proc/self/exe`, `/proc/self/fd/N`, and the addresses returned for pathname +AF_UNIX sockets (`getsockname`, `getpeername`, `accept`, `recvfrom`) map a +whole host path back through the sysroot strip and the same per-component +decode. The forward direction is symmetric: watches, exec targets, +`PT_INTERP` interpreters, and socket addresses resolve through the ordinary +path translation, so a name spelled by the guest and a name reported to the +guest always mean the same file. + ## Why nothing is locked Which spelling a guest name takes is decided by the name, so a create is one diff --git a/mk/tests.mk b/mk/tests.mk index d6f5cc32..3fe606be 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -19,6 +19,7 @@ test-sysroot-nofollow test-sysroot-chdir test-sysroot-symlink-escape \ test-sysroot-inotify-names test-sysroot-exec-names \ test-sysroot-interp-fallback test-sysroot-interp-cased \ + test-sysroot-absock-names test-absock-cleanup \ test-linkat-symlink-fallback test-casefold-host \ test-casefold-walk-host test-sysroot-name-unique \ test-sysroot-name-relative \ @@ -191,6 +192,10 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-sysroot-interp-fallback @printf "\n$(BLUE)━━━ PT_INTERP through an escaped path ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-interp-cased + @printf "\n$(BLUE)━━━ pathname sockets across the escape boundary ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-absock-names + @printf "\n$(BLUE)━━━ absock namespace lifecycle ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-absock-cleanup @printf "\n$(BLUE)━━━ sysroot mounted at / ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-root @printf "\n$(BLUE)━━━ literal names without a sysroot ━━━$(RESET)\n" @@ -478,6 +483,61 @@ test-sysroot-symlink-target: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-symlink-tar exit 1; \ fi +# A pathname socket's address is a filesystem path and resolves like one. +# The recipe asserts the host-side half: the socket the guest leaves bound +# sits inside the sysroot only under its escape, nothing landed at the +# host-literal spelling, and the private shortening-link dir is swept on +# exit. +## pathname AF_UNIX socket addresses resolve through the sysroot +test-sysroot-absock-names: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-absock-names + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + before=$$(ls -d /tmp/elfuse-absock-* 2>/dev/null | wc -l | tr -d ' '); \ + had_sockdir=0; [ -e "/sockdir" ] && had_sockdir=1; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-absock-names; \ + if [ "$$had_sockdir" = 0 ] && [ -e "/sockdir" ]; then \ + printf "$(RED)FAIL$(RESET) a socket path escaped to the host root\n"; \ + exit 1; \ + fi; \ + if ls -A "$$tmpdir/sockdir" | grep -qx 'Sock'; then \ + printf "$(RED)FAIL$(RESET) Sock was stored literally\n"; \ + ls -Ab "$$tmpdir/sockdir"; \ + exit 1; \ + fi; \ + after=$$(ls -d /tmp/elfuse-absock-* 2>/dev/null | wc -l | tr -d ' '); \ + if [ "$$after" -gt "$$before" ]; then \ + printf "$(RED)FAIL$(RESET) absock namespace dir leaked ($$before -> $$after)\n"; \ + exit 1; \ + fi + +# The absock namespace dir is shared across a forked guest tree, so neither +# exit order may destroy state the other side still needs; the recipe also +# asserts the dir itself does not leak. +## absock namespace lifecycle across fork and exit order +test-absock-cleanup: $(ELFUSE_BIN) $(BUILD_DIR)/test-absock-cleanup + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + before=$$(ls -d /tmp/elfuse-absock-* 2>/dev/null | wc -l | tr -d ' '); \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" $(BUILD_DIR)/test-absock-cleanup; \ + printf " %-30s " "owner sweep spares live child"; \ + out=$$($(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-absock-cleanup owner-sweep 2>/dev/null); \ + verdict=$$(printf '%s\n' "$$out" | sed -n 's/^OWNER_SWEEP=//p'); \ + if [ "$$verdict" = ok ]; then \ + printf "OK\n"; \ + else \ + printf "FAIL: child socket %s\n" "$${verdict:-unreported}"; \ + exit 1; \ + fi; \ + after=$$(ls -d /tmp/elfuse-absock-* 2>/dev/null | wc -l | tr -d ' '); \ + if [ "$$after" -gt "$$before" ]; then \ + printf "$(RED)FAIL$(RESET) absock namespace dir leaked ($$before -> $$after)\n"; \ + exit 1; \ + fi + # PT_INTERP names the loader by the guest's spelling, and a rootfs may ship # it somewhere other than where the binary asks (store-style paths). The # interp resolver falls back to /lib/ when the asked-for path does diff --git a/src/syscall/net-absock.c b/src/syscall/net-absock.c index 180ee1ad..47950795 100644 --- a/src/syscall/net-absock.c +++ b/src/syscall/net-absock.c @@ -5,6 +5,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include +#include #include #include #include @@ -21,11 +23,21 @@ #include "utils.h" +#include "syscall/abi.h" +#include "syscall/internal.h" #include "syscall/net.h" +#include "syscall/net-abi.h" #include "syscall/net-absock.h" +#include "syscall/path.h" #define ABSOCK_MAX_ENTRIES 64 #define ABSOCK_MAX_NAME 107 +/* Linux struct sockaddr_un carries at most 108 sun_path bytes. */ +#define LINUX_UNIX_PATH_MAX 108 +/* Width of the leading sun_family field, so the offset at which sun_path + * begins and the length arithmetic around it cannot drift apart. + */ +#define LINUX_SA_FAMILY_LEN 2 typedef struct { int guest_fd; @@ -42,6 +54,19 @@ static bool absock_dir_created; static _Atomic uint64_t absock_namespace_id; static _Atomic uint32_t absock_autobind_counter; +static void absock_cleanup(void); + +/* Spell the namespace directory for @namespace_id. Shared so a reader can + * name the directory without depending on this process being the one that + * created it, which is not the same thing: fork spawns a fresh process that + * inherits the id, and it still has to undo a link a sibling made. + */ +static int absock_dir_format(char *out, size_t out_sz, uint64_t namespace_id) +{ + return snprintf(out, out_sz, "/tmp/elfuse-absock-%llu", + (unsigned long long) namespace_id); +} + static int absock_ensure_dir_locked(void) { uint64_t namespace_id = atomic_load(&absock_namespace_id); @@ -53,14 +78,23 @@ static int absock_ensure_dir_locked(void) namespace_id = (uint64_t) getpid(); atomic_store(&absock_namespace_id, namespace_id); } - snprintf(absock_dir, sizeof(absock_dir), "/tmp/elfuse-absock-%llu", - (unsigned long long) namespace_id); + absock_dir_format(absock_dir, sizeof(absock_dir), namespace_id); /* The namespace-id path is guessable; create_private_dir rejects a * pre-planted symlink or foreign-owned directory in world-writable /tmp. */ if (create_private_dir(absock_dir) < 0) return -1; + /* Arm the exit sweep here, the one point where on-disk namespace state + * first appears. Every producer of that state (abstract bind, autobind, + * connect rewrite, and the pathname-socket shortening links) reaches the + * dir through this function, so a single registration covers them all. + * Reaching this line already means absock_dir_created was false and the + * directory was just created, and it is set below and never cleared, so + * this runs exactly once and needs no separate guard. + */ + atexit(absock_cleanup); + absock_dir_created = true; return 0; } @@ -176,10 +210,10 @@ int absock_is_abstract_unix(const uint8_t *linux_sa, uint32_t addrlen) if (addrlen < 4) return 0; uint16_t fam; - memcpy(&fam, linux_sa, 2); + memcpy(&fam, linux_sa, LINUX_SA_FAMILY_LEN); if (fam != LINUX_AF_UNIX) return 0; - return linux_sa[2] == '\0'; + return linux_sa[LINUX_SA_FAMILY_LEN] == '\0'; } static int absock_build_sun(const char *fs_path, @@ -196,6 +230,224 @@ static int absock_build_sun(const char *fs_path, return (int) (offsetof(struct sockaddr_un, sun_path) + path_len + 1); } +/* Point a short symlink in the private absock dir at an over-long translated + * socket path so it fits sun_path. bind(2) through a dangling symlink creates + * the socket at the target and connect(2) follows it (probed on macOS 15). + * Forked guests share the namespace dir, so a losing EEXIST race is accepted + * when the existing link already names the same target. + */ +static int absock_shorten_path(const char *host_path, char *out, size_t out_sz) +{ + pthread_mutex_lock(&absock_lock); + if (absock_ensure_dir_locked() < 0) { + pthread_mutex_unlock(&absock_lock); + return -1; + } + absock_encode_name((const uint8_t *) host_path, + (uint32_t) strlen(host_path), out, out_sz); + /* Create-first, never unlink a matching link: absock_lock is + * per-process, so an unconditional unlink could yank a forked sibling's + * just-created link between its shorten and its bind(2). The encoded + * name is derived from the target, so an existing link with the same + * name almost always already points at the right place. + */ + if (symlink(host_path, out) < 0) { + char existing[LINUX_PATH_MAX]; + ssize_t n = -1; + if (errno == EEXIST) + n = readlink(out, existing, sizeof(existing) - 1); + if (n < 0 || (size_t) n != strlen(host_path) || + /* cppcheck-suppress legacyUninitvar + * Short-circuit || guarantees memcmp only runs when n == + * strlen(host_path) and readlink filled exactly + * existing[0..n-1] on success. + */ + memcmp(existing, host_path, (size_t) n)) { + /* Stale or foreign entry under the derived name: replace it. */ + (void) unlink(out); + if (symlink(host_path, out) < 0) { + pthread_mutex_unlock(&absock_lock); + return -1; + } + } + } + pthread_mutex_unlock(&absock_lock); + return 0; +} + +/* Reverse-map one returned pathname AF_UNIX address to its guest spelling. + * Returns the Linux sockaddr length when the address was rewritten, or -1 + * when it is not a translated pathname and the generic converter should run. + */ +static int absock_sockaddr_un_from_mac(const struct sockaddr_un *sun, + uint32_t mac_len, + uint8_t *linux_sa, + uint32_t linux_sa_size) +{ + /* Bound every read by mac_len: macOS may fill all 104 sun_path bytes + * with no terminator, and only mac_len bytes of the caller's + * sockaddr_storage are initialized. + */ + size_t sp_max = mac_len - offsetof(struct sockaddr_un, sun_path); + if (sp_max > sizeof(sun->sun_path)) + sp_max = sizeof(sun->sun_path); + size_t sp_len = strnlen(sun->sun_path, sp_max); + if (sp_len == 0) + return -1; + char mac_path[sizeof(sun->sun_path) + 1]; + memcpy(mac_path, sun->sun_path, sp_len); + mac_path[sp_len] = '\0'; + + /* Undo the over-length shortening symlink, then map the host path back + * to the guest namespace so the guest reads back the spelling it bound + * or connected with, not the sysroot-prefixed (and possibly escaped) + * host path. + */ + char host_path[LINUX_PATH_MAX]; + str_copy_trunc(host_path, mac_path, sizeof(host_path)); + /* Compare against the whole directory component, not a byte prefix of it: + * namespace 1234 would otherwise claim the paths of namespace 12345 and + * readlink a directory it does not own. + */ + char ns_dir[sizeof(absock_dir)]; + int ns_len = + absock_dir_format(ns_dir, sizeof(ns_dir), absock_get_namespace_id()); + if (ns_len > 0 && (size_t) ns_len < sizeof(ns_dir) && + !strncmp(host_path, ns_dir, (size_t) ns_len) && + host_path[ns_len] == '/') { + char target[LINUX_PATH_MAX]; + ssize_t n = readlink(host_path, target, sizeof(target) - 1); + if (n > 0) { + target[n] = '\0'; + str_copy_trunc(host_path, target, sizeof(host_path)); + } + } + + char guest_path[LINUX_PATH_MAX]; + if (path_host_to_guest(host_path, guest_path, sizeof(guest_path)) != 0 || + !strcmp(guest_path, mac_path)) + return -1; + + /* Write the Linux sockaddr directly: the guest may have bound a + * Linux-legal name longer than the 103 usable bytes of a macOS + * sun_path, and rebuilding a mac sockaddr first would fail for exactly + * the paths the shortening symlink serves. + */ + size_t glen = strlen(guest_path); + if (glen > LINUX_UNIX_PATH_MAX || linux_sa_size < LINUX_SA_FAMILY_LEN) + return -1; + uint16_t fam16 = LINUX_AF_UNIX; + memcpy(linux_sa, &fam16, LINUX_SA_FAMILY_LEN); + uint32_t avail = linux_sa_size - LINUX_SA_FAMILY_LEN; + uint32_t copy = (uint32_t) glen; + if (glen < LINUX_UNIX_PATH_MAX) + copy++; /* include the terminator, kernel-style */ + if (copy > avail) + copy = avail; + memcpy(linux_sa + LINUX_SA_FAMILY_LEN, guest_path, copy); + return (int) (LINUX_SA_FAMILY_LEN + copy); +} + +int net_sockaddr_from_mac(const struct sockaddr *mac_sa, + uint32_t mac_len, + uint8_t *linux_sa, + uint32_t linux_sa_size) +{ + if (mac_sa && mac_len > offsetof(struct sockaddr_un, sun_path) && + mac_sa->sa_family == AF_UNIX) { + int rc = + absock_sockaddr_un_from_mac((const struct sockaddr_un *) mac_sa, + mac_len, linux_sa, linux_sa_size); + if (rc >= 0) + return rc; + } + return mac_to_linux_sockaddr(mac_sa, (socklen_t) mac_len, linux_sa, + linux_sa_size); +} + +/* linux_errno() reports an int64_t syscall result, while these converters + * return int to match linux_to_mac_sockaddr, the sibling they stand in for. + * Every Linux errno is well under 4096 in magnitude, so the narrowing is + * exact; it is spelled out rather than left implicit so it does not read as + * an accident, and so -Wnarrowing-style analysis stays quiet about it. + */ +static int absock_errno(void) +{ + return (int) linux_errno(); +} + +int net_sockaddr_to_mac(const uint8_t *linux_sa, + uint32_t addrlen, + bool create, + struct sockaddr_storage *mac_sa) +{ + uint16_t fam = 0; + if (addrlen >= LINUX_SA_FAMILY_LEN) + memcpy(&fam, linux_sa, LINUX_SA_FAMILY_LEN); + + if (fam == LINUX_AF_UNIX && addrlen > LINUX_SA_FAMILY_LEN && + linux_sa[LINUX_SA_FAMILY_LEN] != '\0') { + /* Pathname socket: the name is a filesystem path and must go through + * sysroot translation like every other path-taking syscall; the raw + * bytes would name the unrelated host-literal file. Linux permits an + * unterminated sun_path, so bound the copy by addrlen. A bind uses + * create semantics, which spell an absent case-protected leaf at its + * escape, so the socket file lands where stat and connect will look; + * a name already bound resolves to the occupied spelling and the + * host bind reports EADDRINUSE, matching Linux. + */ + char guest_path[LINUX_UNIX_PATH_MAX + 1]; + uint32_t plen = addrlen - LINUX_SA_FAMILY_LEN; + if (plen > LINUX_UNIX_PATH_MAX) + return -LINUX_EINVAL; + memcpy(guest_path, linux_sa + LINUX_SA_FAMILY_LEN, plen); + guest_path[plen] = '\0'; + + path_translation_t tx; + if (path_translate_at(LINUX_AT_FDCWD, guest_path, + create ? PATH_TR_CREATE : PATH_TR_NONE, &tx) < 0) + return absock_errno(); + if (tx.fuse_path || tx.proc_resolved != 0) + return -LINUX_ENOSYS; + + /* A shm leaf carries the never-follow rule, but bind(2) and connect(2) + * take a sockaddr rather than a dirfd and at_flags, so it cannot ride + * on an open flag here and is checked outright. Following a + * guest-planted link would bind the socket at the link's target, + * outside the tree entirely, 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 exactly what + * is_guest_system_path() denies the guest by name. See + * dev_shm_resolve_path() in procemu.c. + */ + if (tx.is_dev_shm) { + struct stat leaf_st; + if (lstat(tx.host_path, &leaf_st) == 0 && S_ISLNK(leaf_st.st_mode)) + return -LINUX_ELOOP; + } + + char short_path[sizeof(((struct sockaddr_un *) 0)->sun_path)]; + const char *host_path = tx.host_path; + if (strlen(host_path) >= sizeof(short_path)) { + /* Surface the real failure (EACCES, EIO, ENOSPC, ...): the guest + * name is Linux-legal, so reporting ENAMETOOLONG would + * misattribute a symlink-layer error to the pathname length. + */ + if (absock_shorten_path(host_path, short_path, sizeof(short_path)) < + 0) + return absock_errno(); + host_path = short_path; + } + int mac_len = absock_build_sun(host_path, mac_sa); + if (mac_len < 0) + return -LINUX_ENAMETOOLONG; + return mac_len; + } + + int mac_len = linux_to_mac_sockaddr(linux_sa, addrlen, mac_sa); + return mac_len < 0 ? -LINUX_EINVAL : mac_len; +} + int absock_rewrite_connect(const uint8_t *linux_sa, uint32_t addrlen, struct sockaddr_storage *mac_sa) @@ -283,19 +535,53 @@ void absock_bind_rollback(int idx) static void absock_cleanup(void) { + /* Every process unlinks its own table-tracked sockets. A forked child + * starts from an empty table because fork hands over only the namespace + * id, so an entry here is never a sibling's. + */ for (int i = 0; i < ABSOCK_MAX_ENTRIES; i++) { if (absock_table[i].active) unlink(absock_table[i].fs_path); } - if (absock_dir_created) - rmdir(absock_dir); -} -void absock_init_cleanup(void) -{ - static int registered; - if (!registered) { - atexit(absock_cleanup); - registered = 1; + if (!absock_dir_created) + return; + + /* The shortening links are untracked, so no table owns them and the + * process that minted the namespace retires them on its way out. Sweep + * only symlinks: the abstract-socket backing files sharing this directory + * belong to whichever process bound them, including children that outlive + * the owner, and each unlinks its own above. Unlinking those too would + * destroy a socket a live child still has bound. A live child that loses + * a shortening link only degrades to the raw host link path, never to a + * missing socket. + */ + if ((uint64_t) getpid() == absock_get_namespace_id()) { + DIR *d = opendir(absock_dir); + if (d) { + struct dirent *de; + while ((de = readdir(d)) != NULL) { + /* A filesystem may decline to classify an entry inline and + * report DT_UNKNOWN, so fall back to a nofollow stat there. + */ + bool is_link = de->d_type == DT_LNK; + if (de->d_type == DT_UNKNOWN) { + struct stat st; + is_link = fstatat(dirfd(d), de->d_name, &st, + AT_SYMLINK_NOFOLLOW) == 0 && + S_ISLNK(st.st_mode); + } + if (!is_link) + continue; + unlinkat(dirfd(d), de->d_name, 0); + } + closedir(d); + } } + + /* Any participant may retire the directory, not just the owner: rmdir + * succeeds only once it is empty, so the last one out removes it and a + * namespace whose directory was created by a forked child cannot leak. + */ + rmdir(absock_dir); } diff --git a/src/syscall/net-absock.h b/src/syscall/net-absock.h index c2b874e1..46379954 100644 --- a/src/syscall/net-absock.h +++ b/src/syscall/net-absock.h @@ -7,10 +7,34 @@ #pragma once +#include #include #include int absock_is_abstract_unix(const uint8_t *linux_sa, uint32_t addrlen); + +/* Convert a guest sockaddr to the host form. Pathname AF_UNIX addresses go + * through sysroot path translation (create semantics for bind, lookup for + * connect/sendto/sendmsg), with over-long translated paths diverted through + * a short symlink in the private absock dir; every other family delegates to + * linux_to_mac_sockaddr. Returns the mac sockaddr length, or a negative + * LINUX errno usable directly as the syscall result. + */ +int net_sockaddr_to_mac(const uint8_t *linux_sa, + uint32_t addrlen, + bool create, + struct sockaddr_storage *mac_sa); + +/* Convert a host sockaddr back to the guest form. Pathname AF_UNIX + * addresses are reverse-mapped through path_host_to_guest (undoing the + * over-length shortening symlink first) so the guest reads back the + * spelling it bound or connected with; every other family delegates to + * mac_to_linux_sockaddr. Same return convention as mac_to_linux_sockaddr. + */ +int net_sockaddr_from_mac(const struct sockaddr *mac_sa, + uint32_t mac_len, + uint8_t *linux_sa, + uint32_t linux_sa_size); int absock_rewrite_connect(const uint8_t *linux_sa, uint32_t addrlen, struct sockaddr_storage *mac_sa); @@ -24,4 +48,3 @@ void absock_bind_rollback(int idx); int absock_reverse_lookup(const char *fs_path, uint8_t *out_name, uint32_t *out_len); -void absock_init_cleanup(void); diff --git a/src/syscall/net-msg.c b/src/syscall/net-msg.c index 54ff01c7..e338ba6e 100644 --- a/src/syscall/net-msg.c +++ b/src/syscall/net-msg.c @@ -24,6 +24,7 @@ #include "syscall/net.h" #include "syscall/net-sockopt.h" #include "syscall/net-abi.h" +#include "syscall/net-absock.h" #include "syscall/proc.h" #include "syscall/signal.h" @@ -222,10 +223,11 @@ int64_t sys_sendmsg(guest_t *g, int fd, uint64_t msg_gva, int linux_flags) host_fd_ref_close(&host_ref); return -LINUX_EFAULT; } - int ml = linux_to_mac_sockaddr(linux_sa, lmsg.msg_namelen, &mac_sa); + int ml = + net_sockaddr_to_mac(linux_sa, lmsg.msg_namelen, false, &mac_sa); if (ml < 0) { host_fd_ref_close(&host_ref); - return -LINUX_EINVAL; + return ml; } dest_sa = (struct sockaddr *) &mac_sa; dest_len = (socklen_t) ml; @@ -584,13 +586,24 @@ int64_t sys_recvmsg(guest_t *g, int fd, uint64_t msg_gva, int flags) } if (lmsg.msg_name) { + /* Stays 0 when the host reported no address, and when one could not be + * converted: either way nothing was written for a length to describe. + */ + uint32_t nl = 0; if (msg.msg_namelen > 0) { uint8_t linux_sa[128]; - int out_len = mac_to_linux_sockaddr((struct sockaddr *) &mac_sa, + int out_len = net_sockaddr_from_mac((struct sockaddr *) &mac_sa, msg.msg_namelen, linux_sa, (uint32_t) sizeof(linux_sa)); if (out_len > 0) { - uint32_t write_len = (uint32_t) out_len; + /* The length must describe the bytes written, which are the + * translated address rather than the host one: a pathname + * socket's host spelling is the longer of the two, so passing + * the host length on leaves the guest reading past the address + * it was given. Reported untruncated, per recvmsg(2). + */ + nl = (uint32_t) out_len; + uint32_t write_len = nl; if (write_len > lmsg.msg_namelen) write_len = lmsg.msg_namelen; if (guest_write_small(g, lmsg.msg_name, linux_sa, write_len) < @@ -602,7 +615,6 @@ int64_t sys_recvmsg(guest_t *g, int fd, uint64_t msg_gva, int flags) } } } - uint32_t nl = (uint32_t) msg.msg_namelen; if (guest_write_small(g, msg_gva + offsetof(linux_msghdr_t, msg_namelen), &nl, sizeof(nl)) < 0) { diff --git a/src/syscall/net.c b/src/syscall/net.c index 9154e1a1..4d3faca7 100644 --- a/src/syscall/net.c +++ b/src/syscall/net.c @@ -372,7 +372,6 @@ int64_t sys_bind(guest_t *g, int fd, uint64_t addr_gva, uint32_t addrlen) /* Abstract Unix socket: rewrite to filesystem path */ int absock_idx = -1; if (absock_is_abstract_unix(linux_sa, addrlen)) { - absock_init_cleanup(); int bind_len; absock_idx = absock_bind_prepare(linux_sa, addrlen, &mac_sa, fd, &bind_len); @@ -386,10 +385,10 @@ int64_t sys_bind(guest_t *g, int fd, uint64_t addr_gva, uint32_t addrlen) } mac_len = bind_len; } else { - mac_len = linux_to_mac_sockaddr(linux_sa, addrlen, &mac_sa); + mac_len = net_sockaddr_to_mac(linux_sa, addrlen, true, &mac_sa); if (mac_len < 0) { host_fd_ref_close(&host_ref); - return -LINUX_EINVAL; + return mac_len; } } @@ -520,7 +519,7 @@ static int64_t do_accept(guest_t *g, } uint8_t linux_sa[128]; int out_len = - mac_to_linux_sockaddr((struct sockaddr *) &mac_sa, mac_len, + net_sockaddr_from_mac((struct sockaddr *) &mac_sa, mac_len, linux_sa, (uint32_t) sizeof(linux_sa)); if (out_len > 0) { uint32_t actual_len = (uint32_t) out_len; @@ -584,10 +583,10 @@ int64_t sys_connect(guest_t *g, int fd, uint64_t addr_gva, uint32_t addrlen) return -LINUX_ECONNREFUSED; } } else { - mac_len = linux_to_mac_sockaddr(linux_sa, addrlen, &mac_sa); + mac_len = net_sockaddr_to_mac(linux_sa, addrlen, false, &mac_sa); if (mac_len < 0) { host_fd_ref_close(&host_ref); - return -LINUX_EINVAL; + return mac_len; } } @@ -693,7 +692,7 @@ static int64_t sockaddr_writeback(guest_t *g, { uint8_t linux_sa[128]; int out_len = - mac_to_linux_sockaddr((const struct sockaddr *) mac_sa, mac_len, + net_sockaddr_from_mac((const struct sockaddr *) mac_sa, mac_len, linux_sa, (uint32_t) sizeof(linux_sa)); if (out_len < 0) { host_fd_ref_close(host_ref); @@ -851,10 +850,10 @@ int64_t sys_sendto(guest_t *g, host_fd_ref_close(&host_ref); return -LINUX_EFAULT; } - int mac_len = linux_to_mac_sockaddr(linux_sa, addrlen, &mac_sa); + int mac_len = net_sockaddr_to_mac(linux_sa, addrlen, false, &mac_sa); if (mac_len < 0) { host_fd_ref_close(&host_ref); - return -LINUX_EINVAL; + return mac_len; } dest = (struct sockaddr *) &mac_sa; dest_len = (socklen_t) mac_len; @@ -958,7 +957,7 @@ int64_t sys_recvfrom(guest_t *g, } uint8_t linux_sa[128]; int out_len = - mac_to_linux_sockaddr((struct sockaddr *) &mac_sa, mac_len, + net_sockaddr_from_mac((struct sockaddr *) &mac_sa, mac_len, linux_sa, (uint32_t) sizeof(linux_sa)); if (out_len > 0 || mac_len == 0) { uint32_t actual_len = out_len > 0 ? (uint32_t) out_len : 0; diff --git a/tests/test-absock-cleanup.c b/tests/test-absock-cleanup.c new file mode 100644 index 00000000..6d5fbe36 --- /dev/null +++ b/tests/test-absock-cleanup.c @@ -0,0 +1,206 @@ +/* + * absock namespace lifecycle + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Over-long pathname AF_UNIX socket addresses divert their host path through a + * shortening symlink in a shared /tmp/elfuse-absock- directory. The + * namespace is shared across a forked guest tree (children inherit the root's + * namespace id), so neither exit order may destroy state the other side still + * needs. Both orders are covered: + * default mode, child exits first: the parent binds an over-long socket, a + * forked child binds its own and exits, and the parent's getsockname must + * still reverse-map to the guest spelling after the child is reaped; + * "owner-sweep" mode, root exits first: see owner_sweep_mode() below. + * The companion recipe check asserts the namespace dir does not leak after the + * guest exits. + * + * Linux contract pinned: unix(7). A bound pathname socket stays bound and + * addressable for as long as its owner holds it, whatever unrelated processes + * in the same namespace do. Code under test: absock_cleanup, + * absock_ensure_dir_locked, and absock_shorten_path in + * src/syscall/net-absock.c. A regression shows up as getsockname losing the + * guest spelling after a sibling exits, or as a live child's socket vanishing + * when the namespace owner leaves first, a rendezvous point dying because an + * unrelated process ended. + * + * Needs a plain-dir sysroot on a case-insensitive volume: the four escaped + * levels below push the host path past the 104-byte macOS sun_path so the + * shortening link is actually created, while the guest spelling stays under + * the 108-byte Linux limit. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DEEP_DIR "/Deep.A/Deep.B/Deep.C/Deep.D" + +static int bind_pathname(const char *path) +{ + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) + return -1; + struct sockaddr_un un = {0}; + un.sun_family = AF_UNIX; + strncpy(un.sun_path, path, sizeof(un.sun_path) - 1); + if (bind(fd, (struct sockaddr *) &un, sizeof(un)) < 0 || + listen(fd, 1) < 0) { + close(fd); + return -1; + } + return fd; +} + +static bool getsockname_is(int fd, const char *expect) +{ + struct sockaddr_un got = {0}; + socklen_t len = sizeof(got); + return getsockname(fd, (struct sockaddr *) &got, &len) == 0 && + !strcmp(got.sun_path, expect); +} + +#define ROOT_ABS "\0absock-cleanup-root" +#define CHILD_ABS "\0absock-cleanup-child" +#define ABS_LEN(name) \ + ((socklen_t) (offsetof(struct sockaddr_un, sun_path) + sizeof(name) - 1)) + +static void abs_addr(struct sockaddr_un *un, const char *name, size_t len) +{ + memset(un, 0, sizeof(*un)); + un->sun_family = AF_UNIX; + memcpy(un->sun_path, name, len); +} + +static int bind_abstract(const char *name, size_t len) +{ + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) + return -1; + struct sockaddr_un un; + abs_addr(&un, name, len); + socklen_t alen = (socklen_t) (offsetof(struct sockaddr_un, sun_path) + len); + if (bind(fd, (struct sockaddr *) &un, alen) < 0 || listen(fd, 64) < 0) { + close(fd); + return -1; + } + return fd; +} + +/* Root-exits-first: the root binds before forking, so it creates and owns the + * namespace dir, and its exit sweep runs while the child is still alive. The + * sweep walks the shared dir, where the child's abstract-socket backing file + * also lives, so an indiscriminate unlink destroys a socket the child still has + * bound and listening. The root cannot report the outcome because it must exit + * first, and the child cannot report it through the exit status either, since + * the runtime reports the root's. It prints a marker line the recipe greps for; + * a verdict file would be stored escaped and unreadable by name from the + * host side. + */ +static int owner_sweep_mode(void) +{ + int ready[2], gone[2]; + if (pipe(ready) < 0 || pipe(gone) < 0) + return 1; + + if (bind_abstract(ROOT_ABS, sizeof(ROOT_ABS) - 1) < 0) + return 1; + + pid_t pid = fork(); + if (pid < 0) + return 1; + if (pid == 0) { + close(ready[0]); + close(gone[1]); + bool ok = bind_abstract(CHILD_ABS, sizeof(CHILD_ABS) - 1) >= 0; + if (write(ready[1], "r", 1) != 1) + _exit(1); + + /* The read returns 0 (EOF) once the root dies and its write end is + * closed, so the reconnect below races nothing. + */ + char b; + if (read(gone[0], &b, 1) != 0) + _exit(1); + + struct sockaddr_un un; + abs_addr(&un, CHILD_ABS, sizeof(CHILD_ABS) - 1); + int c = socket(AF_UNIX, SOCK_STREAM, 0); + ok = ok && c >= 0 && + connect(c, (struct sockaddr *) &un, ABS_LEN(CHILD_ABS)) == 0; + + const char *msg = ok ? "OWNER_SWEEP=ok\n" : "OWNER_SWEEP=swept\n"; + if (write_fd_all(1, msg, strlen(msg)) < 0) + _exit(1); + _exit(ok ? 0 : 3); + } + + close(ready[1]); + close(gone[0]); + char b; + if (read(ready[0], &b, 1) != 1) + return 1; + /* exit(), not _exit(): the sweep under test is an atexit handler. */ + exit(0); +} + +int main(int argc, char **argv) +{ + if (argc > 1 && !strcmp(argv[1], "owner-sweep")) + return owner_sweep_mode(); + + TEST("deep escaped chain mkdir"); + bool deep = + (mkdir("/Deep.A", 0755) == 0 || errno == EEXIST) && + (mkdir("/Deep.A/Deep.B", 0755) == 0 || errno == EEXIST) && + (mkdir("/Deep.A/Deep.B/Deep.C", 0755) == 0 || errno == EEXIST) && + (mkdir(DEEP_DIR, 0755) == 0 || errno == EEXIST); + EXPECT_TRUE(deep, "mkdir deep chain"); + + TEST("parent over-long bind"); + int pfd = bind_pathname(DEEP_DIR "/Parent.Sock"); + EXPECT_TRUE(pfd >= 0, "parent bind+listen"); + + TEST("parent getsockname before fork"); + EXPECT_TRUE(pfd >= 0 && getsockname_is(pfd, DEEP_DIR "/Parent.Sock"), + "parent name round-trips"); + + /* The child binds its own over-long socket, so it too creates a shortening + * link in the shared namespace dir, then exits. Its exit sweep must leave + * the parent's link alone. + */ + TEST("child binds over-long and exits"); + pid_t pid = fork(); + if (pid == 0) { + int cfd = bind_pathname(DEEP_DIR "/Child.Sock"); + _exit(cfd >= 0 ? 0 : 1); + } + int status = 0; + EXPECT_TRUE(pid > 0 && waitpid(pid, &status, 0) == pid && + WIFEXITED(status) && WEXITSTATUS(status) == 0, + "child bound and exited cleanly"); + + TEST("parent getsockname after child exit"); + EXPECT_TRUE(pfd >= 0 && getsockname_is(pfd, DEEP_DIR "/Parent.Sock"), + "sibling exit must not remove the parent's shortening link"); + if (pfd >= 0) + close(pfd); + + SUMMARY("test-absock-cleanup"); + return fails > 0 ? 1 : 0; +} diff --git a/tests/test-sysroot-absock-names.c b/tests/test-sysroot-absock-names.c new file mode 100644 index 00000000..ff251f36 --- /dev/null +++ b/tests/test-sysroot-absock-names.c @@ -0,0 +1,416 @@ +/* + * Pathname AF_UNIX sockets under a case-fold sysroot + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A pathname socket's address is a filesystem path, and it must resolve like + * one: through the sysroot, with the same escape rules as open(2), so bind, + * stat, connect, and unlink all agree on which file a name means. The address + * read back through getsockname/getpeername must carry the guest's bytes, + * never the sysroot prefix or a stored spelling. + * + * Linux contract pinned: unix(7). Binding to a pathname creates a socket + * file at that path in the caller's namespace, colliding names are distinct + * files, rebinding an in-use path is EADDRINUSE, and the full 108-byte + * sun_path budget is the guest's. + * + * Code under test: net_sockaddr_to_mac / net_sockaddr_from_mac in + * src/syscall/net-absock.c and their call sites in src/syscall/net.c and + * src/syscall/net-msg.c. A regression shows up as bind reporting ENOENT for a + * directory the guest created, the socket file landing at a host-literal path + * outside the sysroot, or getsockname returning sysroot-prefixed bytes, which + * is how a D-Bus- or X-style rendezvous between two guest processes stops + * working. + * + * Run under --sysroot on a case-folding volume. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define DIR_S "/sockdir" + +static int bind_listener(const char *path) +{ + struct sockaddr_un sa; + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + + if (fd < 0) + return -1; + memset(&sa, 0, sizeof(sa)); + sa.sun_family = AF_UNIX; + snprintf(sa.sun_path, sizeof(sa.sun_path), "%s", path); + if (bind(fd, (struct sockaddr *) &sa, sizeof(sa)) != 0 || + listen(fd, 4) != 0) { + close(fd); + return -1; + } + return fd; +} + +/* Connect to @path, send @token, report what the peer heard through + * @accepted on the listener side. + */ +static int connect_send(const char *path, char token) +{ + struct sockaddr_un sa; + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + + if (fd < 0) + return -1; + memset(&sa, 0, sizeof(sa)); + sa.sun_family = AF_UNIX; + snprintf(sa.sun_path, sizeof(sa.sun_path), "%s", path); + if (connect(fd, (struct sockaddr *) &sa, sizeof(sa)) != 0 || + write(fd, &token, 1) != 1) { + close(fd); + return -1; + } + return fd; +} + +static int bind_dgram(const char *path) +{ + struct sockaddr_un sa; + int fd = socket(AF_UNIX, SOCK_DGRAM, 0); + + if (fd < 0) + return -1; + memset(&sa, 0, sizeof(sa)); + sa.sun_family = AF_UNIX; + snprintf(sa.sun_path, sizeof(sa.sun_path), "%s", path); + if (bind(fd, (struct sockaddr *) &sa, sizeof(sa)) != 0) { + close(fd); + return -1; + } + return fd; +} + +static char accept_token(int listener) +{ + char token = '?'; + int c = accept(listener, NULL, NULL); + + if (c < 0) + return token; + if (read(c, &token, 1) != 1) + token = '?'; + close(c); + return token; +} + +int main(void) +{ + char path[PATH_MAX]; + struct sockaddr_un sa; + socklen_t slen; + struct stat st; + int lfd, cfd; + + printf("test-sysroot-absock-names: pathname sockets in the sysroot\n"); + + TEST("fixture mkdir"); + EXPECT_TRUE(mkdir(DIR_S, 0755) == 0 || errno == EEXIST, "mkdir"); + + /* The address is a path: it must land inside the sysroot, in a directory + * only the guest namespace holds. + */ + TEST("bind creates a socket inside the sysroot"); + snprintf(path, sizeof(path), "%s/My.Sock", DIR_S); + lfd = bind_listener(path); + EXPECT_TRUE(lfd >= 0, "bind + listen"); + + TEST("getsockname returns the exact guest bytes"); + memset(&sa, 0, sizeof(sa)); + slen = sizeof(sa); + if (lfd < 0) { + FAIL("no listener"); + } else if (getsockname(lfd, (struct sockaddr *) &sa, &slen) != 0) { + FAIL("getsockname"); + } else { + EXPECT_TRUE(!strncmp(sa.sun_path, path, sizeof(sa.sun_path)), + "address leaked a host spelling"); + if (strncmp(sa.sun_path, path, sizeof(sa.sun_path))) + fprintf(stderr, " got %.*s\n", (int) sizeof(sa.sun_path), + sa.sun_path); + } + + /* bind and the path layer must agree on which file the name means. */ + TEST("stat sees the bound socket"); + EXPECT_TRUE(stat(path, &st) == 0 && S_ISSOCK(st.st_mode), "stat"); + + TEST("a second socket connects and a byte round-trips"); + cfd = connect_send(path, 'a'); + EXPECT_TRUE(cfd >= 0 && accept_token(lfd) == 'a', "connect + read"); + if (cfd >= 0) + close(cfd); + + TEST("rebinding an in-use name is EADDRINUSE"); + { + int fd2 = socket(AF_UNIX, SOCK_STREAM, 0); + memset(&sa, 0, sizeof(sa)); + sa.sun_family = AF_UNIX; + snprintf(sa.sun_path, sizeof(sa.sun_path), "%s", path); + errno = 0; + EXPECT_TRUE(fd2 >= 0 && + bind(fd2, (struct sockaddr *) &sa, sizeof(sa)) != 0 && + errno == EADDRINUSE, + "should be EADDRINUSE"); + if (fd2 >= 0) + close(fd2); + } + + /* Names differing only by case are distinct sockets, each reachable by + * its own spelling: one stored literally, one escaped. + */ + TEST("case-colliding socket names coexist"); + { + char lower[PATH_MAX], upper[PATH_MAX]; + int lfd2, lfd3, c1, c2; + + snprintf(lower, sizeof(lower), "%s/sock", DIR_S); + snprintf(upper, sizeof(upper), "%s/Sock", DIR_S); + lfd2 = bind_listener(lower); + lfd3 = bind_listener(upper); + if (lfd2 < 0 || lfd3 < 0) { + FAIL("bind pair"); + } else { + TEST(" and each spelling reaches its own listener"); + c1 = connect_send(lower, 'l'); + c2 = connect_send(upper, 'u'); + EXPECT_TRUE(c1 >= 0 && c2 >= 0 && accept_token(lfd2) == 'l' && + accept_token(lfd3) == 'u', + "wrong listener answered"); + if (c1 >= 0) + close(c1); + if (c2 >= 0) + close(c2); + } + if (lfd2 >= 0) + close(lfd2); + if (lfd3 >= 0) + close(lfd3); + } + + /* recvmsg reports a datagram's source address, and the length it reports + * has to describe the bytes it wrote: the guest spelling, which is what + * the translation hands back. A host spelling is longer, so a guest + * sizing the path as msg_namelen - offsetof(sun_path) reads past the + * address into whatever its own buffer held. recvmsg used to report the + * macOS length while writing the translated address, which the other + * three readback paths (accept, getsockname and recvfrom) never did. + * Both sockets are bound, because an unbound sender has no address for + * the receiver to be told about. + */ + TEST("recvmsg reports the guest address length"); + { + char sender[PATH_MAX]; + struct sockaddr_un from; + struct msghdr msg; + struct iovec iov; + char byte = 'd'; + int rfd, sfd; + + snprintf(path, sizeof(path), "%s/Recv.Sock", DIR_S); + snprintf(sender, sizeof(sender), "%s/Sender.Sock", DIR_S); + rfd = bind_dgram(path); + sfd = bind_dgram(sender); + if (rfd < 0 || sfd < 0) { + FAIL("bind dgram pair"); + } else { + memset(&sa, 0, sizeof(sa)); + sa.sun_family = AF_UNIX; + snprintf(sa.sun_path, sizeof(sa.sun_path), "%s", path); + memset(&from, 0, sizeof(from)); + memset(&msg, 0, sizeof(msg)); + iov.iov_base = &byte; + iov.iov_len = 1; + msg.msg_name = &from; + msg.msg_namelen = sizeof(from); + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + if (sendto(sfd, &byte, 1, 0, (struct sockaddr *) &sa, sizeof(sa)) != + 1) { + FAIL("sendto"); + } else if (recvmsg(rfd, &msg, 0) != 1) { + FAIL("recvmsg"); + } else { + unsigned want = + (unsigned) (offsetof(struct sockaddr_un, sun_path) + + strlen(sender) + 1); + EXPECT_TRUE( + (unsigned) msg.msg_namelen == want && + !strncmp(from.sun_path, sender, sizeof(from.sun_path)), + "namelen or bytes describe the host spelling"); + if ((unsigned) msg.msg_namelen != want) + fprintf(stderr, " namelen %u, want %u\n", + (unsigned) msg.msg_namelen, want); + } + } + if (rfd >= 0) + close(rfd); + if (sfd >= 0) + close(sfd); + } + + /* A Linux-legal guest name whose translated host spelling overflows the + * 104-byte macOS sun_path: the escape more than doubles a mixed-case + * component and the sysroot prefix comes on top, so this is the common + * case for deep socket paths, not a corner. + */ + TEST("a name whose host spelling overflows macOS sun_path still binds"); + { + int lfd4, c4; + + snprintf(path, sizeof(path), + "%s/Very.Long.Mixed.Case.Directory.For.Escapes", DIR_S); + if (mkdir(path, 0755) != 0 && errno != EEXIST) { + FAIL("mkdir"); + } else { + snprintf(path, sizeof(path), + "%s/Very.Long.Mixed.Case.Directory.For.Escapes/S.sock", + DIR_S); + lfd4 = bind_listener(path); + if (lfd4 < 0) { + FAIL("bind"); + } else { + TEST(" and connect through it round-trips"); + c4 = connect_send(path, 'x'); + EXPECT_TRUE(c4 >= 0 && accept_token(lfd4) == 'x', + "connect + read"); + if (c4 >= 0) + close(c4); + close(lfd4); + } + } + } + + /* A socket address is a path, so it inherits the /dev/shm never-follow + * rule. bind(2) and connect(2) take a sockaddr rather than a dirfd and + * at_flags, so that rule cannot ride on an open flag here and is checked + * outright. Following a guest-planted link reported ENOTSOCK for a host + * file that exists and ENOENT for one that does not, which tells the guest + * whether any host path exists, including every path + * is_guest_system_path() keeps it from naming: connecting to /etc/passwd + * directly is ENOENT, and through the link it was not. + */ + TEST("connect does not follow a shm symlink out of the backing dir"); + { + unlink("/dev/shm/absock-escape"); + if (symlink("/etc/passwd", "/dev/shm/absock-escape") != 0) { + FAIL("symlink into /dev/shm"); + } else { + int sfd2 = socket(AF_UNIX, SOCK_STREAM, 0); + + memset(&sa, 0, sizeof(sa)); + sa.sun_family = AF_UNIX; + snprintf(sa.sun_path, sizeof(sa.sun_path), "%s", + "/dev/shm/absock-escape"); + errno = 0; + EXPECT_ERRNO(connect(sfd2, (struct sockaddr *) &sa, sizeof(sa)), + ELOOP, "should refuse to follow the link"); + if (sfd2 >= 0) + close(sfd2); + unlink("/dev/shm/absock-escape"); + } + } + + /* The same rule for bind, which is the half that writes: a dangling link + * binds the socket at its target, so following one plants a socket file + * anywhere the guest can name as a target. The recipe checks host-side + * that nothing landed there. + */ + TEST("bind does not follow a shm symlink out of the backing dir"); + { + unlink("/dev/shm/absock-bind-escape"); + if (symlink("/tmp/elfuse-absock-escapee", + "/dev/shm/absock-bind-escape") != 0) { + FAIL("symlink into /dev/shm"); + } else { + int sfd3 = socket(AF_UNIX, SOCK_STREAM, 0); + + memset(&sa, 0, sizeof(sa)); + sa.sun_family = AF_UNIX; + snprintf(sa.sun_path, sizeof(sa.sun_path), "%s", + "/dev/shm/absock-bind-escape"); + errno = 0; + EXPECT_ERRNO(bind(sfd3, (struct sockaddr *) &sa, sizeof(sa)), ELOOP, + "should refuse to follow the link"); + if (sfd3 >= 0) + close(sfd3); + unlink("/dev/shm/absock-bind-escape"); + } + } + + /* An over-length name is reached through a link in a namespace directory, + * and reading the address back has to undo it. fork is posix_spawn plus a + * state handshake, so the child is a fresh elfuse process: it inherits the + * namespace id but has not created that directory itself. Undoing the link + * used to be conditional on having created it, so a guest that only + * inherited the socket read back the /tmp link path in place of the name + * it asked for, and could neither stat nor rebind what it was told. + */ + TEST("a forked child reads back the guest spelling, not the link"); + { + int lfd5; + pid_t pid; + + snprintf(path, sizeof(path), + "%s/Very.Long.Mixed.Case.Directory.For.Escapes/F.sock", DIR_S); + lfd5 = bind_listener(path); + if (lfd5 < 0) { + FAIL("bind"); + } else { + pid = fork(); + if (pid == 0) { + struct sockaddr_un csa; + socklen_t clen = sizeof(csa); + + memset(&csa, 0, sizeof(csa)); + if (getsockname(lfd5, (struct sockaddr *) &csa, &clen) != 0) + _exit(2); + _exit(strncmp(csa.sun_path, path, sizeof(csa.sun_path)) ? 1 + : 0); + } + if (pid < 0) { + FAIL("fork"); + } else { + int status = 0; + + waitpid(pid, &status, 0); + EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0, + "child read back the namespace link path"); + } + close(lfd5); + } + } + + /* The socket is an ordinary directory entry to every other syscall. */ + TEST("unlink removes the socket by its guest name"); + snprintf(path, sizeof(path), "%s/My.Sock", DIR_S); + EXPECT_TRUE(unlink(path) == 0 && stat(path, &st) != 0 && errno == ENOENT, + "unlink + stat"); + if (lfd >= 0) + close(lfd); + + SUMMARY("test-sysroot-absock-names"); + return fails > 0 ? 1 : 0; +} From 479545c6153f8236841931d0084b492b26170d8c Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 29 Jul 2026 23:37:07 +0200 Subject: [PATCH 11/11] Harden the check suite for names and paths 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. --- README.md | 4 +- docs/testing.md | 40 ++- mk/tests.mk | 91 ++++++ tests/test-matrix.sh | 14 +- tests/test-sysroot-path-matrix.c | 485 +++++++++++++++++++++++++++++++ 5 files changed, 624 insertions(+), 10 deletions(-) create mode 100644 tests/test-sysroot-path-matrix.c diff --git a/README.md b/README.md index a7cb827f..6b7f7148 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ boot-time overhead those tools impose. - GNU `objcopy` or `llvm-objcopy` - Hypervisor entitlement: `com.apple.security.hypervisor` -To build only (`make elfuse`) without running tests, just the +To build only (`make elfuse`) without running tests, just the Xcode Command Line Tools and `objcopy` (`brew install binutils`) suffice. For guest test binaries, the project also expects an AArch64 Linux cross @@ -65,7 +65,7 @@ used by the repository test harness, but `CROSS_COMPILE` and `BAREMETAL_CROSS` are overridable. See -[docs/testing.md](docs/testing.md#build-requirements) for toolchain setup guide. +[docs/testing.md](docs/testing.md#build-requirements) for toolchain setup guide. ## Quick Start diff --git a/docs/testing.md b/docs/testing.md index 2692171c..d5bbd978 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -32,7 +32,7 @@ Guest test builds additionally require: - An AArch64 Linux cross-compiler for C test programs - An AArch64 bare-metal toolchain for the assembly smoke test -The toolchain defaults are defined in `mk/toolchain.mk`. +The toolchain defaults are defined in `mk/toolchain.mk`. These variables are intended to be overridden when needed: - `CROSS_COMPILE` @@ -46,7 +46,7 @@ the full `make test-matrix` (including the `qemu-aarch64` reference run). Run it once on an Apple Silicon macOS host: ```sh -# GNU coreutils (gtimeout) — required by the test harness timeout wrapper +# GNU coreutils (gtimeout): required by the test harness timeout wrapper brew install coreutils # GNU objcopy @@ -60,12 +60,14 @@ brew tap messense/macos-cross-toolchains brew trust --formula messense/macos-cross-toolchains/aarch64-unknown-linux-gnu brew install aarch64-unknown-linux-gnu -# QEMU — boots the Alpine minirootfs for the qemu-aarch64 reference run +# QEMU: boots the Alpine minirootfs for the qemu-aarch64 reference run brew install qemu ``` -Depending on your setup, you might need to add the following to your PATH -``` +Depending on the setup, the bare-metal toolchain may also need adding to +`PATH`: + +```sh export PATH="/opt/homebrew/opt/aarch64-elf-gcc/bin:$PATH" ``` @@ -103,8 +105,27 @@ What they do: `externals/test-fixtures/aarch64-musl/staticbin/bin/busybox` or downloaded into `build/busybox` on first run) - the filename codec and case-exact path resolution unit tests - - the sysroot filename lanes: one representation per name, non-ASCII - and normalization, full-length names, and host-staged escape shapes + - the sysroot lanes, each a recipe in `mk/tests.mk` that provisions + its own sysroot and asserts the on-disk shape host-side after the + guest exits: the filename family (one representation per name, + relative and dirfd-relative names, non-ASCII, full length, + host-staged escape shapes, concurrent colliding creates), the + edge shapes (sysroot at `/`, no sysroot, the guest-visible cwd), + byte-exact lookup, host fallback, symlink escapes and targets, + case collisions, the decode boundary (inotify names, exec + identity, `PT_INTERP`, pathname `AF_UNIX` sockets), the host + path ceiling (`ENAMETOOLONG` where macOS's 1024-byte `PATH_MAX` + undercuts the guest's 4096, a macOS-only boundary, which is why + the lane is absent from the qemu matrix), and the frozen-spelling + corpus (on-disk escapes staged byte-for-byte from + `tests/casefold-vectors.h` and read back through a live sysroot) + - the byte-exact oracle lane (`check-name-caseexact`): the name + suite re-run against a case-sensitive APFS sparsebundle. The + volume itself enforces the byte-exact matching the tests assert, + so a failure there means a test's expectation (not the volume) + is wrong, whatever the folding lane says of it. The i18n lane + runs in its `csapfs` mode, pinning the two divergences that + configuration accepts (see `docs/filenames.md`) - the sysroot procfs exec, FUSE-on-Alpine, and `timeout=0` regressions - the Rosetta CLI gating regressions - the hot-syscall guardrail (`tests/test-bench-guardrail.sh`) @@ -114,6 +135,10 @@ What they do: (`test-rosetta-cli`, `test-rosetta-failure-modes`, `test-rosetta-statics`, `test-rosetta-alpine`, `test-rosetta-audit`, `test-rosetta-jit`, `test-rosetta-glibc`) +- `make test-sysroot-name-soak`: minutes of threaded and forked churn over + one case-colliding name set (`SECS=N` overrides the default 120). Excluded + from `check` for its runtime; a pass is only the absence of a reproducer, + and the invariants are stated in `tests/test-sysroot-name-soak.c` - `make test-busybox`: just the BusyBox suite, useful when iterating on a single applet failure without rerunning the unit suite - `make test-fuse-alpine`: validate guest `/dev/fuse` + `mount("fuse")` @@ -380,6 +405,7 @@ Suggested minimum validation: | Change area | Recommended validation | |-------------|------------------------| | CLI, logging, docs-only build rules | `make elfuse` | +| Filename codec, case-exact walk, sysroot resolvers | `make check` (runs the codec unit tests, name lanes, and byte-exact oracle lane), plus `make test-sysroot-name-soak` for resolver concurrency. A red golden vector in `test-casefold-host` means the on-disk format moved: see `docs/filenames.md` before touching `tests/casefold-vectors.h` | | General syscall or runtime logic | `make elfuse && make check && make test-matrix-elfuse-aarch64` | | `/proc`, `/dev`, path, or BusyBox-sensitive behavior | `make elfuse && make check && make test-matrix-elfuse-aarch64` | | Rosetta hosting, x86_64 dispatch, VZ ioctls, AOT cache | `make elfuse && make test-rosetta-all` | diff --git a/mk/tests.mk b/mk/tests.mk index 3fe606be..e6d1ab41 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -30,6 +30,7 @@ test-sysroot-name-staged test-sysroot-name-race \ test-sysroot-pathmax test-sysroot-corpus \ test-sysroot-name-soak check-soak \ + check-name-caseexact test-sysroot-path-matrix \ probe-volume-naming perf ## Build and run the assembly hello world test @@ -162,6 +163,8 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-sysroot-pathmax @printf "\n$(BLUE)━━━ frozen on-disk spelling corpus ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-corpus + @printf "\n$(BLUE)━━━ addressing modes agree across the path matrix ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-path-matrix @printf "\n$(BLUE)━━━ shebang parser unit test ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-shebang-host @printf "\n$(BLUE)━━━ proctitle argv-tail regression ━━━$(RESET)\n" @@ -826,6 +829,94 @@ test-sysroot-name-race: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-race done; \ printf "test-sysroot-name-race: 10 rounds - PASS\n" +# The byte-exact lane is the oracle for the name suite. A case-sensitive +# volume stores every name as itself and matches byte-exactly (the same +# contract the tests assert), so an expectation that fails here disagrees +# with a real Linux filesystem, whatever the folding lane says of it. +# test-sysroot-name-staged stays out: it stages the spellings a folding +# volume forces, and those do not exist here. One image hosts every run, +# with a per-test subdirectory keeping the sysroots apart; the find at the +# end enforces for all tests at once that elfuse stored nothing escaped, +# which is the inversion of the folding lane's stray checks. It prunes the +# path-matrix subtree, because an escape-shaped literal is one of that +# test's name classes: the guest asks for that name and a byte-exact volume +# owes it back unchanged, so an entry there is a guest creation and says +# nothing about what elfuse wrote. A positive check keeps the subtree +# covered instead, since every other lane creates no such name. +# The cross-product matrix asserts mode agreement, an invariant that must +# hold identically whether the volume folds or not; the recipe provisions a +# folding tmpdir, and check-name-caseexact re-runs the same binary on the +# case-sensitive volume. +## Addressing modes agree over operation x shape x name class +test-sysroot-path-matrix: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-path-matrix + @set -e; \ + tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-path-matrix + +## Re-run the name suite on a case-sensitive volume as ground truth +check-name-caseexact: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-name-unique \ + $(BUILD_DIR)/test-sysroot-name-relative \ + $(BUILD_DIR)/test-sysroot-name-i18n \ + $(BUILD_DIR)/test-sysroot-name-length \ + $(BUILD_DIR)/test-sysroot-name-race \ + $(BUILD_DIR)/test-sysroot-path-matrix + @dmg=$$(mktemp -u).dmg; mnt=""; \ + trap '[ -n "$$mnt" ] && hdiutil detach "$$mnt" -force -quiet >/dev/null 2>&1; rm -f "$$dmg"' EXIT; \ + if ! hdiutil create -size 64m -fs "Case-sensitive APFS" \ + -volname elfusenamecs -quiet "$$dmg" >/dev/null 2>&1; then \ + printf "$(YELLOW)SKIP$(RESET) check-name-caseexact (hdiutil create failed)\n"; \ + exit 0; \ + fi; \ + mnt=$$(hdiutil attach "$$dmg" -nobrowse | awk '/\/Volumes\//{print $$NF}'); \ + if [ -z "$$mnt" ]; then \ + printf "$(YELLOW)SKIP$(RESET) check-name-caseexact (hdiutil attach failed)\n"; \ + exit 0; \ + fi; \ + set -e; \ + for t in name-unique name-i18n name-length; do \ + mkdir -p "$$mnt/$$t"; \ + done; \ + printf 'staged\n' > "$$mnt/name-i18n/$$(printf '\346\226\207\346\241\243')-host.txt"; \ + $(ELFUSE_BIN) --sysroot "$$mnt/name-unique" \ + $(BUILD_DIR)/test-sysroot-name-unique; \ + mkdir -p "$$mnt/name-relative" "$$mnt/name-relative-outside"; \ + $(ELFUSE_BIN) --sysroot "$$mnt/name-relative" \ + $(BUILD_DIR)/test-sysroot-name-relative "$$mnt/name-relative-outside"; \ + $(ELFUSE_BIN) --sysroot "$$mnt/name-i18n" \ + $(BUILD_DIR)/test-sysroot-name-i18n csapfs; \ + mkdir -p "$$mnt/path-matrix"; \ + $(ELFUSE_BIN) --sysroot "$$mnt/path-matrix" \ + $(BUILD_DIR)/test-sysroot-path-matrix; \ + if [ ! -e "$$mnt/name-i18n/$$(printf '\346\226\207\346\241\243')-host.txt" ]; then \ + printf "$(RED)FAIL$(RESET) a host-staged non-ASCII name was disturbed\n"; \ + exit 1; \ + fi; \ + $(ELFUSE_BIN) --sysroot "$$mnt/name-length" \ + $(BUILD_DIR)/test-sysroot-name-length; \ + i=0; \ + while [ $$i -lt 3 ]; do \ + mkdir -p "$$mnt/name-race-$$i"; \ + $(ELFUSE_BIN) --sysroot "$$mnt/name-race-$$i" \ + $(BUILD_DIR)/test-sysroot-name-race > "$$mnt/name-race-$$i/.out" 2>&1 || { \ + cat "$$mnt/name-race-$$i/.out"; exit 1; }; \ + i=$$((i + 1)); \ + done; \ + printf "test-sysroot-name-race: 3 byte-exact rounds - PASS\n"; \ + escaped=$$(find "$$mnt" -path "$$mnt/path-matrix" -prune -o \ + -name '.ef=*' -print | wc -l | tr -d ' '); \ + if [ "$$escaped" != 0 ]; then \ + printf "$(RED)FAIL$(RESET) %s name(s) escaped on a byte-exact volume\n" "$$escaped"; \ + find "$$mnt" -path "$$mnt/path-matrix" -prune -o -name '.ef=*' -print; \ + exit 1; \ + fi; \ + if [ -z "$$(find "$$mnt/path-matrix" -name 'Mixed.Name' -print -quit)" ]; then \ + printf "$(RED)FAIL$(RESET) path-matrix stored no literal Mixed.Name\n"; \ + find "$$mnt/path-matrix" | head -40; \ + exit 1; \ + fi + # Build APFS-side dirents whose UTF-8 byte length exceeds Linux # NAME_MAX (255). 89 copies of U+3042 (3-byte UTF-8) plus a 1-byte # ASCII tag = 268 bytes per name; the guest cannot forge this via diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index 9eebe701..a7fbb558 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -363,6 +363,7 @@ QEMU_SKIP=" # measure the host's naming rules instead of Linux's. Their elfuse-side coverage # is the make-check sysroot lanes. ELFUSE_SKIP=" + test-sysroot-path-matrix test-sysroot-name-unique test-sysroot-name-relative test-sysroot-name-i18n @@ -841,6 +842,15 @@ run_unit_tests() # spellings elfuse produces on a folding volume, which is not a concept a # Linux kernel has, and it fails 10 of its 11 assertions here for exactly # that reason. + # + # test-sysroot-pathmax is deliberately absent for the mirror-image reason: + # it pins ENAMETOOLONG at the macOS 1024-byte PATH_MAX ceiling, and a real + # Linux kernel, with no such ceiling, correctly builds every path the test + # expects to be refused. + # + # test-sysroot-corpus is deliberately absent like test-sysroot-name-staged: + # its fixtures are the on-disk spellings elfuse freezes for a folding + # volume, which mean nothing to a Linux kernel. printf "\nFilenames\n" test_check "$runner" "test-sysroot-name-unique" "0 failed" \ "$bindir/test-sysroot-name-unique" @@ -852,6 +862,8 @@ run_unit_tests() "$bindir/test-sysroot-name-length" test_check "$runner" "test-sysroot-name-race" "0 failed" \ "$bindir/test-sysroot-name-race" + test_check "$runner" "test-sysroot-path-matrix" "0 failed" \ + "$bindir/test-sysroot-path-matrix" } run_coreutils_tests() @@ -1281,7 +1293,7 @@ run_suite() # detector does not recognize yet. EXPECTED_BASELINES=( "elfuse-aarch64|238|0" - "qemu-aarch64|223|0" + "qemu-aarch64|224|0" "elfuse-x86_64:apple-m1-m2|71|0" "elfuse-x86_64:apple-m3-plus|71|0" "elfuse-x86_64:apple-unknown|71|0" diff --git a/tests/test-sysroot-path-matrix.c b/tests/test-sysroot-path-matrix.c new file mode 100644 index 00000000..74547f55 --- /dev/null +++ b/tests/test-sysroot-path-matrix.c @@ -0,0 +1,485 @@ +/* + * Path-translation cross product: addressing modes must agree + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Every escaped bug in the path layer sat in one cell of a cross product the + * hand-written tests visited selectively: a final symlink through a dirfd, a + * long-tier name through the openat2 walker, a create below an intermediate + * link. This test enumerates the product programmatically (addressing mode + * x operation x path shape x name class), so a cell exists because the loop + * reached it, not because someone thought of it. + * + * Linux contract pinned: path_resolution(7) makes no distinction between an + * absolute path, a cwd-relative one, and an openat(2) dirfd-relative one that + * name the same file: same result, same errno, same object. The oracle is + * exactly that agreement: each cell runs one operation through all three + * modes and requires identical (rc, errno), the same (st_dev, st_ino) for + * lookups, and for creates that the canonical absolute spelling sees what + * was made. Agreement is the invariant every escaped dirfd bug violated, so + * a divergence names its cell in the failure message. + * + * Code under test: path_translate_at and its relative/dirfd legs in + * src/syscall/path.c, over the resolvers in src/syscall/proc-state.c and the + * walk in src/syscall/casefold-walk.c. A regression shows up as one mode + * diverging: a create landing beside a link instead of through it, a + * lookup succeeding where a sibling mode reports ENOENT, or an errno class + * changing with the spelling of the same file. + * + * Deliberately not here: the sysroot mounted at "/" (read-only root; + * test-sysroot-root), concurrency (test-sysroot-name-race), and name-length + * ceilings (test-sysroot-pathmax); single-axis suites cover those. A pass + * proves mode agreement over the enumerated cells, not the absence of cells + * outside the table. Run under --sysroot. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" +#include "test-util.h" + +int passes = 0, fails = 0; + +#define ROOT "/pmx" + +/* One result of running an operation in one addressing mode. */ +typedef struct { + int rc; /* 0 or -1 */ + int err; /* errno when rc < 0, else 0 */ + dev_t dev; /* identity when the op yields one */ + ino_t ino; + bool has_id; +} cell_result_t; + +enum { MODE_ABS, MODE_CWD, MODE_DIRFD, MODE_COUNT }; + +static const char *const mode_name[MODE_COUNT] = {"absolute", "cwd-relative", + "dirfd"}; + +/* The dirfd every MODE_DIRFD operation is measured from; opened on ROOT once + * in main. MODE_CWD relies on main having chdir'd to ROOT. + */ +static int root_fd = -1; + +/* Spell @rel (a path relative to ROOT) for @mode into @out. */ +static const char *spell(int mode, const char *rel, char *out, size_t outsz) +{ + if (mode == MODE_ABS) + snprintf(out, outsz, "%s/%s", ROOT, rel); + else + snprintf(out, outsz, "%s", rel); + return out; +} + +typedef int (*cell_op_t)(int mode, const char *rel, cell_result_t *res); + +/* Fill @res from a stat-like probe of @rel in @mode; @nofollow picks the + * lstat flavor. Identity is recorded so lookups can assert one object. + */ +static int probe_stat(int mode, + const char *rel, + bool nofollow, + cell_result_t *res) +{ + char path[PATH_MAX]; + struct stat st; + int rc; + + errno = 0; + if (mode == MODE_DIRFD) + rc = fstatat(root_fd, rel, &st, nofollow ? AT_SYMLINK_NOFOLLOW : 0); + else if (nofollow) + rc = lstat(spell(mode, rel, path, sizeof(path)), &st); + else + rc = stat(spell(mode, rel, path, sizeof(path)), &st); + res->rc = rc < 0 ? -1 : 0; + res->err = rc < 0 ? errno : 0; + res->has_id = rc == 0; + res->dev = rc == 0 ? st.st_dev : 0; + res->ino = rc == 0 ? st.st_ino : 0; + return 0; +} + +static int op_stat(int mode, const char *rel, cell_result_t *res) +{ + return probe_stat(mode, rel, false, res); +} + +static int op_lstat(int mode, const char *rel, cell_result_t *res) +{ + return probe_stat(mode, rel, true, res); +} + +static int op_open_rdonly(int mode, const char *rel, cell_result_t *res) +{ + char path[PATH_MAX]; + struct stat st; + int fd; + + errno = 0; + if (mode == MODE_DIRFD) + fd = openat(root_fd, rel, O_RDONLY); + else + fd = open(spell(mode, rel, path, sizeof(path)), O_RDONLY); + res->rc = fd < 0 ? -1 : 0; + res->err = fd < 0 ? errno : 0; + res->has_id = false; + if (fd >= 0) { + if (fstat(fd, &st) == 0) { + res->has_id = true; + res->dev = st.st_dev; + res->ino = st.st_ino; + } + close(fd); + } + return 0; +} + +static int op_openat2_nosym(int mode, const char *rel, cell_result_t *res) +{ + struct open_how how = { + .flags = O_RDONLY, .mode = 0, .resolve = RESOLVE_NO_SYMLINKS}; + char path[PATH_MAX]; + struct stat st; + long fd; + + errno = 0; + if (mode == MODE_DIRFD) + fd = syscall(SYS_openat2, root_fd, rel, &how, sizeof(how)); + else + fd = syscall(SYS_openat2, AT_FDCWD, + spell(mode, rel, path, sizeof(path)), &how, sizeof(how)); + res->rc = fd < 0 ? -1 : 0; + res->err = fd < 0 ? errno : 0; + res->has_id = false; + if (fd >= 0) { + if (fstat((int) fd, &st) == 0) { + res->has_id = true; + res->dev = st.st_dev; + res->ino = st.st_ino; + } + close((int) fd); + } + return 0; +} + +/* Create @rel, record the outcome, verify through the canonical absolute + * spelling that exactly this mode's create is visible, then remove it the + * same canonical way so the next mode starts from the same directory. + */ +static int op_create(int mode, const char *rel, cell_result_t *res) +{ + char path[PATH_MAX]; + char abs[PATH_MAX]; + struct stat st; + int fd; + + snprintf(abs, sizeof(abs), "%s/%s", ROOT, rel); + errno = 0; + if (mode == MODE_DIRFD) + fd = openat(root_fd, rel, O_CREAT | O_WRONLY, 0644); + else + fd = open(spell(mode, rel, path, sizeof(path)), O_CREAT | O_WRONLY, + 0644); + res->rc = fd < 0 ? -1 : 0; + res->err = fd < 0 ? errno : 0; + res->has_id = false; + if (fd >= 0) { + close(fd); + /* The canonical spelling must see what this mode made: a create that + * "succeeded" somewhere else is the failure this matrix exists for. + */ + if (stat(abs, &st) == 0) { + res->has_id = true; + res->dev = st.st_dev; + res->ino = st.st_ino; + } else { + res->rc = -1; + res->err = ENOENT; /* created, but not where the name says */ + } + unlink(abs); + } + return 0; +} + +static int op_mkdir(int mode, const char *rel, cell_result_t *res) +{ + char path[PATH_MAX]; + char abs[PATH_MAX]; + struct stat st; + int rc; + + snprintf(abs, sizeof(abs), "%s/%s", ROOT, rel); + errno = 0; + if (mode == MODE_DIRFD) + rc = mkdirat(root_fd, rel, 0755); + else + rc = mkdir(spell(mode, rel, path, sizeof(path)), 0755); + res->rc = rc < 0 ? -1 : 0; + res->err = rc < 0 ? errno : 0; + res->has_id = false; + if (rc == 0) { + if (stat(abs, &st) == 0 && S_ISDIR(st.st_mode)) { + res->has_id = true; + res->dev = st.st_dev; + res->ino = st.st_ino; + } else { + res->rc = -1; + res->err = ENOENT; + } + rmdir(abs); + } + return 0; +} + +static int op_excl_existing(int mode, const char *rel, cell_result_t *res) +{ + char path[PATH_MAX]; + int fd; + + errno = 0; + if (mode == MODE_DIRFD) + fd = openat(root_fd, rel, O_CREAT | O_EXCL | O_WRONLY, 0644); + else + fd = open(spell(mode, rel, path, sizeof(path)), + O_CREAT | O_EXCL | O_WRONLY, 0644); + res->rc = fd < 0 ? -1 : 0; + res->err = fd < 0 ? errno : 0; + res->has_id = false; + if (fd >= 0) + close(fd); /* agreement failure; leave the evidence in place */ + return 0; +} + +static int op_rename(int mode, const char *rel, cell_result_t *res) +{ + char src_spelled[PATH_MAX]; + char dst_spelled[PATH_MAX]; + char abs[PATH_MAX]; + char dst_rel[PATH_MAX]; + char dst_abs[PATH_MAX]; + struct stat st; + int rc; + + /* Rename within the same directory prefix: the destination reuses the + * source's parent so the cell exercises exactly one shape. + */ + snprintf(abs, sizeof(abs), "%s/%s", ROOT, rel); + snprintf(dst_rel, sizeof(dst_rel), "%s.Renamed", rel); + snprintf(dst_abs, sizeof(dst_abs), "%s/%s", ROOT, dst_rel); + + /* Stage the source through the canonical spelling. */ + int fd = open(abs, O_CREAT | O_WRONLY, 0644); + if (fd < 0) { + res->rc = -1; + res->err = errno; + res->has_id = false; + return 0; + } + close(fd); + + errno = 0; + if (mode == MODE_DIRFD) + rc = renameat(root_fd, rel, root_fd, dst_rel); + else + rc = rename(spell(mode, rel, src_spelled, sizeof(src_spelled)), + spell(mode, dst_rel, dst_spelled, sizeof(dst_spelled))); + res->rc = rc < 0 ? -1 : 0; + res->err = rc < 0 ? errno : 0; + res->has_id = false; + if (rc == 0) { + if (stat(dst_abs, &st) == 0) { + res->has_id = true; + res->dev = st.st_dev; + res->ino = st.st_ino; + } else { + res->rc = -1; + res->err = ENOENT; + } + } + unlink(dst_abs); + unlink(abs); + return 0; +} + +typedef struct { + const char *name; + cell_op_t fn; + bool creates; /* the cell mutates; run only against absent leaves */ +} op_t; + +static const op_t ops[] = { + {"stat", op_stat, false}, + {"lstat", op_lstat, false}, + {"open", op_open_rdonly, false}, + {"openat2-nosym", op_openat2_nosym, false}, + {"create", op_create, true}, + {"mkdir", op_mkdir, true}, + {"excl-existing", op_excl_existing, false}, + {"rename", op_rename, true}, +}; + +/* Path shapes: a prefix the leaf is planted under. sublink is a symlink to + * Sub.Dir, so the third shape crosses an intermediate link. + */ +static const char *const shapes[] = {"", "Sub.Dir/", "sublink/"}; + +/* Name classes. The escape-shaped literal is a name the guest may legally + * create; the long-tier name crosses the codec's hex ceiling. + */ +#define LONG_NAME_LEN 126 +static char long_name[LONG_NAME_LEN + 1]; + +static const char *leaves[] = {"plain-name", "Mixed.Name", ".ef=464f4f", + long_name}; + +/* Fixture leaves that exist before the lookup ops run. */ +static void stage_fixture(const char *rel) +{ + char abs[PATH_MAX]; + int fd; + + snprintf(abs, sizeof(abs), "%s/%s", ROOT, rel); + fd = open(abs, O_CREAT | O_WRONLY, 0644); + if (fd >= 0) { + write(fd, "pmx", 3); + close(fd); + } +} + +static void run_cell(const op_t *op, const char *shape, const char *leaf) +{ + cell_result_t r[MODE_COUNT]; + char rel[PATH_MAX]; + char label[192]; + bool agree = true; + + snprintf(rel, sizeof(rel), "%s%s", shape, leaf); + snprintf(label, sizeof(label), "%s %s%s agrees across modes", op->name, + shape, leaf); + + for (int m = 0; m < MODE_COUNT; m++) + op->fn(m, rel, &r[m]); + + for (int m = 1; m < MODE_COUNT; m++) { + if (r[m].rc != r[0].rc || r[m].err != r[0].err) + agree = false; + /* Identity must match for lookups only: a mutating cell makes (and + * removes) a fresh object per mode, so its inodes legitimately + * differ; placement is already folded into rc via the canonical + * probe inside the op. + */ + if (!op->creates && r[m].has_id && r[0].has_id && + (r[m].dev != r[0].dev || r[m].ino != r[0].ino)) + agree = false; + } + + TEST(label); + if (agree) { + PASS(); + } else { + FAIL("addressing modes disagree"); + for (int m = 0; m < MODE_COUNT; m++) + printf(" %-12s rc=%d errno=%d ino=%llu\n", mode_name[m], + r[m].rc, r[m].err, + r[m].has_id ? (unsigned long long) r[m].ino : 0ULL); + } +} + +int main(void) +{ + char abs[PATH_MAX]; + + printf("test-sysroot-path-matrix: addressing modes must agree\n"); + + memset(long_name, 'Q', LONG_NAME_LEN); + long_name[LONG_NAME_LEN] = '\0'; + + TEST("fixtures"); + snprintf(abs, sizeof(abs), "%s/Sub.Dir", ROOT); + bool ok = (mkdir(ROOT, 0755) == 0 || errno == EEXIST) && + (mkdir(abs, 0755) == 0 || errno == EEXIST); + snprintf(abs, sizeof(abs), "%s/sublink", ROOT); + ok = ok && (symlink("Sub.Dir", abs) == 0 || errno == EEXIST); + ok = ok && chdir(ROOT) == 0 && + (root_fd = open(ROOT, O_RDONLY | O_DIRECTORY)) >= 0; + EXPECT_TRUE(ok, "fixture setup"); + if (!ok) { + SUMMARY("test-sysroot-path-matrix"); + return 1; + } + + /* Lookup fixtures: every (shape, leaf) cell that lookup ops touch holds a + * real file, staged through the canonical absolute spelling. sublink/ + * shares Sub.Dir/'s entries by construction, which is the point: the two + * shapes must then also agree with each other about identity. + */ + for (size_t s = 0; s < sizeof(shapes) / sizeof(shapes[0]); s++) { + if (!strcmp(shapes[s], "sublink/")) + continue; + for (size_t l = 0; l < sizeof(leaves) / sizeof(leaves[0]); l++) { + char rel[PATH_MAX]; + snprintf(rel, sizeof(rel), "%s%s", shapes[s], leaves[l]); + stage_fixture(rel); + } + } + + /* A final symlink as its own shape, one per target class. */ + snprintf(abs, sizeof(abs), "%s/final-link", ROOT); + symlink("Mixed.Name", abs); + + for (size_t o = 0; o < sizeof(ops) / sizeof(ops[0]); o++) { + for (size_t s = 0; s < sizeof(shapes) / sizeof(shapes[0]); s++) { + for (size_t l = 0; l < sizeof(leaves) / sizeof(leaves[0]); l++) { + char rel[PATH_MAX]; + + if (ops[o].creates) { + /* Mutating cells use a fresh leaf beside the fixture so + * the lookup fixtures stay untouched. + */ + char fresh[PATH_MAX]; + snprintf(fresh, sizeof(fresh), "New.%s", leaves[l]); + /* An escape-shaped or long fresh leaf keeps its class. */ + if (!strcmp(leaves[l], ".ef=464f4f")) + snprintf(fresh, sizeof(fresh), ".ef=4e4557"); + else if (leaves[l] == long_name) + snprintf(fresh, sizeof(fresh), "N%s", long_name); + snprintf(rel, sizeof(rel), "%s%s", shapes[s], fresh); + /* Guard the composed name against the guest ceiling. */ + if (strlen(fresh) > 255) + continue; + } else { + snprintf(rel, sizeof(rel), "%s%s", shapes[s], leaves[l]); + } + run_cell(&ops[o], shapes[s], rel + strlen(shapes[s])); + } + } + } + + /* Final-symlink shape: follow and nofollow lookups, all modes. */ + run_cell(&ops[0], "", "final-link"); + run_cell(&ops[1], "", "final-link"); + run_cell(&ops[2], "", "final-link"); + + /* Trailing separator asserts directoriness; a regular file owes ENOTDIR + * in every mode alike (path_resolution(7)). + */ + run_cell(&ops[0], "", "plain-name/"); + run_cell(&ops[0], "", "Mixed.Name/"); + run_cell(&ops[2], "Sub.Dir/", "Mixed.Name/"); + + if (root_fd >= 0) + close(root_fd); + + SUMMARY("test-sysroot-path-matrix"); + return fails > 0 ? 1 : 0; +}