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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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; +} From 917e4439796d83e8309f0afc76f67ef6dae9eb98 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 15 Jul 2026 20:05:53 +0200 Subject: [PATCH 12/22] Extract elfuse_launch into src/core/launch.c elfuse_launch owns guest bring-up: guest_bootstrap_prepare, the FUSE-temp unlink, the sysroot casefold probe, vCPU creation, GDB init/sync/wait, the run loop, gdb_stub_shutdown, the shim counter and syscall histogram dumps, and guest_destroy. main() retains the original CLI argv (proctitle rewriting), option parsing, sysroot provisioning, the shebang loop, the --gdb x86_64 guard, host cwd, and the heap resource cleanup, and now hands off through launch_args_t so other launchers (the OCI run helper) can share one bring-up path. The launch_args_t envp field generalizes the old hard-coded environ: NULL keeps the host environment, so main()'s behavior is unchanged. Bring-up failures unwind through a single fail label instead of repeating the guest_destroy-plus-unlink tail at every error site. Ownership of the FUSE-materialized temp ELF moves with the bring-up: elfuse_launch owns the unlink from the prepare call onward (teardown and the post-prepare error paths), and main() drops its claim before handing off, so main's shared goto unwind cannot double-unlink a path whose ownership has been transferred. The embedded shim blob include moves along with its only consumer, so shim_bin has a single object definition site. --- Makefile | 1 + src/core/launch.c | 195 ++++++++++++++++++++++++++++++++++++++++++++++ src/core/launch.h | 88 +++++++++++++++++++++ src/main.c | 161 ++++++++++---------------------------- 4 files changed, 324 insertions(+), 121 deletions(-) create mode 100644 src/core/launch.c create mode 100644 src/core/launch.h diff --git a/Makefile b/Makefile index 1dad3a0d..dce5bf9c 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,7 @@ SRCS := \ core/vdso.c \ core/shim-globals.c \ core/bootstrap.c \ + core/launch.c \ core/rosetta.c \ core/sysroot.c \ runtime/thread.c \ diff --git a/src/core/launch.c b/src/core/launch.c new file mode 100644 index 00000000..7b8b54a6 --- /dev/null +++ b/src/core/launch.c @@ -0,0 +1,195 @@ +/* elfuse VM launch: bring-up + GDB + run loop + teardown + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Implementation of elfuse_launch (contract and caller/callee ownership in + * launch.h). Extracted from src/main.c so the positional-ELF CLI and other + * launchers (e.g. the OCI run helper) share one bring-up path. + * + * shim_blob.h is included here, not in src/main.c, so the static + * shim_bin / shim_bin_len blob has a single object definition site. + */ + +#include "launch.h" + +#include +#include +#include +#include +#include +#include + +#include "core/bootstrap.h" +#include "core/guest.h" +#include "core/shim-globals.h" +#include "core/sysroot.h" + +#include "runtime/futex.h" /* futex_interrupt_request */ +#include "runtime/thread.h" +#include "syscall/poll.h" /* wakeup_pipe_signal */ +#include "syscall/proc.h" + +#include "debug/gdbstub.h" +#include "debug/log.h" +#include "debug/syscall-hist.h" + +/* Embedded shim binary (generated by xxd -i from shim.bin). */ +#include "shim_blob.h" + +/* The shim code slot in the infra reserve is sized tight (INFRA_SHIM_SLOT, a + * few x the current blob) so the rest of the reserve goes to the page-table + * pool. If the shim ever outgrows the slot it would overlap the shim-data + * block; fail the build loudly rather than corrupt memory at boot. Enlarge + * INFRA_SHIM_SLOT (and shrink the pool to match) if this fires. + */ +_Static_assert(sizeof(shim_bin) <= INFRA_SHIM_SLOT, + "shim blob exceeds its infra slot; bump INFRA_SHIM_SLOT"); + +int elfuse_launch(const launch_args_t *args) +{ + if (!args) { + log_error("elfuse_launch: NULL args"); + return 1; + } + + extern char **environ; + char **envp_use = args->envp ? args->envp : environ; + + guest_t g; + bool guest_initialized = false; + guest_bootstrap_t boot; + /* Track the temp flag locally: elfuse_launch owns the post-prepare + * unlink of a FUSE-materialized temp, but the caller's launch_args_t + * is const and its copy is discarded after the call anyway. + */ + bool elf_host_temp = args->elf_host_temp; + /* The guest-visible entrypoint path is argv[0]; elf_path is the + * resolved host path to that binary. They differ when path + * translation or a FUSE-materialized temp is involved. + */ + const char *elf_guest_path = (args->guest_argc > 0 && args->guest_argv) + ? args->guest_argv[0] + : args->elf_path; + + if (guest_bootstrap_prepare( + &g, args->elf_path, elf_host_temp, elf_guest_path, args->sysroot, + args->guest_argc, args->guest_argv, envp_use, shim_bin, + shim_bin_len, args->verbose, &guest_initialized, &boot) < 0) + goto fail; + + /* A FUSE-materialized temp has been loaded; drop it once the guest + * has its own mapping, unless Rosetta still needs the reopenable + * host path. + */ + if (elf_host_temp && !g.is_rosetta) { + unlink(args->elf_path); + elf_host_temp = false; + } + + if (args->sysroot) { + bool case_sensitive = true; + bool case_preserving = true; + if (sysroot_probe_case_sensitivity(args->sysroot, &case_sensitive, + &case_preserving) == 0) + proc_set_sysroot_casefold(case_preserving && !case_sensitive); + else + proc_set_sysroot_casefold(false); + } else { + proc_set_sysroot_casefold(false); + } + + hv_vcpu_t vcpu; + hv_vcpu_exit_t *vexit; + if (guest_bootstrap_create_vcpu(&g, &boot, args->verbose, &vcpu, &vexit) < + 0) + goto fail; + + /* GDB setup must happen before the first run so entry-stop and + * hardware breakpoints can affect the initial vCPU. + */ + if (args->gdb_port > 0) { + if (gdb_stub_init(args->gdb_port, &g) < 0) { + log_error("failed to initialize GDB stub"); + goto fail; + } + gdb_stub_sync_debug_regs(vcpu); + if (args->gdb_stop_on_entry) + gdb_stub_wait_for_attach(); + } + + /* vcpu_run_loop owns guest execution until exit, fatal signal, or timeout. + */ + int exit_code = + vcpu_run_loop(vcpu, vexit, &g, args->verbose, args->timeout_sec, NULL); + + /* Tear down debugger state before joining workers: a worker parked in + * gdb_stub_handle_stop() stays active (not deactivated) until this + * broadcasts resume_cond, so joining first would just time out and + * detach it while it is still paused. + */ + gdb_stub_shutdown(); + + /* Join worker vCPU threads before guest_destroy unmaps the guest slab: a + * sibling still mid-iteration in its own run loop would fault on freed + * guest memory and crash the host with SIGSEGV, masking the real exit + * code. The join is a no-op once workers have wound down (the common + * single-threaded case). + * + * vcpu_run_loop can also return via a bare break (alarm timeout 124, a + * fatal default-disposition signal, or ELR_EL1==0) with no one having + * requested exit_group or kicked the siblings out of hv_vcpu_run. Mirror + * guest_destroy's request-interrupt prefix here first; otherwise this join + * burns its full poll cap and detaches every worker, and guest_destroy's + * own interrupt-join skips them (it honors join_abandoned), leaving live + * pthreads to fault on the imminent unmap. + */ + if (!proc_exit_group_requested()) + proc_request_exit_group(0); + futex_interrupt_request(); + wakeup_pipe_signal(); + thread_interrupt_all(); + /* Workers parked on internal condvars (fork barrier, ptrace stop/wait) + * see neither the pipe nor the vCPU kick; broadcast so they re-check the + * exit-group flag and terminate before the join below gives up on them. + */ + thread_wake_exit_waiters(); + thread_join_workers(); + + /* Diagnostic counter dump runs before guest_destroy so the + * shim_data mapping is still valid. ELFUSE_SHIM_STATS is the gate; + * an unset variable produces no output. + */ + if (shim_globals_stats_enabled()) + shim_globals_counters_dump(&g); + + /* Dump the startup histogram before guest_destroy so any + * cleanup-path syscalls (closing host fds, unmapping the slab) do + * not appear in the captured set. The dump is a no-op when + * ELFUSE_STARTUP_TRACE=syscalls was not requested. + */ + syscall_hist_dump(); + + if (guest_initialized) + guest_destroy(&g); + + /* Rosetta guests keep the FUSE-materialized temp alive for the whole run + * (the translator reopens the host path); drop it now that the guest is + * gone so repeated Rosetta launches do not accumulate temp files. + */ + if (elf_host_temp) + unlink(args->elf_path); + + return exit_code; + +fail: + /* Bring-up failed: unwind whatever exists so far. The caller owns + * pre-prepare failures; from the prepare call onward the temp unlink + * is ours. + */ + if (guest_initialized) + guest_destroy(&g); + if (elf_host_temp) + unlink(args->elf_path); + return 1; +} diff --git a/src/core/launch.h b/src/core/launch.h new file mode 100644 index 00000000..66555350 --- /dev/null +++ b/src/core/launch.h @@ -0,0 +1,88 @@ +/* elfuse VM launch entry: post-CLI bring-up + run loop + teardown + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * elfuse_launch is the single entry point for "run a guest binary in a + * fresh HVF VM until it exits". It is shared between main() (legacy + * positional-ELF CLI) and future launchers such as the OCI run helper. + * + * The function owns the guest_t, the vCPU, the GDB stub, the run loop, + * the diagnostic dumps, and guest teardown; it does NOT own the + * elf_path / sysroot / guest_argv heap copies or the sysroot_mount the + * host CLI may have provisioned; those stay with the caller so + * behaviors that need the original CLI argv (proctitle rewriting, + * --create-sysroot detach on exit, host cwd save+restore) remain + * coherent regardless of how the launch was kicked off. + * + * Lifetime / ownership contract: + * + * - The caller owns every pointer in launch_args_t. elfuse_launch reads + * them and does not free them; const-qualified pointers stay valid + * for the duration of the call. + * - envp may be NULL; the host process environ is used in that case. + * - guest_argv is the string array the guest sees as its argv. + * guest_argv[0] is the guest-visible entrypoint path (what the guest + * reads back via /proc/self/exe and argv[0]); elf_path is the + * resolved host path to that binary. The two differ when path + * translation or a FUSE-materialized temp is involved. + * - elf_host_temp is true when elf_path is a FUSE-materialized temp + * that must be unlinked once guest_bootstrap_prepare has loaded it + * (skipped for Rosetta guests, which reopen the path). The caller + * owns the unlink for any pre-prepare failure; elfuse_launch owns it + * from the prepare call onward. + * - fork_child_fd / vfork_notify_fd are forwarded for fork-child-routed + * launches; main() dispatches the fork-child path before reaching + * elfuse_launch and so passes -1. + */ + +#pragma once + +#include +#include + +typedef struct { + /* Host filesystem path to the guest ELF (resolved; may be a + * FUSE-materialized temp when elf_host_temp is true). + */ + const char *elf_path; + + /* True when elf_path is a temp to unlink after guest_bootstrap_prepare + * loads it. + */ + bool elf_host_temp; + + /* Host filesystem path to the sysroot the guest sees as / (absolute), + * or NULL when the guest runs without a sysroot. + */ + const char *sysroot; + + /* String array the guest sees as its argv. guest_argv[0] is the + * guest-visible entrypoint path. + */ + int guest_argc; + const char **guest_argv; + + /* NULL-terminated guest environ. NULL means "use host environ". envp is + * char** (not const) to match the environ/guest_bootstrap_prepare + * convention: guest programs may mutate their environment. + */ + char **envp; + + /* GDB Remote Serial Protocol port (0 disables the stub) and whether + * to halt before the first guest instruction. + */ + int gdb_port; + bool gdb_stop_on_entry; + + /* Per-iteration vCPU run timeout. 0 disables (no alarm()). */ + int timeout_sec; + + bool verbose; +} launch_args_t; + +/* Bring up the guest VM, run it to exit / signal / timeout, tear down, + * return the exit code. Returns 1 on bring-up failure (with a log + * message) and the guest's exit status otherwise. + */ +int elfuse_launch(const launch_args_t *args); diff --git a/src/main.c b/src/main.c index 842e1846..2b0ad695 100644 --- a/src/main.c +++ b/src/main.c @@ -31,21 +31,17 @@ #include "core/bootstrap.h" #include "core/guest.h" +#include "core/launch.h" #include "core/rosetta.h" -#include "core/shim-globals.h" #include "core/sysroot.h" #include "runtime/forkipc.h" -#include "runtime/futex.h" /* futex_interrupt_request */ #include "runtime/proctitle.h" -#include "runtime/thread.h" #include "syscall/fuse.h" #include "syscall/path.h" -#include "syscall/poll.h" /* wakeup_pipe_signal */ #include "syscall/proc.h" -#include "debug/gdbstub.h" #include "debug/log.h" #include "debug/syscall-hist.h" @@ -135,17 +131,11 @@ static void cleanup_main_resources(guest_t *g, free((void *) sysroot_path); } -/* Embedded shim binary (generated by xxd -i from shim.bin) */ -#include "shim_blob.h" - -/* The shim code slot in the infra reserve is sized tight (INFRA_SHIM_SLOT, a - * few x the current blob) so the rest of the reserve goes to the page-table - * pool. If the shim ever outgrows the slot it would overlap the shim-data - * block; fail the build loudly rather than corrupt memory at boot. Enlarge - * INFRA_SHIM_SLOT (and shrink the pool to match) if this fires. +/* The embedded shim binary (shim_blob.h, generated by xxd -i from shim.bin) + * is now included by src/core/launch.c, the single site that hands the blob to + * guest_bootstrap_prepare. main() no longer references shim_bin/shim_bin_len + * directly, so the static blob has one object definition site. */ -_Static_assert(sizeof(shim_bin) <= INFRA_SHIM_SLOT, - "shim blob exceeds its infra slot; bump INFRA_SHIM_SLOT"); /* The infra-reserve layout invariants documented in guest.h are derived from * raw offset constants, so a future edit that grows the pool by shifting one @@ -615,121 +605,50 @@ int main(int argc, char **argv) } } - guest_bootstrap_t boot; - extern char **environ; - - if (guest_bootstrap_prepare(&g, elf_host_path, elf_host_temp, elf_path, - sysroot, guest_argc, guest_argv, environ, - shim_bin, shim_bin_len, verbose, - &guest_initialized, &boot) < 0) - goto fail; - if (elf_host_temp && !g.is_rosetta) { - unlink(elf_host_path); - elf_host_temp = false; - } - - if (have_sysroot) { - bool case_sensitive = true; - bool case_preserving = true; - if (sysroot_probe_case_sensitivity(sysroot, &case_sensitive, - &case_preserving) == 0) { - proc_set_sysroot_casefold(case_preserving && !case_sensitive); - } else { - proc_set_sysroot_casefold(false); - } - } else { - proc_set_sysroot_casefold(false); - } - - runtime_set_process_title(argc, argv, elf_path); - - hv_vcpu_t vcpu; - hv_vcpu_exit_t *vexit; - if (guest_bootstrap_create_vcpu(&g, &boot, verbose, &vcpu, &vexit) < 0) - goto fail; - - /* GDB setup must happen before the first run so entry-stop and hardware - * breakpoints can affect the initial vCPU. - */ - if (gdb_port > 0) { - if (gdb_stub_init(gdb_port, &g) < 0) { - log_error("failed to initialize GDB stub"); - goto fail; - } - /* Mirror any preconfigured breakpoints/watchpoints into this vCPU. */ - gdb_stub_sync_debug_regs(vcpu); - - if (gdb_stop_on_entry) - gdb_stub_wait_for_attach(); - } - - /* vcpu_run_loop owns guest execution until exit, fatal signal, or timeout. - */ - exit_code = vcpu_run_loop(vcpu, vexit, &g, verbose, timeout_sec, NULL); - - /* Tear down debugger state before joining workers: a worker parked in - * gdb_stub_handle_stop() stays active (not deactivated) until this - * broadcasts resume_cond, so joining first would just time out and detach - * it while it is still paused. - */ - gdb_stub_shutdown(); - - /* Wait for worker vCPU threads to stop before tearing down guest memory. - * The main thread leaves the run loop as soon as it observes the exit_group - * flag, but sibling vCPU threads may still be mid-iteration in their own - * run loops (e.g. touching shim_globals). cleanup_main_resources unmaps the - * guest slab via guest_destroy, so a still-running worker would fault on - * freed guest memory and crash the host with SIGSEGV, masking the real exit - * code. thread_join_workers() is a no-op once the workers have already - * wound down (the common single-threaded case). - * - * vcpu_run_loop can also return here without anyone having requested - * exit_group or kicked the siblings out of hv_vcpu_run: the alarm timeout - * (exit_code 124), a fatal default-disposition signal, or ELR_EL1==0 all - * bail out with a bare break. On those paths siblings are still spinning in - * the guest, so mirror guest_destroy's request-interrupt prefix before - * joining -- otherwise this call burns its full poll cap, detaches every - * worker, and guest_destroy's own request-interrupt-join (which honors - * join_abandoned) skips them, leaving live pthreads to fault on the - * imminent unmap. + /* Rewrite the host-visible process title from the guest entrypoint. This + * clobbers the original argv block (already snapshotted into the heap + * elf_path / guest_argv above), so it must run before elfuse_launch hands + * control to the guest but after the shebang loop has fixed elf_path. The + * call only touches the original argv and the kernel procname; it does not + * depend on guest_bootstrap_prepare having run, so hoisting it out of the + * bring-up (where it previously sat between prepare and create_vcpu) is + * behavior-preserving. */ - if (!proc_exit_group_requested()) - proc_request_exit_group(0); - futex_interrupt_request(); - wakeup_pipe_signal(); - thread_interrupt_all(); - - /* Workers parked on internal condvars (fork barrier, ptrace stop/wait) see - * neither the pipe nor the vCPU kick; broadcast so they re-check the - * exit-group flag and terminate before the join below gives up on them. - */ - thread_wake_exit_waiters(); - thread_join_workers(); + runtime_set_process_title(argc, argv, elf_path); - /* Diagnostic counter dump runs before guest_destroy so the shim_data - * mapping is still valid. ELFUSE_SHIM_STATS is the gate; an unset variable - * produces no output. + /* Hand the bring-up, run loop, and guest teardown to elfuse_launch. main() + * retains ownership of the original argv (proctitle above), the sysroot + * mount (detached in cleanup_main_resources after the guest exits so the + * mount stays live for the whole run), host cwd, and the heap elf_path / + * sysroot_path / guest_argv copies. */ - if (shim_globals_stats_enabled()) - shim_globals_counters_dump(&g); - - /* Dump the startup histogram before guest_destroy so any cleanup-path - * syscalls (closing host fds, unmapping the slab) do not appear in the - * captured set. The dump is a no-op when ELFUSE_STARTUP_TRACE=syscalls was - * not requested. + launch_args_t largs = { + .elf_path = elf_host_path, + .elf_host_temp = elf_host_temp, + .sysroot = sysroot, + .guest_argc = guest_argc, + .guest_argv = guest_argv, + .gdb_port = gdb_port, + .gdb_stop_on_entry = gdb_stop_on_entry, + .timeout_sec = timeout_sec, + .verbose = verbose, + }; + /* elfuse_launch owns the temp unlink from the prepare call onward, so + * drop main()'s claim before handing off: the shared cleanup below must + * not unlink a path whose ownership has been transferred. */ - syscall_hist_dump(); + elf_host_temp = false; + exit_code = elfuse_launch(&largs); goto cleanup; fail: exit_code = 1; cleanup: - /* Single unwind for every exit past the heap-copy allocations: frees the - * caller-owned heap copies (guest_destroy included, via - * cleanup_main_resources, once the guest came up), detaches the sysroot - * mount, restores the host cwd, and drops a still-owned FUSE-materialized - * temp ELF, which the post-prepare error paths and a Rosetta guest's - * teardown previously leaked. + /* Single unwind for every exit past the heap-copy allocations. On the + * success path elfuse_launch has already run guest_destroy + * (guest_initialized never becomes true in main), so this frees the + * caller-owned heap copies, detaches the sysroot mount, restores the + * host cwd, and drops a still-owned FUSE-materialized temp ELF. */ cleanup_main_resources(&g, guest_initialized, &sysroot_mount, have_host_cwd ? host_cwd : NULL, guest_argv, From f91ece231c078ebaac0d9561a309dab6a7e34ea7 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 15 Jul 2026 23:10:43 +0200 Subject: [PATCH 13/22] Add --user, --workdir, and --env launch flags An OCI image front end needs to set the guest identity, working directory, and environment without patching the runtime; the new flags map onto launch_args_t fields and `elfuse-oci run` drives exactly this interface. The --user identity is staged before bring-up (proc_set_initial_ids) so the auxv AT_UID/AT_GID snapshot taken by build_linux_stack matches what getuid()/getgid() later report. --workdir rejects non-absolute paths up front instead of silently resolving them against the host cwd, and is applied by elfuse_launch after the casefold probe so the translation sees the sysroot's real case behavior. --env/--clear-env build the guest environment with env(1) semantics: KEY=VAL sets, bare KEY inherits from the host environ, --clear-env starts empty; with neither flag given envp stays NULL and the host environ is used unchanged. The new heap resources join main()'s shared goto unwind: envp, workdir, and the raw --env override array are released at the single cleanup label on every exit path. --- src/core/launch.c | 49 ++++++ src/core/launch.h | 66 ++++---- src/main.c | 327 ++++++++++++++++++++++++++++++++---- src/syscall/proc-identity.c | 24 +++ src/syscall/proc.h | 8 + 5 files changed, 410 insertions(+), 64 deletions(-) diff --git a/src/core/launch.c b/src/core/launch.c index 7b8b54a6..5cb56e61 100644 --- a/src/core/launch.c +++ b/src/core/launch.c @@ -15,9 +15,11 @@ #include #include +#include #include #include #include +#include #include #include "core/bootstrap.h" @@ -27,6 +29,7 @@ #include "runtime/futex.h" /* futex_interrupt_request */ #include "runtime/thread.h" +#include "syscall/path.h" #include "syscall/poll.h" /* wakeup_pipe_signal */ #include "syscall/proc.h" @@ -72,6 +75,14 @@ int elfuse_launch(const launch_args_t *args) ? args->guest_argv[0] : args->elf_path; + /* Stage --user before bring-up: prepare's proc_init re-seeds the identity + * state, and build_linux_stack snapshots it into auxv AT_UID/AT_GID. + * Setting the ids after prepare would leave getauxval() reporting the + * default identity while getuid() reports the requested one. + */ + if (args->has_creds) + proc_set_initial_ids(args->uid, args->gid); + if (guest_bootstrap_prepare( &g, args->elf_path, elf_host_temp, elf_guest_path, args->sysroot, args->guest_argc, args->guest_argv, envp_use, shim_bin, @@ -87,6 +98,18 @@ int elfuse_launch(const launch_args_t *args) elf_host_temp = false; } + /* Reject GDB for a Rosetta (x86_64) guest here, not just in main(): the + * stub exposes the aarch64 shim's register/memory view, which is the wrong + * architecture for a Rosetta-translated x86_64 guest. main() rejects it up + * front via a static ELF probe, but enforcing it in elfuse_launch (once + * bring-up has set g.is_rosetta) makes every caller inherit the constraint, + * including the planned OCI run helper. + */ + if (args->gdb_port > 0 && g.is_rosetta) { + log_error("--gdb is not supported for x86_64 (Rosetta) guests"); + goto fail; + } + if (args->sysroot) { bool case_sensitive = true; bool case_preserving = true; @@ -99,6 +122,32 @@ int elfuse_launch(const launch_args_t *args) proc_set_sysroot_casefold(false); } + /* Apply the guest's initial working directory. The guest cwd IS the host + * process cwd (sys_chdir translates a guest path and calls host chdir), + * so --workdir DIR does the same: translate DIR against the sysroot (now + * that casefold is configured above) and chdir to the resulting host path, + * then refresh the cached guest-visible cwd so the first getcwd sees DIR. + * This mirrors the plain real-directory branch of sys_chdir; FUSE-mounted + * or /proc-virtual workdirs are not supported through this flag (neither + * is a realistic image WorkingDir). + */ + if (args->cwd_guest && args->cwd_guest[0] != '\0') { + path_translation_t tx; + if (path_translate_at(LINUX_AT_FDCWD, args->cwd_guest, PATH_TR_NONE, + &tx) < 0) { + log_error("failed to resolve working directory %s: %s", + args->cwd_guest, strerror(errno)); + goto fail; + } + if (chdir(tx.host_path) < 0) { + log_error("failed to set working directory %s: %s", args->cwd_guest, + strerror(errno)); + goto fail; + } + if (proc_cwd_refresh() < 0) + proc_cwd_invalidate(); + } + hv_vcpu_t vcpu; hv_vcpu_exit_t *vexit; if (guest_bootstrap_create_vcpu(&g, &boot, args->verbose, &vcpu, &vexit) < diff --git a/src/core/launch.h b/src/core/launch.h index 66555350..d946197d 100644 --- a/src/core/launch.h +++ b/src/core/launch.h @@ -7,33 +7,16 @@ * fresh HVF VM until it exits". It is shared between main() (legacy * positional-ELF CLI) and future launchers such as the OCI run helper. * - * The function owns the guest_t, the vCPU, the GDB stub, the run loop, - * the diagnostic dumps, and guest teardown; it does NOT own the - * elf_path / sysroot / guest_argv heap copies or the sysroot_mount the - * host CLI may have provisioned; those stay with the caller so - * behaviors that need the original CLI argv (proctitle rewriting, - * --create-sysroot detach on exit, host cwd save+restore) remain - * coherent regardless of how the launch was kicked off. + * The function owns the guest_t, the vCPU, the GDB stub, the run loop, the + * diagnostic dumps, and guest teardown; it does NOT own the elf_path / + * sysroot / guest_argv heap copies or the sysroot_mount the host CLI may + * have provisioned. Those stay with the caller so behaviors that need the + * original CLI argv (proctitle rewriting, --create-sysroot detach on exit, + * host cwd save+restore) stay coherent however the launch was kicked off. * - * Lifetime / ownership contract: - * - * - The caller owns every pointer in launch_args_t. elfuse_launch reads - * them and does not free them; const-qualified pointers stay valid - * for the duration of the call. - * - envp may be NULL; the host process environ is used in that case. - * - guest_argv is the string array the guest sees as its argv. - * guest_argv[0] is the guest-visible entrypoint path (what the guest - * reads back via /proc/self/exe and argv[0]); elf_path is the - * resolved host path to that binary. The two differ when path - * translation or a FUSE-materialized temp is involved. - * - elf_host_temp is true when elf_path is a FUSE-materialized temp - * that must be unlinked once guest_bootstrap_prepare has loaded it - * (skipped for Rosetta guests, which reopen the path). The caller - * owns the unlink for any pre-prepare failure; elfuse_launch owns it - * from the prepare call onward. - * - fork_child_fd / vfork_notify_fd are forwarded for fork-child-routed - * launches; main() dispatches the fork-child path before reaching - * elfuse_launch and so passes -1. + * The caller owns every pointer in launch_args_t for the duration of the + * call; elfuse_launch reads but never frees them. Per-field lifetime and + * ownership notes live on the struct members below. */ #pragma once @@ -42,13 +25,15 @@ #include typedef struct { - /* Host filesystem path to the guest ELF (resolved; may be a - * FUSE-materialized temp when elf_host_temp is true). + /* Host path to the guest ELF; may be a FUSE-materialized temp when + * elf_host_temp is set. */ const char *elf_path; - /* True when elf_path is a temp to unlink after guest_bootstrap_prepare - * loads it. + /* elf_path is a FUSE-materialized temp to unlink once + * guest_bootstrap_prepare has loaded it (kept for Rosetta guests, which + * reopen the path). The caller owns the unlink on any pre-prepare + * failure; elfuse_launch owns it from the prepare call onward. */ bool elf_host_temp; @@ -57,8 +42,10 @@ typedef struct { */ const char *sysroot; - /* String array the guest sees as its argv. guest_argv[0] is the - * guest-visible entrypoint path. + /* Argv the guest sees. guest_argv[0] is the guest-visible entrypoint + * path (what the guest reads back via /proc/self/exe and argv[0]); it + * differs from elf_path (the resolved host path) under path translation + * or a FUSE-materialized temp. */ int guest_argc; const char **guest_argv; @@ -69,6 +56,21 @@ typedef struct { */ char **envp; + /* When true, stage uid/gid as the guest identity before bring-up so the + * auxv AT_UID/AT_GID snapshot and getuid()/getgid() agree. When false, + * uid/gid are ignored and the guest runs under the compile-time default + * GUEST_UID/GUEST_GID (0 under fakeroot), NOT the host identity; a + * launcher that wants the host identity must set has_creds and pass + * getuid()/getgid(). + */ + bool has_creds; + uint32_t uid, gid; + + /* Guest-absolute initial working directory. NULL inherits the host + * cwd (the caller may chdir first to control it). + */ + const char *cwd_guest; + /* GDB Remote Serial Protocol port (0 disables the stub) and whether * to halt before the first guest instruction. */ diff --git a/src/main.c b/src/main.c index 2b0ad695..7afa085b 100644 --- a/src/main.c +++ b/src/main.c @@ -104,6 +104,127 @@ static void free_guest_argv(const char **guest_argv, int guest_argc) free((void *) guest_argv); } +/* Free a guest envp vector produced by build_guest_env. Each entry is a + * heap "KEY=VAL" string owned by us (never a borrowed environ pointer), so + * free every slot then the array. A NULL envp (meaning "use host environ") + * is a no-op. + */ +static void free_envp(char **envp) +{ + if (!envp) + return; + for (char **e = envp; *e; e++) + free(*e); + free((void *) envp); +} + +/* Free the raw --env override array collected during option parsing. These + * strings are distinct from build_guest_env's output (which strdups its own + * copies), so both must be freed. + */ +static void free_env_overrides(char **env_overrides, int n) +{ + free_guest_argv((const char **) env_overrides, n); +} + +/* Build the guest environment vector, mirroring `env(1)` semantics. Returns 0 + * and sets *out_envp to either: + * - NULL when no --env/--clear-env was given, meaning "use the host environ + * as-is" (the pre-flag behavior, preserved exactly), or + * - a malloc'd, NULL-terminated char** of strdup'd "KEY=VAL" strings (caller + * frees with free_envp): the base is the host environ, or empty under + * clear_env, and each override replaces a matching KEY= in place or + * appends. "KEY=VAL" sets; a bare "KEY" inherits KEY from the host environ, + * skipped when unset so it can never create an empty-string variable. Returns + * -1 on allocation failure (out_envp untouched). + */ +static int build_guest_env(char *const *overrides, + int n_overrides, + bool clear_env, + char ***out_envp) +{ + if (n_overrides == 0 && !clear_env) { + *out_envp = NULL; + return 0; + } + + extern char **environ; + int cap = 1; /* NULL terminator */ + if (!clear_env) + for (char **e = environ; *e; e++) + cap++; + cap += n_overrides; + + char **envp = (char **) calloc((size_t) cap, sizeof(char *)); + if (!envp) + return -1; + int n = 0; + + if (!clear_env) { + for (char **e = environ; *e; e++) { + envp[n] = strdup(*e); + if (!envp[n]) + goto fail; + n++; + } + } + + for (int i = 0; i < n_overrides; i++) { + const char *ov = overrides[i]; + const char *eq = strchr(ov, '='); + /* Reject an empty variable name ("--env =VAL", or a bare "--env ""): + * eq == ov (or an empty ov) means a zero-length key. setenv(3), whose + * semantics --env mirrors, rejects an empty name, and appending + * "=VAL" verbatim would hand the guest a malformed environ entry the + * dedup scan cannot match. */ + if ((size_t) (eq ? eq - ov : strlen(ov)) == 0) { + log_error("invalid --env entry \"%s\": empty variable name", ov); + goto fail; + } + char *entry; + if (eq) { + entry = strdup(ov); + } else { + const char *val = getenv(ov); + if (!val) + continue; /* bare KEY, unset on host: skip */ + size_t need = strlen(ov) + 1 + strlen(val) + 1; + entry = (char *) malloc(need); + if (entry) + snprintf(entry, need, "%s=%s", ov, val); + } + if (!entry) + goto fail; + + size_t klen = (size_t) (eq ? eq - ov : strlen(ov)); + int found = -1; + for (int j = 0; j < n; j++) { + if (envp[j] && strncmp(envp[j], ov, klen) == 0 && + envp[j][klen] == '=') { + found = j; + break; + } + } + if (found >= 0) { + free(envp[found]); + envp[found] = entry; + } else { + /* cap is an exact upper bound (host environ + n_overrides + the + * NULL slot) and each override appends at most once, so the + * append can never outgrow the allocation. + */ + envp[n++] = entry; + } + } + envp[n] = NULL; + *out_envp = envp; + return 0; + +fail: + free_envp(envp); + return -1; +} + static void cleanup_main_resources(guest_t *g, bool guest_initialized, sysroot_mount_t *sysroot_mount, @@ -131,12 +252,6 @@ static void cleanup_main_resources(guest_t *g, free((void *) sysroot_path); } -/* The embedded shim binary (shim_blob.h, generated by xxd -i from shim.bin) - * is now included by src/core/launch.c, the single site that hands the blob to - * guest_bootstrap_prepare. main() no longer references shim_bin/shim_bin_len - * directly, so the static blob has one object definition site. - */ - /* The infra-reserve layout invariants documented in guest.h are derived from * raw offset constants, so a future edit that grows the pool by shifting one * offset without the others would silently overlap two regions. Enforce them at @@ -228,6 +343,17 @@ static int host_dc_zva_assert(void) return 0; } +/* One-line usage synopsis shared by the argument-error paths; --help prints + * the long multi-line form. A single definition keeps the copies from + * drifting (one copy had already lost the --gdb flags). + */ +#define ELFUSE_USAGE \ + "usage: elfuse [--verbose] [--timeout N] " \ + "[--sysroot PATH] [--create-sysroot PATH] [--no-rosetta] " \ + "[--fakeroot] [--gdb PORT] [--gdb-stop-on-entry] " \ + "[--user UID[:GID]] [--workdir DIR] [--env KEY=VAL] " \ + "[--clear-env] [args...]" + int main(int argc, char **argv) { log_init(); @@ -251,6 +377,17 @@ int main(int argc, char **argv) int gdb_port = 0; bool gdb_stop_on_entry = false; bool fakeroot = false; + /* Launch flags driven by `elfuse-oci run` (and usable directly). They + * map onto launch_args_t fields; --user overrides the guest identity, + * --workdir sets the guest's initial cwd, --env/--clear-env build the + * guest environment. All are additive: existing flags are unchanged. + */ + bool has_creds = false; + uint32_t uid = 0, gid = 0; + char *workdir = NULL; + char **env_overrides = NULL; + int n_env_overrides = 0, env_cap = 0; + bool clear_env = false; int arg_start = 1; /* 'elfuse rosettad translate ' runs the real Apple rosettad @@ -284,6 +421,8 @@ int main(int argc, char **argv) " [--create-sysroot PATH]\n" " [--no-rosetta] [--fakeroot]\n" " [--gdb PORT] [--gdb-stop-on-entry]\n" + " [--user UID[:GID]] [--workdir DIR]\n" + " [--env KEY=VAL] [--clear-env]\n" " [args...]\n" "\n" "Options:\n" @@ -304,7 +443,17 @@ int main(int argc, char **argv) " --gdb PORT Listen for GDB Remote Serial " "Protocol on PORT\n" " --gdb-stop-on-entry Halt before the first guest " - "instruction\n"); + "instruction\n" + " --user UID[:GID] Run the guest as UID (and GID; " + "defaults to UID). Numeric; elfuse-oci resolves symbolic " + "names\n" + " --workdir DIR Guest-absolute initial working " + "directory (resolved under --sysroot)\n" + " --env KEY=VAL Set a guest environment variable; " + "repeatable. 'KEY' (no '=') inherits from the host environ\n" + " --clear-env Start the guest environment empty " + "(only --env entries apply); default inherits the host " + "environ\n"); return 0; } } @@ -367,24 +516,97 @@ int main(int argc, char **argv) } else if (!strcmp(argv[arg_start], "--gdb-stop-on-entry")) { gdb_stop_on_entry = true; arg_start++; + } else if (!strcmp(argv[arg_start], "--user") && arg_start + 1 < argc) { + /* Numeric UID[:GID]; elfuse-oci resolves symbolic User + * against the image /etc/passwd+group and passes numbers. A bare + * UID sets gid=uid (typical single-user image). + */ + const char *spec = argv[arg_start + 1]; + char *end; + errno = 0; + unsigned long u = strtoul(spec, &end, 10); + if (errno || end == spec || u > UINT32_MAX) { + log_error("invalid --user UID: %s", spec); + goto fail_parse; + } + unsigned long gg = u; + if (*end == ':') { + errno = 0; + char *end2; + gg = strtoul(end + 1, &end2, 10); + if (errno || end2 == end + 1 || *end2 != '\0' || + gg > UINT32_MAX) { + log_error("invalid --user UID:GID: %s", spec); + goto fail_parse; + } + } else if (*end != '\0') { + log_error("invalid --user spec: %s", spec); + goto fail_parse; + } + uid = (uint32_t) u; + gid = (uint32_t) gg; + has_creds = true; + arg_start += 2; + } else if (!strcmp(argv[arg_start], "--workdir") && + arg_start + 1 < argc) { + /* Guest-absolute working directory; elfuse_launch translates it + * against the sysroot and chdirs there. Reject relative paths up + * front: translation would resolve them against the host cwd, + * silently starting the guest outside the intended tree. strdup + * now because runtime_set_process_title clobbers the original + * argv block. + */ + if (argv[arg_start + 1][0] != '/') { + log_error("--workdir requires a guest-absolute path, got %s", + argv[arg_start + 1]); + goto fail_parse; + } + free(workdir); + workdir = strdup(argv[arg_start + 1]); + if (!workdir) { + log_error("out of memory"); + goto fail_parse; + } + arg_start += 2; + } else if (!strcmp(argv[arg_start], "--env") && arg_start + 1 < argc) { + /* "KEY=VAL" sets; "KEY" inherits from the host environ (resolved + * in build_guest_env). strdup now; argv is clobbered later. + */ + if (n_env_overrides == env_cap) { + int ncap = env_cap ? env_cap * 2 : 8; + char **grown = (char **) realloc( + (void *) env_overrides, (size_t) ncap * sizeof(char *)); + if (!grown) { + log_error("out of memory"); + goto fail_parse; + } + env_overrides = grown; + env_cap = ncap; + } + env_overrides[n_env_overrides] = strdup(argv[arg_start + 1]); + if (!env_overrides[n_env_overrides]) { + log_error("out of memory"); + goto fail_parse; + } + n_env_overrides++; + arg_start += 2; + } else if (!strcmp(argv[arg_start], "--clear-env")) { + clear_env = true; + arg_start++; } else if (!strcmp(argv[arg_start], "--")) { arg_start++; break; } else { log_error("unknown option: %s", argv[arg_start]); - log_error( - "usage: elfuse [--verbose] [--timeout N] " - "[--sysroot PATH] [--create-sysroot PATH] [--no-rosetta] " - "[--fakeroot] [--gdb PORT] " - "[--gdb-stop-on-entry] [args...]"); - return 1; + log_error(ELFUSE_USAGE); + goto fail_parse; } } if (sysroot && create_sysroot) { log_error( "use either --sysroot PATH or --create-sysroot PATH, not both"); - return 1; + goto fail_parse; } /* ELFUSE_NO_ROSETTA=1 mirrors --no-rosetta for environments where passing @@ -413,14 +635,14 @@ int main(int argc, char **argv) * internal host reserve. */ if (host_nofile_ensure_capacity() < 0) - return 1; + goto fail_parse; /* Block the vCPU-preemption signals and start the sigwait thread before any * vCPU thread exists, so both the normal path and the fork-child path below * inherit the block on every thread they spawn. */ if (proc_preempt_init() < 0) - return 1; + goto fail_parse; /* Fork-child mode: receive VM state over IPC and run */ if (fork_child_fd >= 0) @@ -428,10 +650,20 @@ int main(int argc, char **argv) timeout_sec); if (arg_start >= argc) { - log_error( - "usage: elfuse [--verbose] [--timeout N] " - "[--sysroot PATH] [--create-sysroot PATH] [--no-rosetta] " - "[--fakeroot] [args...]"); + log_error(ELFUSE_USAGE); + goto fail_parse; + } + + /* Shared unwind for argument-parsing errors past the point where --env / + * --workdir may have allocated: frees those two (the only heap state owned + * before the elf_path/guest_argv copies below) and exits 1. Placed before + * those later declarations so no goto crosses into their scope; post-copy + * paths use the cleanup: label instead. + */ + if (0) { + fail_parse: + free_env_overrides(env_overrides, n_env_overrides); + free(workdir); return 1; } @@ -453,6 +685,8 @@ int main(int argc, char **argv) src_len, LINUX_PATH_MAX - 1, sysroot_src); free(elf_path); free(sysroot_path); + free_env_overrides(env_overrides, n_env_overrides); + free(workdir); return 1; } } @@ -468,6 +702,10 @@ int main(int argc, char **argv) char elf_host_path[LINUX_PATH_MAX]; bool elf_host_temp = false; bool have_host_cwd = (getcwd(host_cwd, sizeof(host_cwd)) != NULL); + /* Declared (and NULL-initialized) before the first `goto fail` so the + * shared cleanup below never frees an uninitialized pointer. + */ + char **envp = NULL; int exit_code; memset(&sysroot_mount, 0, sizeof(sysroot_mount)); if (!elf_path || (have_sysroot && !sysroot_path) || !guest_argv) { @@ -605,22 +843,37 @@ int main(int argc, char **argv) } } + /* Build the guest environment vector late (after the shebang loop and the + * --gdb guard) so only this block's OOM path and the post-launch cleanup + * must free it. With neither --env nor --clear-env given, build_guest_env + * leaves envp NULL and elfuse_launch uses the host environ (pre-flag + * behavior, preserved); it owns that condition, not this call site. + */ + if (build_guest_env(env_overrides, n_env_overrides, clear_env, &envp) < 0) { + /* build_guest_env has already logged the specific reason (OOM or a + * malformed --env entry). + */ + goto fail; + } + /* build_guest_env strdups its own copies, so the raw override array and + * its strings are no longer needed. + */ + free_env_overrides(env_overrides, n_env_overrides); + env_overrides = NULL; + n_env_overrides = 0; + /* Rewrite the host-visible process title from the guest entrypoint. This * clobbers the original argv block (already snapshotted into the heap * elf_path / guest_argv above), so it must run before elfuse_launch hands - * control to the guest but after the shebang loop has fixed elf_path. The - * call only touches the original argv and the kernel procname; it does not - * depend on guest_bootstrap_prepare having run, so hoisting it out of the - * bring-up (where it previously sat between prepare and create_vcpu) is - * behavior-preserving. + * control to the guest but after the shebang loop has fixed elf_path. */ runtime_set_process_title(argc, argv, elf_path); - /* Hand the bring-up, run loop, and guest teardown to elfuse_launch. main() - * retains ownership of the original argv (proctitle above), the sysroot - * mount (detached in cleanup_main_resources after the guest exits so the - * mount stays live for the whole run), host cwd, and the heap elf_path / - * sysroot_path / guest_argv copies. + /* Hand bring-up, run loop, and guest teardown to elfuse_launch. main() + * keeps ownership of the original argv (proctitle above), the sysroot mount + * (detached in cleanup_main_resources after the guest exits so it stays + * live for the whole run), host cwd, and the heap elf_path / sysroot_path / + * guest_argv / envp / workdir copies. */ launch_args_t largs = { .elf_path = elf_host_path, @@ -628,6 +881,11 @@ int main(int argc, char **argv) .sysroot = sysroot, .guest_argc = guest_argc, .guest_argv = guest_argv, + .envp = envp, + .has_creds = has_creds, + .uid = uid, + .gid = gid, + .cwd_guest = workdir, .gdb_port = gdb_port, .gdb_stop_on_entry = gdb_stop_on_entry, .timeout_sec = timeout_sec, @@ -647,9 +905,14 @@ int main(int argc, char **argv) /* Single unwind for every exit past the heap-copy allocations. On the * success path elfuse_launch has already run guest_destroy * (guest_initialized never becomes true in main), so this frees the - * caller-owned heap copies, detaches the sysroot mount, restores the - * host cwd, and drops a still-owned FUSE-materialized temp ELF. + * caller-owned heap copies, detaches the sysroot mount, restores the host + * cwd, and drops a still-owned FUSE-materialized temp ELF. A successful + * build_guest_env already freed and reset the override array, so freeing it + * here is then a no-op. */ + free_env_overrides(env_overrides, n_env_overrides); + free_envp(envp); + free(workdir); cleanup_main_resources(&g, guest_initialized, &sysroot_mount, have_host_cwd ? host_cwd : NULL, guest_argv, guest_argc, elf_path, sysroot_path); diff --git a/src/syscall/proc-identity.c b/src/syscall/proc-identity.c index 68991d39..644954be 100644 --- a/src/syscall/proc-identity.c +++ b/src/syscall/proc-identity.c @@ -31,6 +31,10 @@ static _Atomic int32_t guest_has_ctty = 1; static _Atomic bool fakeroot_enabled = false; +static _Atomic bool initial_ids_staged = false; +static _Atomic uint32_t initial_uid = GUEST_UID; +static _Atomic uint32_t initial_gid = GUEST_GID; + void proc_set_fakeroot_enabled(bool enabled) { atomic_store(&fakeroot_enabled, enabled); @@ -41,6 +45,13 @@ bool proc_fakeroot_enabled(void) return atomic_load(&fakeroot_enabled); } +void proc_set_initial_ids(uint32_t uid, uint32_t gid) +{ + atomic_store(&initial_uid, uid); + atomic_store(&initial_gid, gid); + atomic_store(&initial_ids_staged, true); +} + void proc_identity_init(void) { guest_pid = 1; @@ -55,6 +66,19 @@ void proc_identity_init(void) gid = 0; } + /* An explicit --user request wins over the defaults and over fakeroot. + * It is staged before init rather than applied afterwards because + * build_linux_stack snapshots these values into auxv AT_UID/AT_GID; a + * post-init override would leave getauxval() disagreeing with getuid(). + * Consume the staged value so it applies only to the bring-up it was + * staged for; a later launch in the same host process without credentials + * falls back to the defaults instead of inheriting the prior identity. + */ + if (atomic_exchange(&initial_ids_staged, false)) { + uid = atomic_load(&initial_uid); + gid = atomic_load(&initial_gid); + } + emu_uid = uid; emu_euid = uid; emu_suid = uid; diff --git a/src/syscall/proc.h b/src/syscall/proc.h index 83104fef..eaec0c47 100644 --- a/src/syscall/proc.h +++ b/src/syscall/proc.h @@ -108,6 +108,14 @@ bool proc_rosetta_active(void); void proc_set_fakeroot_enabled(bool enabled); bool proc_fakeroot_enabled(void); +/* Stage the initial guest credentials (--user) before proc_init. + * proc_identity_init applies them in place of the GUEST_UID/GUEST_GID + * defaults, so the auxv AT_UID/AT_GID snapshot taken by build_linux_stack + * matches what getuid()/getgid() later report. The staged value is consumed + * by the next proc_identity_init, so it applies to a single bring-up only. + */ +void proc_set_initial_ids(uint32_t uid, uint32_t gid); + /* Store the guest command line for /proc/self/cmdline emulation. argv is a * NULL-terminated array of strings. */ From 476ec33b4df2db4dd5f1397b76cd20b55cf4de36 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 15 Jul 2026 23:22:51 +0200 Subject: [PATCH 14/22] Add elfuse-oci store with pull and inspect elfuse-oci is a standalone Go binary that owns the OCI image pipeline; elfuse itself stays a pure Linux syscall-to-Darwin runtime with no OCI commands. This first slice is the acquisition half: an OCI image-layout store plus the pull and inspect commands, built on go-containerregistry ($ELFUSE_OCI_STORE or ~/.local/share/elfuse/oci by default). The store is a spec-shape image layout other tools can read, with a refs.json pin table mapping references to manifest digests. An exclusive flock serializes refs.json/index.json updates so concurrent pulls cannot lose pins (the cold-store bootstrap of oci-layout and index.json runs under the same lock, double-checked so a warm store skips it), pin persistence syncs the temp file and directory around the rename, and a nil-object refs.json is rejected as corrupt instead of treated as empty. addImage distinguishes genuinely-absent from unreadable descriptors by scanning index membership, so store corruption surfaces rather than duplicating entries. digestFor returns a distinct errNotPulled for a missing ref so later callers can tell "not pulled" from "store broken". Two helpers keep the plumbing in one place: every subcommand opens the store through commonFlags.openResolvedStore (resolve the store path, then open the layout), and store.withLock scopes lock-held sections; pin and addImage wrap their load-modify-save cycles in it so a critical section cannot leak its lock on an error path. pull resolves the requested platform (default linux/arm64) and validates --platform shape up front; inspect prints the manifest and config summary (or --json) and propagates digest/size and writer errors instead of reporting partial output as success. Credentials come from the ambient default keychain, but its resolution is time-bounded: it shells out to whatever helper the Docker config names, and go-containerregistry drops the context around that exec, so a wedged helper would otherwise hang the pull with no output. A wrapper keychain caps the wait and fails with an explanation and the DOCKER_CONFIG escape hatch instead; a progress line is printed before the pull so it is never silent. Tests cover the pin table, store locking, error kinds, flag parsing, and the command dispatch, with cranePull as a swappable seam so no test touches the network. --- Makefile | 19 +- cmd/elfuse-oci/commands.go | 59 ++++++ cmd/elfuse-oci/common.go | 177 +++++++++++++++++ cmd/elfuse-oci/common_test.go | 90 +++++++++ cmd/elfuse-oci/inspect.go | 84 ++++++++ cmd/elfuse-oci/inspect_test.go | 155 +++++++++++++++ cmd/elfuse-oci/keychain.go | 56 ++++++ cmd/elfuse-oci/keychain_test.go | 74 +++++++ cmd/elfuse-oci/main.go | 83 ++++++++ cmd/elfuse-oci/main_command_test.go | 84 ++++++++ cmd/elfuse-oci/pull.go | 50 +++++ cmd/elfuse-oci/store.go | 294 ++++++++++++++++++++++++++++ cmd/elfuse-oci/store_test.go | 247 +++++++++++++++++++++++ cmd/elfuse-oci/test_helpers_test.go | 157 +++++++++++++++ go.mod | 17 ++ go.sum | 30 +++ mk/toolchain.mk | 5 + 17 files changed, 1680 insertions(+), 1 deletion(-) create mode 100644 cmd/elfuse-oci/commands.go create mode 100644 cmd/elfuse-oci/common.go create mode 100644 cmd/elfuse-oci/common_test.go create mode 100644 cmd/elfuse-oci/inspect.go create mode 100644 cmd/elfuse-oci/inspect_test.go create mode 100644 cmd/elfuse-oci/keychain.go create mode 100644 cmd/elfuse-oci/keychain_test.go create mode 100644 cmd/elfuse-oci/main.go create mode 100644 cmd/elfuse-oci/main_command_test.go create mode 100644 cmd/elfuse-oci/pull.go create mode 100644 cmd/elfuse-oci/store.go create mode 100644 cmd/elfuse-oci/store_test.go create mode 100644 cmd/elfuse-oci/test_helpers_test.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/Makefile b/Makefile index dce5bf9c..1776b7ff 100644 --- a/Makefile +++ b/Makefile @@ -102,7 +102,7 @@ endef .PHONY: all elfuse .PHONY: gen-syscall-dispatch check-syscall-dispatch -all: elfuse +all: elfuse elfuse-oci ## Regenerate build/dispatch.h from src/syscall/dispatch.tbl gen-syscall-dispatch: @@ -127,6 +127,23 @@ elfuse: $(ELFUSE_BIN) $(ELFUSE_BIN): $(OBJS) | $(BUILD_DIR) $(call link-and-sign,$@,$(OBJS)) +# OCI image CLI (Go). Pure Go, no HVF entitlement or codesigning required, +# so it also builds under Linux for spec-conformance / interop CI. The version +# is stamped from the same VERSION string the C binary uses. +OCI_BIN := $(BUILD_DIR)/elfuse-oci +OCI_SRCS := $(shell find cmd/elfuse-oci -type f -name '*.go' 2>/dev/null) + +.PHONY: elfuse-oci +elfuse-oci: $(OCI_BIN) + +# rm -f first: `go build -o` follows an existing symlink at the output path, +# so a stale build/elfuse-oci symlink would clobber build/elfuse. +$(OCI_BIN): go.mod $(OCI_SRCS) | $(BUILD_DIR) + @echo " GO $@" + $(Q)rm -f $@ + $(Q)cd $(CURDIR) && $(GO) build -ldflags "-X main.version=$(VERSION)" \ + -o $@ ./cmd/elfuse-oci + # Native test binaries (macOS, Hypervisor.framework) ## Build the multi-vCPU HVF validation test (native macOS binary) diff --git a/cmd/elfuse-oci/commands.go b/cmd/elfuse-oci/commands.go new file mode 100644 index 00000000..4d59efe1 --- /dev/null +++ b/cmd/elfuse-oci/commands.go @@ -0,0 +1,59 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" +) + +// The subcommands share common-flag parsing (common.go) and the OCI +// image-layout store (store.go). pull and inspect are pure store ops. + +// cmdPull implements `elfuse-oci pull [--store] [--platform] `. +func cmdPull(args []string) error { + cf, ref, err := parsePullArgs(args) + if err != nil { + return err + } + s, err := cf.openResolvedStore() + if err != nil { + return err + } + return pullImage(cf, s, ref) +} + +func parsePullArgs(args []string) (commonFlags, string, error) { + var cf commonFlags + fs := newCommandFlagSet("pull", &cf) + if err := fs.Parse(args); err != nil { + return cf, "", err + } + ref, err := oneArg("pull", fs.Args(), "") + return cf, ref, err +} + +// cmdInspect implements `elfuse-oci inspect [--store] [--json] `. +func cmdInspect(args []string) error { + cf, asJSON, ref, err := parseInspectArgs(args) + if err != nil { + return err + } + s, err := cf.openResolvedStore() + if err != nil { + return err + } + return inspect(os.Stdout, s, ref, asJSON) +} + +func parseInspectArgs(args []string) (commonFlags, bool, string, error) { + var cf commonFlags + var asJSON bool + fs := newCommandFlagSet("inspect", &cf) + fs.BoolVar(&asJSON, "json", false, "") + if err := fs.Parse(args); err != nil { + return cf, false, "", err + } + ref, err := oneArg("inspect", fs.Args(), "") + return cf, asJSON, ref, err +} diff --git a/cmd/elfuse-oci/common.go b/cmd/elfuse-oci/common.go new file mode 100644 index 00000000..925f2812 --- /dev/null +++ b/cmd/elfuse-oci/common.go @@ -0,0 +1,177 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "flag" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" +) + +// version is stamped at build time via -ldflags "-X main.version=...". The +// default "dev" is what `go build` without the stamp produces. +var version = "dev" + +// Platform is an OCI platform triple. Variant is optional (e.g. "v8" for +// arm64); empty means "the default variant for this arch". +type Platform struct { + OS string + Arch string + Variant string +} + +func (p Platform) String() string { + if p.Variant != "" { + return p.OS + "/" + p.Arch + "/" + p.Variant + } + return p.OS + "/" + p.Arch +} + +// Set implements flag.Value for --platform. +func (p *Platform) Set(s string) error { + parsed, err := parsePlatform(s) + if err != nil { + return err + } + *p = parsed + return nil +} + +// defaultPlatform is linux/arm64: elfuse runs aarch64-linux guests natively via +// HVF, and x86_64 guests via Rosetta. elfuse-oci targets arm64 by default; +// --platform selects another (e.g. linux/amd64 for an x86_64 image run under +// Rosetta). +var defaultPlatform = Platform{OS: "linux", Arch: "arm64"} + +// parsePlatform parses "os/arch" or "os/arch/variant". The value must have +// exactly two or three slash-separated components, each non-empty: "linux//", +// "/arm64", "linux/arm64/", and "linux/arm64/v8/extra" are all rejected rather +// than riding through to the registry client as a nonsense platform. +func parsePlatform(s string) (Platform, error) { + parts := strings.Split(s, "/") + if len(parts) < 2 || len(parts) > 3 || slices.Contains(parts, "") { + return Platform{}, fmt.Errorf("invalid --platform %q (want os/arch[/variant])", s) + } + if len(parts) == 3 { + return Platform{OS: parts[0], Arch: parts[1], Variant: parts[2]}, nil + } + return Platform{OS: parts[0], Arch: parts[1]}, nil +} + +// defaultStore returns the OCI store directory: $ELFUSE_OCI_STORE if set, +// otherwise ~/.local/share/elfuse/oci. The store is an OCI image-layout +// (blobs/, index.json) plus a ref->digest pin table (see store.go). +func defaultStore() (string, error) { + if s := os.Getenv("ELFUSE_OCI_STORE"); s != "" { + return s, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("no --store given and $HOME unset: %w", err) + } + return filepath.Join(home, ".local", "share", "elfuse", "oci"), nil +} + +// commonFlags holds the flags shared by every subcommand. +type commonFlags struct { + store string + platform Platform + // platformSet records an explicit --platform: run uses it to validate the + // pinned image, while the default platform stays advisory so a ref pulled + // for another platform still runs without re-specifying --platform. + platformSet bool +} + +// platformFlag adapts commonFlags.platform to flag.Value while recording +// that the flag was set explicitly. +type platformFlag struct{ cf *commonFlags } + +func (pf platformFlag) String() string { + if pf.cf == nil { + return "" + } + return pf.cf.platform.String() +} + +func (pf platformFlag) Set(s string) error { + if err := pf.cf.platform.Set(s); err != nil { + return err + } + pf.cf.platformSet = true + return nil +} + +// resolveStore fills cf.store with the default when unset and ensures the +// directory exists. +func (cf *commonFlags) resolveStore() error { + if cf.store == "" { + s, err := defaultStore() + if err != nil { + return err + } + cf.store = s + } + return os.MkdirAll(cf.store, 0o755) +} + +// openResolvedStore is every subcommand's store preamble: resolve the store +// path (defaulting and creating it) and open the layout. +func (cf *commonFlags) openResolvedStore() (*store, error) { + if err := cf.resolveStore(); err != nil { + return nil, err + } + return openStore(cf.store) +} + +// newCommandFlagSet creates a FlagSet whose parse errors are returned (not +// exited on) so main reports them uniformly, while ` -h` and a bad flag +// still print that subcommand's own flag list. The FlagSet's own error line is +// discarded (main prints the returned error); the Usage closure writes the flag +// list straight to stderr so it survives regardless. +func newCommandFlagSet(name string, cf *commonFlags) *flag.FlagSet { + *cf = commonFlags{platform: defaultPlatform} + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "usage: elfuse-oci %s [flags]\n", name) + fs.SetOutput(os.Stderr) + fs.PrintDefaults() + fs.SetOutput(io.Discard) + } + fs.StringVar(&cf.store, "store", "", "OCI store directory (default $ELFUSE_OCI_STORE or ~/.local/share/elfuse/oci)") + fs.Var(platformFlag{cf}, "platform", "target platform os/arch[/variant]") + return fs +} + +type repeatedStringFlag []string + +func (f *repeatedStringFlag) String() string { + if f == nil { + return "" + } + return strings.Join(*f, ",") +} + +func (f *repeatedStringFlag) Set(s string) error { + *f = append(*f, s) + return nil +} + +func oneArg(cmd string, args []string, what string) (string, error) { + if len(args) != 1 { + return "", fmt.Errorf("%s: expected one %s, got %d", cmd, what, len(args)) + } + return args[0], nil +} + +func noArgs(cmd string, args []string) error { + if len(args) != 0 { + return fmt.Errorf("%s: takes no argument", cmd) + } + return nil +} diff --git a/cmd/elfuse-oci/common_test.go b/cmd/elfuse-oci/common_test.go new file mode 100644 index 00000000..8bf083ee --- /dev/null +++ b/cmd/elfuse-oci/common_test.go @@ -0,0 +1,90 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "reflect" + "testing" +) + +func TestParsePlatform(t *testing.T) { + cases := []struct { + in string + want Platform + wantErr bool + }{ + {"linux/arm64", Platform{OS: "linux", Arch: "arm64"}, false}, + {"linux/amd64/v8", Platform{OS: "linux", Arch: "amd64", Variant: "v8"}, false}, + {"darwin/arm64", Platform{OS: "darwin", Arch: "arm64"}, false}, + {"linux", Platform{}, true}, + {"", Platform{}, true}, + {"linux//", Platform{}, true}, + {"/arm64", Platform{}, true}, + {"linux//v8", Platform{}, true}, + {"linux/arm64/", Platform{}, true}, + {"linux/arm64/v8/extra", Platform{}, true}, + {"//", Platform{}, true}, + } + for _, c := range cases { + got, err := parsePlatform(c.in) + if (err != nil) != c.wantErr { + t.Errorf("parsePlatform(%q): err=%v, wantErr=%v", c.in, err, c.wantErr) + continue + } + if c.wantErr { + continue + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("parsePlatform(%q): got %+v, want %+v", c.in, got, c.want) + } + if got.String() != c.in { + t.Errorf("Platform(%q).String() = %q, want %q", c.in, got.String(), c.in) + } + } +} + +func TestParsePullArgs(t *testing.T) { + cf, ref, err := parsePullArgs([]string{"--store", "/s", "--platform", "linux/amd64", "alpine:3"}) + if err != nil { + t.Fatal(err) + } + if ref != "alpine:3" { + t.Fatalf("ref = %q, want alpine:3", ref) + } + if cf.store != "/s" { + t.Errorf("store = %q, want /s", cf.store) + } + if !reflect.DeepEqual(cf.platform, Platform{OS: "linux", Arch: "amd64"}) { + t.Errorf("platform = %+v, want linux/amd64", cf.platform) + } +} + +func TestParseInspectArgs(t *testing.T) { + _, asJSON, ref, err := parseInspectArgs([]string{"--json", "alpine:3"}) + if err != nil { + t.Fatal(err) + } + if !asJSON { + t.Error("asJSON = false, want true") + } + if ref != "alpine:3" { + t.Errorf("ref = %q, want alpine:3", ref) + } +} + +func TestParseCommandFlagErrors(t *testing.T) { + cases := []struct { + name string + err error + }{ + {"malformed platform", func() error { _, _, err := parsePullArgs([]string{"--platform", "bogus", "alpine:3"}); return err }()}, + {"unknown flag", func() error { _, _, err := parsePullArgs([]string{"--unknown", "alpine:3"}); return err }()}, + {"missing ref", func() error { _, _, err := parsePullArgs(nil); return err }()}, + } + for _, tc := range cases { + if tc.err == nil { + t.Errorf("%s: got nil error", tc.name) + } + } +} diff --git a/cmd/elfuse-oci/inspect.go b/cmd/elfuse-oci/inspect.go new file mode 100644 index 00000000..bec2f475 --- /dev/null +++ b/cmd/elfuse-oci/inspect.go @@ -0,0 +1,84 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "fmt" + "io" +) + +// inspect prints a stored image's manifest + config. With --json the raw config +// JSON is emitted (one object); otherwise a human-readable summary. +func inspect(w io.Writer, s *store, ref string, asJSON bool) error { + img, err := s.image(ref) + if err != nil { + return err + } + d, err := img.Digest() + if err != nil { + return err + } + cfg, err := img.ConfigFile() + if err != nil { + return err + } + cf := cfg.Config + + if asJSON { + // The raw config blob, not a re-marshal of the parsed struct: fields + // ggcr does not model (vendor extensions) must survive, and the bytes + // should diff cleanly against `skopeo inspect --config`. + b, err := img.RawConfigFile() + if err != nil { + return err + } + // A failed write (closed pipe, full disk on a redirect) must not + // exit 0: callers would consume truncated JSON as success. + if _, err := fmt.Fprintf(w, "%s\n", b); err != nil { + return fmt.Errorf("inspect: write: %w", err) + } + return nil + } + + // bufio latches the first write error and reports it at Flush, so the + // summary can print unconditionally without an if around every line. + bw := bufio.NewWriter(w) + w = bw + + fmt.Fprintf(w, "%-12s %s\n", "Ref:", ref) + fmt.Fprintf(w, "%-12s %s\n", "Digest:", d) + fmt.Fprintf(w, "%-12s %s/%s\n", "Platform:", cfg.OS, cfg.Architecture) + if !cfg.Created.IsZero() { + fmt.Fprintf(w, "%-12s %s\n", "Created:", cfg.Created.UTC().Format("2006-01-02T15:04:05Z")) + } + fmt.Fprintf(w, "%-12s %v\n", "Entrypoint:", cf.Entrypoint) + fmt.Fprintf(w, "%-12s %v\n", "Cmd:", cf.Cmd) + fmt.Fprintf(w, "%-12s %s\n", "WorkingDir:", cf.WorkingDir) + fmt.Fprintf(w, "%-12s %s\n", "User:", cf.User) + fmt.Fprintf(w, "Env (%d):\n", len(cf.Env)) + for _, e := range cf.Env { + fmt.Fprintf(w, " %s\n", e) + } + layers, err := img.Layers() + if err != nil { + return err + } + fmt.Fprintf(w, "Layers (%d):\n", len(layers)) + for i, l := range layers { + ld, err := l.Digest() + if err != nil { + return fmt.Errorf("inspect: layer %d digest: %w", i, err) + } + ls, err := l.Size() + if err != nil { + return fmt.Errorf("inspect: layer %d size: %w", i, err) + } + fmt.Fprintf(w, " %2d %s %d bytes\n", i, ld, ls) + } + if err := bw.Flush(); err != nil { + return fmt.Errorf("inspect: write: %w", err) + } + return nil +} diff --git a/cmd/elfuse-oci/inspect_test.go b/cmd/elfuse-oci/inspect_test.go new file mode 100644 index 00000000..0a2b240b --- /dev/null +++ b/cmd/elfuse-oci/inspect_test.go @@ -0,0 +1,155 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" +) + +// TestInspectHuman asserts the human-readable summary surfaces the ref, +// platform, and command. Substring checks (not column spacing) keep it robust +// to formatting tweaks. +func TestInspectHuman(t *testing.T) { + s := openTestStore(t) + ref := "local:tiny" + if _, err := s.addImage(ref, tinyImage(t)); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := inspect(&buf, s, ref, false); err != nil { + t.Fatal(err) + } + out := buf.String() + for _, want := range []string{"local:tiny", "Platform:", "linux/arm64", "Cmd:", "/hello"} { + if !strings.Contains(out, want) { + t.Errorf("inspect output missing %q:\n%s", want, out) + } + } +} + +// TestInspectJSON asserts --json emits a valid v1.ConfigFile with the tiny +// image's architecture/os/cmd. +func TestInspectJSON(t *testing.T) { + s := openTestStore(t) + ref := "local:tiny" + if _, err := s.addImage(ref, tinyImage(t)); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := inspect(&buf, s, ref, true); err != nil { + t.Fatal(err) + } + var cf v1.ConfigFile + if err := json.Unmarshal(buf.Bytes(), &cf); err != nil { + t.Fatalf("json unmarshal: %v (raw %q)", err, buf.String()) + } + if cf.Architecture != "arm64" { + t.Errorf("Architecture: got %q, want arm64", cf.Architecture) + } + if cf.OS != "linux" { + t.Errorf("OS: got %q, want linux", cf.OS) + } + if len(cf.Config.Cmd) != 1 || cf.Config.Cmd[0] != "/hello" { + t.Errorf("Cmd: got %v, want [/hello]", cf.Config.Cmd) + } +} + +func imageWithRichConfig(t *testing.T) v1.Image { + t.Helper() + img := tinyImage(t) + cfg, err := img.ConfigFile() + if err != nil { + t.Fatal(err) + } + cfg.Created = v1.Time{Time: time.Date(2026, 7, 9, 12, 34, 56, 0, time.FixedZone("test", 2*60*60))} + cfg.Config.Entrypoint = []string{"/entry"} + cfg.Config.Cmd = []string{"arg"} + cfg.Config.Env = []string{"A=1", "B=2"} + cfg.Config.WorkingDir = "/work" + cfg.Config.User = "1000:1000" + img, err = mutate.ConfigFile(img, cfg) + if err != nil { + t.Fatal(err) + } + return img +} + +func TestInspectHumanIncludesCreatedEnvAndLayers(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:rich", imageWithRichConfig(t)); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := inspect(&buf, s, "local:rich", false); err != nil { + t.Fatal(err) + } + out := buf.String() + for _, want := range []string{ + "Ref: local:rich", + "Created: 2026-07-09T10:34:56Z", + "Entrypoint: [/entry]", + "Cmd: [arg]", + "WorkingDir: /work", + "User: 1000:1000", + "Env (2):", + "A=1", + "Layers (1):", + } { + if !strings.Contains(out, want) { + t.Fatalf("inspect output missing %q:\n%s", want, out) + } + } +} + +func TestInspectMissingRefAndConfigErrors(t *testing.T) { + s := openTestStore(t) + if err := inspect(&bytes.Buffer{}, s, "local:missing", false); err == nil || !strings.Contains(err.Error(), "not pulled") { + t.Fatalf("inspect missing ref err = %v, want not pulled", err) + } + + img := tinyImage(t) + config, err := img.ConfigName() + if err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:tiny", img); err != nil { + t.Fatal(err) + } + if err := os.Remove(blobPath(s.root, config.String())); err != nil { + t.Fatal(err) + } + if err := inspect(&bytes.Buffer{}, s, "local:tiny", false); err == nil { + t.Fatal("inspect with missing config succeeded, want error") + } +} + +// failingWriter fails every write, standing in for a closed pipe or a full +// filesystem behind a redirect. +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("sink failed") } + +func TestInspectPropagatesWriteErrors(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:tiny", tinyImage(t)); err != nil { + t.Fatal(err) + } + if err := inspect(failingWriter{}, s, "local:tiny", true); err == nil { + t.Fatal("inspect --json exited clean on a failed write, want error") + } + if err := inspect(failingWriter{}, s, "local:tiny", false); err == nil { + t.Fatal("inspect summary exited clean on a failed write, want error") + } +} diff --git a/cmd/elfuse-oci/keychain.go b/cmd/elfuse-oci/keychain.go new file mode 100644 index 00000000..40eea883 --- /dev/null +++ b/cmd/elfuse-oci/keychain.go @@ -0,0 +1,56 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "time" + + "github.com/google/go-containerregistry/pkg/authn" +) + +// credResolveTimeout bounds how long credential resolution may run before the +// pull gives up. Resolution shells out to whatever helper the ambient Docker +// config names (credsStore / credHelpers), and go-containerregistry's default +// keychain discards the context around that exec, so a wedged helper (for +// example docker-credential-desktop with Docker Desktop not running) otherwise +// hangs the pull with no output and no way out but a signal. +const credResolveTimeout = 10 * time.Second + +// timedKeychain runs an inner keychain's Resolve under a deadline. On timeout it +// returns an actionable error instead of blocking forever; the inner goroutine +// is abandoned and reaped when the process exits. It preserves the inner +// keychain's result in the normal case, so registry auth is unchanged when the +// helper answers promptly. +type timedKeychain struct { + inner authn.Keychain + timeout time.Duration +} + +func (t timedKeychain) Resolve(r authn.Resource) (authn.Authenticator, error) { + type resolved struct { + auth authn.Authenticator + err error + } + // Buffered so the goroutine's send never blocks after a timeout, leaving + // nothing to leak once the helper finally returns. + ch := make(chan resolved, 1) + go func() { + auth, err := t.inner.Resolve(r) + ch <- resolved{auth, err} + }() + + select { + case out := <-ch: + return out.auth, out.err + case <-time.After(t.timeout): + return nil, fmt.Errorf( + "resolving credentials for %s timed out after %s; a docker "+ + "credential helper (credsStore or credHelpers in "+ + "~/.docker/config.json) may be blocked. Retry with "+ + "DOCKER_CONFIG pointing at a directory whose config.json "+ + "omits it for an anonymous pull", + r.String(), t.timeout) + } +} diff --git a/cmd/elfuse-oci/keychain_test.go b/cmd/elfuse-oci/keychain_test.go new file mode 100644 index 00000000..6e40e462 --- /dev/null +++ b/cmd/elfuse-oci/keychain_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/authn" +) + +// fakeResource is a minimal authn.Resource for driving timedKeychain.Resolve. +type fakeResource struct{ s string } + +func (f fakeResource) String() string { return f.s } +func (f fakeResource) RegistryStr() string { return f.s } + +// blockingKeychain models a wedged credential helper: Resolve never returns +// until the test releases it. +type blockingKeychain struct{ release chan struct{} } + +func (b blockingKeychain) Resolve(authn.Resource) (authn.Authenticator, error) { + <-b.release + return authn.Anonymous, nil +} + +// A wedged helper must surface a bounded, actionable error rather than hang. +// The old failure was an indefinite block with no output; a regression here +// reads as Resolve never returning within the timeout. +func TestTimedKeychainReportsTimeout(t *testing.T) { + inner := blockingKeychain{release: make(chan struct{})} + defer close(inner.release) // let the parked goroutine exit after the test + + tk := timedKeychain{inner: inner, timeout: 50 * time.Millisecond} + + done := make(chan error, 1) + go func() { + _, err := tk.Resolve(fakeResource{s: "registry.example/img"}) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected a timeout error, got nil") + } + if !strings.Contains(err.Error(), "DOCKER_CONFIG") { + t.Fatalf("error does not name the workaround: %v", err) + } + if !strings.Contains(err.Error(), "registry.example/img") { + t.Fatalf("error does not name the resource: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Resolve did not return within the timeout; it hung") + } +} + +// A helper that answers promptly must pass its result through untouched, so +// registry auth is unchanged when nothing is wedged. +func TestTimedKeychainPassesThroughFastResult(t *testing.T) { + ready := blockingKeychain{release: make(chan struct{})} + close(ready.release) // already released: Resolve returns immediately + + tk := timedKeychain{inner: ready, timeout: 2 * time.Second} + auth, err := tk.Resolve(fakeResource{s: "registry.example/img"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if auth != authn.Anonymous { + t.Fatalf("authenticator = %v, want the inner keychain's result", auth) + } +} diff --git a/cmd/elfuse-oci/main.go b/cmd/elfuse-oci/main.go new file mode 100644 index 00000000..d9e77c3a --- /dev/null +++ b/cmd/elfuse-oci/main.go @@ -0,0 +1,83 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +// elfuse-oci is the OCI image CLI for elfuse. +// +// It owns the OCI image pipeline (pull, store, and inspect for now) +// using go-containerregistry. elfuse itself stays a pure Linux +// syscall-to-Darwin runtime with no OCI awareness. +// +// Usage: +// +// elfuse-oci pull [--store DIR] [--platform os/arch[/variant]] +// elfuse-oci inspect [--store DIR] [--json] +// +// is an OCI image reference (docker.io/library/alpine:3, ghcr.io/..., +// localhost:5000/foo:tag, or name@sha256:...). The default store is +// $ELFUSE_OCI_STORE or ~/.local/share/elfuse/oci. +package main + +import ( + "errors" + "flag" + "fmt" + "os" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "elfuse-oci: %s\n", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `usage: elfuse-oci [flags] [args...] + +commands: + pull Pull an image reference into the local OCI store + inspect Print a stored image's manifest + config + help Show this help + version Print the elfuse-oci version + +common flags: + --store DIR OCI store directory (default $ELFUSE_OCI_STORE or + ~/.local/share/elfuse/oci) + --platform os/arch[/variant] Target platform (default linux/arm64) +`) +} + +func run(args []string) error { + if len(args) == 0 { + usage() + return fmt.Errorf("no command given") + } + cmd, rest := args[0], args[1:] + // A ` -h`/`--help` makes the subcommand FlagSet print its own flag list + // and return flag.ErrHelp; treat that as success rather than an error. + if err := dispatch(cmd, rest); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return nil +} + +func dispatch(cmd string, rest []string) error { + switch cmd { + case "help", "-h", "--help": + usage() + return nil + case "version", "-V", "--version": + fmt.Println("elfuse-oci " + version) + return nil + case "pull": + return cmdPull(rest) + case "inspect": + return cmdInspect(rest) + default: + usage() + return fmt.Errorf("unknown command: %s", cmd) + } +} diff --git a/cmd/elfuse-oci/main_command_test.go b/cmd/elfuse-oci/main_command_test.go new file mode 100644 index 00000000..9a8636da --- /dev/null +++ b/cmd/elfuse-oci/main_command_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os/exec" + "strings" + "testing" +) + +func TestRunDispatchHelpVersionAndErrors(t *testing.T) { + stdout, stderr, err := captureOutput(t, func() error { return run([]string{"help"}) }) + if err != nil { + t.Fatalf("run help: %v", err) + } + if stdout != "" { + t.Fatalf("help stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, "usage: elfuse-oci") || !strings.Contains(stderr, "commands:") { + t.Fatalf("help stderr missing usage:\n%s", stderr) + } + + stdout, stderr, err = captureOutput(t, func() error { return run([]string{"--version"}) }) + if err != nil { + t.Fatalf("run --version: %v", err) + } + if strings.TrimSpace(stdout) != "elfuse-oci "+version { + t.Fatalf("version stdout = %q, want elfuse-oci %s", stdout, version) + } + if stderr != "" { + t.Fatalf("version stderr = %q, want empty", stderr) + } + + _, stderr, err = captureOutput(t, func() error { return run(nil) }) + if err == nil || !strings.Contains(err.Error(), "no command") { + t.Fatalf("run nil err = %v, want no command", err) + } + if !strings.Contains(stderr, "usage: elfuse-oci") { + t.Fatalf("no-arg stderr missing usage:\n%s", stderr) + } + + _, stderr, err = captureOutput(t, func() error { return run([]string{"bogus"}) }) + if err == nil || !strings.Contains(err.Error(), "unknown command: bogus") { + t.Fatalf("run bogus err = %v, want unknown command", err) + } + if !strings.Contains(stderr, "usage: elfuse-oci") { + t.Fatalf("unknown-command stderr missing usage:\n%s", stderr) + } +} + +func TestMainSubprocessExitBehavior(t *testing.T) { + stdout, stderr, err := runMainSubprocess(t, "version") + if err != nil { + t.Fatalf("main version err = %v, stdout=%q stderr=%q", err, stdout, stderr) + } + if strings.TrimSpace(stdout) != "elfuse-oci "+version { + t.Fatalf("main version stdout = %q", stdout) + } + if stderr != "" { + t.Fatalf("main version stderr = %q, want empty", stderr) + } + + stdout, stderr, err = runMainSubprocess(t, "bogus") + exit, ok := err.(*exec.ExitError) + if !ok || exit.ExitCode() != 1 { + t.Fatalf("main bogus err = %T %v, want exit 1", err, err) + } + if stdout != "" { + t.Fatalf("main bogus stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, "elfuse-oci: unknown command: bogus") { + t.Fatalf("main bogus stderr = %q, want formatted error", stderr) + } + + _, stderr, err = runMainSubprocess(t) + exit, ok = err.(*exec.ExitError) + if !ok || exit.ExitCode() != 1 { + t.Fatalf("main no-arg err = %T %v, want exit 1", err, err) + } + if !strings.Contains(stderr, "elfuse-oci: no command given") { + t.Fatalf("main no-arg stderr = %q, want formatted error", stderr) + } +} diff --git a/cmd/elfuse-oci/pull.go b/cmd/elfuse-oci/pull.go new file mode 100644 index 00000000..ff9c63a2 --- /dev/null +++ b/cmd/elfuse-oci/pull.go @@ -0,0 +1,50 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/v1" +) + +var cranePull = crane.Pull + +// platformOption converts the parsed --platform into a crane option. +func platformOption(cf commonFlags) crane.Option { + p := v1.Platform{ + OS: cf.platform.OS, + Architecture: cf.platform.Arch, + Variant: cf.platform.Variant, + } + return crane.WithPlatform(&p) +} + +// pullImage fetches ref from a registry into the store, pinning ref to the +// image's manifest digest. Re-pulling the same digest is a no-op on the +// layout index (dedup by digest); only the pin table is refreshed. +func pullImage(cf commonFlags, s *store, ref string) error { + // Progress goes to stderr: pullImage also runs on the `run` path, where + // stdout belongs to the guest and callers capture it ($(run ...)). Printed + // before the pull so a slow or credential-blocked registry is not silent. + fmt.Fprintf(os.Stderr, "Pulling %s...\n", ref) + + // Wrap the ambient keychain so a wedged credential helper fails fast with + // an explanation instead of hanging the pull; see timedKeychain. + keychain := crane.WithAuthFromKeychain( + timedKeychain{authn.DefaultKeychain, credResolveTimeout}) + img, err := cranePull(ref, platformOption(cf), keychain) + if err != nil { + return fmt.Errorf("pull %s: %w", ref, err) + } + digest, err := s.addImage(ref, img) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Pulled %s -> %s\n", ref, digest) + return nil +} diff --git a/cmd/elfuse-oci/store.go b/cmd/elfuse-oci/store.go new file mode 100644 index 00000000..ec7972af --- /dev/null +++ b/cmd/elfuse-oci/store.go @@ -0,0 +1,294 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "syscall" + + "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/layout" +) + +// The store is a real OCI image-layout on disk: an `oci-layout` version +// file, a `blobs/sha256/` tree, and an `index.json` image index (managed by +// go-containerregistry's layout package). Multiple pulled images coexist as +// separate manifest descriptors in the one index, distinguished by digest. +// +// On top of the spec layout we keep a ref->manifest-digest pin table +// (refs.json) so `unpack`/`inspect`/`run` can resolve an image by its +// original reference. This is elfuse-specific lookup metadata; OCI readers can +// still parse the layout through index.json and the content-addressed blobs. +// Keeping it separate lets us preserve the exact pull reference, including +// `docker.io/library/alpine:3` or `name@sha256:...`. + +const ( + ociLayoutFile = `{"imageLayoutVersion":"1.0.0"}` + emptyIndex = `{"schemaVersion":2,"manifests":[]}` +) + +type store struct { + path layout.Path + root string +} + +// openStore ensures the layout scaffolding exists and returns a handle. +// Creating an empty layout (oci-layout + empty index.json + blobs/sha256/) +// here, rather than via layout.Write, lets the first pull go through the same +// Append path as every subsequent one. The bootstrap runs under the store +// lock: writeIfAbsent's stat-then-write is check-then-act, and without the +// lock a parallel first-use pull could rename an empty index.json over one +// that another process had just populated, leaving that process's pin +// pointing at a manifest the index no longer lists. +func openStore(root string) (*store, error) { + for _, d := range []string{root, filepath.Join(root, "blobs"), filepath.Join(root, "blobs", "sha256")} { + if err := os.MkdirAll(d, 0o755); err != nil { + return nil, err + } + } + s := &store{path: layout.Path(root), root: root} + layoutFile := filepath.Join(root, "oci-layout") + indexFile := filepath.Join(root, "index.json") + // Fast path: a warm store has both files, and they are never removed once + // created, so no lock is needed. A run's startup must not block behind a + // concurrent pull's store lock just to bootstrap no-ops; writeIfAbsent + // re-checks under the lock, making this a double-checked bootstrap. + if fileMissing(layoutFile) || fileMissing(indexFile) { + err := s.withLock(func() error { + if err := writeIfAbsent(layoutFile, []byte(ociLayoutFile)); err != nil { + return err + } + return writeIfAbsent(indexFile, []byte(emptyIndex)) + }) + if err != nil { + return nil, err + } + } + return s, nil +} + +func fileMissing(path string) bool { + _, err := os.Stat(path) + return err != nil +} + +// writeIfAbsent writes data to path unless the file already exists. The +// stat-then-write pair is check-then-act; the caller must hold the store +// lock so no metadata writer can slip between the two steps. +func writeIfAbsent(path string, data []byte) error { + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + return os.WriteFile(path, data, 0o644) +} + +// refPins maps an image reference to its manifest digest ("sha256:..."). +type refPins map[string]string + +func (s *store) loadPins() (refPins, error) { + b, err := os.ReadFile(filepath.Join(s.root, "refs.json")) + if os.IsNotExist(err) { + return refPins{}, nil + } else if err != nil { + return nil, err + } + var p refPins + if err := json.Unmarshal(b, &p); err != nil { + return nil, fmt.Errorf("store: corrupt refs.json: %w", err) + } + if p == nil { + return nil, fmt.Errorf("store: corrupt refs.json: expected object") + } + return p, nil +} + +func (s *store) savePins(p refPins) error { + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + // Unique temp name: a fixed name would let two writers clobber each + // other's half-written temp even before the rename race. + tmp, err := os.CreateTemp(s.root, ".refs.json.*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) // no-op once the rename succeeds + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o644); err != nil { + tmp.Close() + return err + } + // Durability, not just atomicity: rmi's crash-ordering argument (drop the + // pin before dropping the descriptor) only holds if the new refs.json + // cannot revert to an old pin after a crash. Sync the temp file before the + // rename and the directory after it, so the rename is durable once this + // returns. + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmp.Name(), filepath.Join(s.root, "refs.json")); err != nil { + return err + } + return fsyncDir(s.root) +} + +// fsyncDir flushes a directory's entries (renames, unlinks) to stable +// storage. +func fsyncDir(dir string) error { + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} + +// lock takes an exclusive advisory flock on /.lock and returns the +// unlock func. It serializes read-modify-write cycles on refs.json and +// index.json across concurrent elfuse-oci processes (parallel pulls, or +// a pull racing an rmi); without it, last-writer-wins on refs.json can drop a +// just-recorded pin. +func (s *store) lock() (func(), error) { + f, err := os.OpenFile(filepath.Join(s.root, ".lock"), os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, fmt.Errorf("store: open lock: %w", err) + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + f.Close() + return nil, fmt.Errorf("store: lock: %w", err) + } + return func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} + +// withLock runs fn while holding the store lock. Results cross the closure +// boundary by capture; the lock is released before withLock returns, so +// callers can keep post-lock work (reporting, other stores) outside the +// critical section. +func (s *store) withLock(fn func() error) error { + unlock, err := s.lock() + if err != nil { + return err + } + defer unlock() + return fn() +} + +// pin records ref->digest in the pin table. +func (s *store) pin(ref, digest string) error { + return s.withLock(func() error { return s.pinLocked(ref, digest) }) +} + +// pinLocked is pin's load-modify-save cycle; the caller holds the store lock. +func (s *store) pinLocked(ref, digest string) error { + p, err := s.loadPins() + if err != nil { + return err + } + p[ref] = digest + return s.savePins(p) +} + +// errNotPulled marks the ref-simply-missing case, distinguishing it from +// store corruption or IO failures: `run` auto-pulls only on this error. +var errNotPulled = fmt.Errorf("not pulled") + +// digestFor returns the manifest digest pinned for ref, or an error wrapping +// errNotPulled if the ref has not been pulled into this store. +func (s *store) digestFor(ref string) (string, error) { + p, err := s.loadPins() + if err != nil { + return "", err + } + d, ok := p[ref] + if !ok { + return "", fmt.Errorf("store: %q %w (run `elfuse-oci pull %s` first)", ref, errNotPulled, ref) + } + return d, nil +} + +// addImage appends img to the layout index if its manifest is not already +// present (dedup by digest), and pins ref to that digest. Returns the digest. +// The store lock covers the whole check-append-pin sequence: index.json is +// itself updated by read-modify-write inside the layout package, so two +// concurrent pulls could otherwise duplicate or drop descriptors. +func (s *store) addImage(ref string, img v1.Image) (string, error) { + d, err := img.Digest() + if err != nil { + return "", fmt.Errorf("store: compute manifest digest: %w", err) + } + h, err := v1.NewHash(d.String()) + if err != nil { + return "", err + } + err = s.withLock(func() error { + // Re-pulling the same digest must not append a duplicate descriptor + // to the index. + present, err := s.hasImageLocked(h) + if err != nil { + return fmt.Errorf("store: read layout index: %w", err) + } + if !present { + if err := s.path.AppendImage(img); err != nil { + return fmt.Errorf("store: append image: %w", err) + } + } + return s.pinLocked(ref, d.String()) + }) + if err != nil { + return "", err + } + return d.String(), nil +} + +// hasImageLocked reports whether the layout index already carries a manifest +// descriptor for h. The caller holds the store lock. This is a positive +// membership scan rather than a probe via s.path.Image(h): the layout package +// returns an untyped error for both "not found" and a corrupt or unreadable +// index.json, and treating the latter as "absent" would silently append into +// a broken store, masking the corruption. +func (s *store) hasImageLocked(h v1.Hash) (bool, error) { + ii, err := s.path.ImageIndex() + if err != nil { + return false, err + } + im, err := ii.IndexManifest() + if err != nil { + return false, err + } + for _, desc := range im.Manifests { + if desc.Digest == h { + return true, nil + } + } + return false, nil +} + +// image returns the v1.Image pinned for ref. +func (s *store) image(ref string) (v1.Image, error) { + d, err := s.digestFor(ref) + if err != nil { + return nil, err + } + h, err := v1.NewHash(d) + if err != nil { + return nil, err + } + return s.path.Image(h) +} diff --git a/cmd/elfuse-oci/store_test.go b/cmd/elfuse-oci/store_test.go new file mode 100644 index 00000000..d8ce31f1 --- /dev/null +++ b/cmd/elfuse-oci/store_test.go @@ -0,0 +1,247 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +// TestDigestForErrorKinds pins the distinction cmdRun's auto-pull relies on: +// a merely-absent ref is errNotPulled (triggers the pull), while a corrupt +// refs.json is a different error that must surface instead of being masked by +// a network pull. +func TestDigestForErrorKinds(t *testing.T) { + s := openTestStore(t) + if _, err := s.digestFor("local:absent"); !errors.Is(err, errNotPulled) { + t.Fatalf("missing ref err = %v, want errNotPulled", err) + } + if err := os.WriteFile(filepath.Join(s.root, "refs.json"), []byte("{corrupt"), 0o644); err != nil { + t.Fatal(err) + } + _, err := s.digestFor("local:absent") + if err == nil || errors.Is(err, errNotPulled) { + t.Fatalf("corrupt refs.json err = %v, must not be errNotPulled", err) + } +} + +// TestAddImageCorruptIndexSurfaces pins that addImage distinguishes "image +// not in the layout" from "layout index unreadable": appending into a corrupt +// store would mask the corruption behind a fresh descriptor. +func TestAddImageCorruptIndexSurfaces(t *testing.T) { + s := openTestStore(t) + if err := os.WriteFile(filepath.Join(s.root, "index.json"), []byte("{corrupt"), 0o644); err != nil { + t.Fatal(err) + } + _, err := s.addImage("local:corrupt", tinyImage(t)) + if err == nil || !strings.Contains(err.Error(), "read layout index") { + t.Fatalf("addImage with corrupt index.json err = %v, want read-layout-index error", err) + } + // The corrupt index must be left as-is for diagnosis, not clobbered by an + // append. + b, rerr := os.ReadFile(filepath.Join(s.root, "index.json")) + if rerr != nil || string(b) != "{corrupt" { + t.Fatalf("index.json after failed addImage = %q, err=%v; want untouched", b, rerr) + } +} + +// TestPinConcurrentWritersKeepAllEntries pins the store-lock behavior: N +// concurrent pin calls (as parallel `pull` processes would issue) must all +// survive into refs.json. Without the flock around the load-modify-save +// cycle, last-writer-wins drops entries. +func TestPinConcurrentWritersKeepAllEntries(t *testing.T) { + s := openTestStore(t) + const n = 16 + var wg sync.WaitGroup + errs := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs <- s.pin(fmt.Sprintf("local:ref%d", i), fmt.Sprintf("sha256:%064d", i)) + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + pins, err := s.loadPins() + if err != nil { + t.Fatal(err) + } + if len(pins) != n { + t.Fatalf("refs.json has %d pins after %d concurrent writers, want %d", len(pins), n, n) + } +} + +// TestOpenStoreBootstrapWaitsForStoreLock pins that openStore's bootstrap +// runs under the store lock. writeIfAbsent's stat-then-write is +// check-then-act: without the lock, a parallel first-use pull could rename an +// empty index.json over one the lock holder just populated, so openStore must +// block until the holder releases and then leave the populated index alone. +func TestOpenStoreBootstrapWaitsForStoreLock(t *testing.T) { + root := filepath.Join(t.TempDir(), "store") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + // Hold the store lock as a concurrent pull's metadata write would. + lockFile, err := os.OpenFile(filepath.Join(root, ".lock"), os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + t.Fatal(err) + } + defer lockFile.Close() + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + _, err := openStore(root) + done <- err + }() + select { + case <-done: + t.Fatal("openStore finished while the store lock was held; bootstrap must serialize with metadata writers") + case <-time.After(100 * time.Millisecond): + } + // The lock holder commits a populated index, then releases. Bootstrap must + // observe it and must not replace it with the empty scaffold. + populated := `{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:` + strings.Repeat("a", 64) + `","size":1}]}` + if err := os.WriteFile(filepath.Join(root, "index.json"), []byte(populated), 0o644); err != nil { + t.Fatal(err) + } + if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(root, "index.json")) + if err != nil { + t.Fatal(err) + } + if string(b) != populated { + t.Fatalf("index.json after bootstrap = %q, want the populated index left untouched", b) + } +} + +func TestDefaultStoreFromEnvAndResolveStore(t *testing.T) { + want := filepath.Join(t.TempDir(), "store") + t.Setenv("ELFUSE_OCI_STORE", want) + got, err := defaultStore() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("defaultStore = %q, want %q", got, want) + } + + var cf commonFlags + if err := cf.resolveStore(); err != nil { + t.Fatalf("resolveStore: %v", err) + } + if cf.store != want { + t.Fatalf("resolved store = %q, want %q", cf.store, want) + } + if fi, err := os.Stat(want); err != nil || !fi.IsDir() { + t.Fatalf("resolved store dir = %v, err=%v; want directory", fi, err) + } + + fileStore := filepath.Join(t.TempDir(), "store-file") + if err := os.WriteFile(fileStore, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + cf = commonFlags{store: fileStore} + if err := cf.resolveStore(); err == nil { + t.Fatal("resolveStore on file path succeeded, want error") + } + + home := t.TempDir() + t.Setenv("ELFUSE_OCI_STORE", "") + t.Setenv("HOME", home) + got, err = defaultStore() + if err != nil { + t.Fatal(err) + } + want = filepath.Join(home, ".local", "share", "elfuse", "oci") + if got != want { + t.Fatalf("defaultStore without env = %q, want %q", got, want) + } +} + +func TestRepeatedStringFlag(t *testing.T) { + var nilFlag *repeatedStringFlag + if got := nilFlag.String(); got != "" { + t.Fatalf("nil repeatedStringFlag String = %q, want empty", got) + } + + var f repeatedStringFlag + if err := f.Set("A=1"); err != nil { + t.Fatal(err) + } + if err := f.Set("B=2"); err != nil { + t.Fatal(err) + } + if got := f.String(); got != "A=1,B=2" { + t.Fatalf("repeatedStringFlag String = %q, want A=1,B=2", got) + } +} + +func TestOpenStoreAndWriteIfAbsentErrorCases(t *testing.T) { + rootFile := filepath.Join(t.TempDir(), "store-file") + if err := os.WriteFile(rootFile, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := openStore(rootFile); err == nil { + t.Fatal("openStore on file path succeeded, want error") + } + + p := filepath.Join(t.TempDir(), "existing") + if err := os.WriteFile(p, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := writeIfAbsent(p, []byte("new")); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if string(b) != "old" { + t.Fatalf("writeIfAbsent overwrote existing file with %q, want old", b) + } +} + +func TestLoadPinsCorruptNullAndPinError(t *testing.T) { + s := openTestStore(t) + for _, tc := range []struct { + name string + data string + want string + }{ + {"malformed", "{", "corrupt refs.json"}, + {"null", "null", "expected object"}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.WriteFile(filepath.Join(s.root, "refs.json"), []byte(tc.data), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.loadPins(); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("loadPins err = %v, want %q", err, tc.want) + } + if err := s.pin("local:a", "sha256:"+strings.Repeat("1", 64)); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("pin err = %v, want %q", err, tc.want) + } + }) + } +} diff --git a/cmd/elfuse-oci/test_helpers_test.go b/cmd/elfuse-oci/test_helpers_test.go new file mode 100644 index 00000000..fcb0c7af --- /dev/null +++ b/cmd/elfuse-oci/test_helpers_test.go @@ -0,0 +1,157 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/tar" + "bytes" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +func TestMain(m *testing.M) { + if raw, ok := os.LookupEnv("ELFUSE_OCI_MAIN_TEST_ARGS"); ok { + var args []string + if raw != "" { + args = strings.Split(raw, "\x00") + } + os.Args = append([]string{os.Args[0]}, args...) + main() + return + } + os.Exit(m.Run()) +} + +func sliceContains(xs []string, want string) bool { + return slices.Contains(xs, want) +} + +func captureOutput(t *testing.T, fn func() error) (string, string, error) { + t.Helper() + + oldStdout, oldStderr := os.Stdout, os.Stderr + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + stdoutCh := make(chan string, 1) + stderrCh := make(chan string, 1) + go func() { + b, _ := io.ReadAll(stdoutR) + stdoutCh <- string(b) + }() + go func() { + b, _ := io.ReadAll(stderrR) + stderrCh <- string(b) + }() + + os.Stdout, os.Stderr = stdoutW, stderrW + defer func() { + os.Stdout, os.Stderr = oldStdout, oldStderr + }() + + fnErr := fn() + _ = stdoutW.Close() + _ = stderrW.Close() + stdout := <-stdoutCh + stderr := <-stderrCh + _ = stdoutR.Close() + _ = stderrR.Close() + return stdout, stderr, fnErr +} + +func openTestStore(t *testing.T) *store { + t.Helper() + s, err := openStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return s +} + +// buildImage builds a one-layer in-memory image whose layer content is fixed +// ("hello"="world") but whose Cmd is cmd, so two calls with different cmds +// produce the same layer blob but distinct config/manifest digests. This is +// what TestRmiKeepsSharedBlobs needs to exercise reachability GC: two pinned +// refs sharing one layer, with distinct manifests. +func buildImage(t *testing.T, cmd []string) v1.Image { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + if err := tw.WriteHeader(&tar.Header{Name: "hello", Mode: 0o644, Size: 5, Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte("world")); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + // LayerFromOpener calls the opener per read so digest/diffid can be queried + // repeatedly (LayerFromReader is deprecated and single-shot). + opener := func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(buf.Bytes())), nil + } + layer, err := tarball.LayerFromOpener(opener) + if err != nil { + t.Fatal(err) + } + img, err := mutate.AppendLayers(empty.Image, layer) + if err != nil { + t.Fatal(err) + } + diffID, err := layer.DiffID() + if err != nil { + t.Fatal(err) + } + img, err = mutate.ConfigFile(img, &v1.ConfigFile{ + Architecture: "arm64", + OS: "linux", + Config: v1.Config{Cmd: cmd}, + RootFS: v1.RootFS{Type: "layers", DiffIDs: []v1.Hash{diffID}}, + }) + if err != nil { + t.Fatal(err) + } + return img +} + +// tinyImage is the canonical single-layer offline test image. +func tinyImage(t *testing.T) v1.Image { + t.Helper() + return buildImage(t, []string{"/hello"}) +} + +func blobPath(root, digest string) string { + return filepath.Join(root, "blobs", "sha256", strings.TrimPrefix(digest, "sha256:")) +} + +func runMainSubprocess(t *testing.T, args ...string) (string, string, error) { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^$") + cmd.Env = append(os.Environ(), "ELFUSE_OCI_MAIN_TEST_ARGS="+strings.Join(args, "\x00")) + // Buffers instead of pipes: exec.Cmd drains both concurrently, so a child + // filling one stream can't deadlock against a sequential reader of the + // other (the pattern the os/exec docs warn about). + var outB, errB bytes.Buffer + cmd.Stdout = &outB + cmd.Stderr = &errB + waitErr := cmd.Run() + return outB.String(), errB.String(), waitErr +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..d004d0b6 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/sysprog21/elfuse + +go 1.26.4 + +require github.com/google/go-containerregistry v0.21.7 + +require ( + github.com/docker/cli v29.5.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/klauspost/compress v1.18.7 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + gotest.tools/v3 v3.5.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..627c493a --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= +github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/mk/toolchain.mk b/mk/toolchain.mk index e0f6be4d..b701e5e4 100644 --- a/mk/toolchain.mk +++ b/mk/toolchain.mk @@ -42,3 +42,8 @@ SHIM_ASFLAGS ?= -arch arm64 # clang-format CLANG_FORMAT ?= clang-format + +# Go toolchain for the OCI image CLI (build/elfuse-oci). It is a +# pure Go program with no HVF dependency, so it builds and runs on Linux CI +# too (for spec-conformance / interop tests). `go` from PATH by default. +GO ?= go From aefecae92b998f99b9a775e4c2e54d6448038890 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 15 Jul 2026 23:23:35 +0200 Subject: [PATCH 15/22] Add elfuse-oci unpack of OCI layers Apply a stored image's layers into a rootfs directory, whiteouts and all, so `unpack` (and later `run`) can materialize a filesystem tree from the store without any external tool. Layer application is hardened against hostile archives: extraction is bounded by os.Root so no entry, symlink, or hardlink escapes the destination; member names and hard-link targets archived absolute (GNU tar -P builders) are applied root-relative, as other OCI consumers do; parent components are Lstat'd and a symlinked or non-dir intermediate is replaced with a real directory (containerd/Docker behavior); opaque whiteouts clear through a real directory only, are order-independent within a layer, and an invalid bare .wh. entry fails extraction instead of deleting its parent; a directory entry replaces a lower-layer non-directory. File bodies are bounded by the tar header size and short bodies are an error, and permissions are finalized with an explicit chmod so the host umask cannot skew modes. setuid/setgid/sticky bits are re-applied where the host allows it, and degrade gracefully where it does not. An unprivileged chmod that sets setuid/setgid is rejected with EPERM on macOS when the unpacked file's inherited group is one the invoking user is not in (a new file takes its parent directory's group under BSD semantics, e.g. wheel under /tmp, not the tar's root/shadow owner). Since the rootfs is owned by the invoking user those bits could not be honored at runtime there anyway, so they are dropped with a warning naming the lost bit rather than aborting the whole unpack, which is what lets Debian-family images and their shadow suite (chage, passwd, ...) unpack at all. Reported at https://github.com/sysprog21/elfuse/pull/191#issuecomment-5011520776 Unpack stages into a temp sibling directory and renames into the final cache path (keyed by manifest digest under /rootfs/), so a concurrent reader never observes a partial tree and the loser of a rename race adopts the winner's complete one. A failed unpack removes only what it created. Tests drive whiteouts, opaque ordering, parent-symlink replacement, hardlink identity, exact-size reads, mode preservation, the special- bit degrade decision, and the staged-rename semantics over synthetic layer tarballs. --- cmd/elfuse-oci/cache_key.go | 53 +++ cmd/elfuse-oci/commands.go | 43 +- cmd/elfuse-oci/common_test.go | 18 +- cmd/elfuse-oci/main.go | 8 +- cmd/elfuse-oci/store_test.go | 13 + cmd/elfuse-oci/unpack.go | 445 +++++++++++++++++++++ cmd/elfuse-oci/unpack_image_test.go | 597 ++++++++++++++++++++++++++++ cmd/elfuse-oci/unpack_test.go | 459 +++++++++++++++++++++ 8 files changed, 1632 insertions(+), 4 deletions(-) create mode 100644 cmd/elfuse-oci/cache_key.go create mode 100644 cmd/elfuse-oci/unpack.go create mode 100644 cmd/elfuse-oci/unpack_image_test.go create mode 100644 cmd/elfuse-oci/unpack_test.go diff --git a/cmd/elfuse-oci/cache_key.go b/cmd/elfuse-oci/cache_key.go new file mode 100644 index 00000000..a59597c8 --- /dev/null +++ b/cmd/elfuse-oci/cache_key.go @@ -0,0 +1,53 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/google/go-containerregistry/pkg/v1" +) + +// Store subdirectories holding unpacked caches, keyed by cacheKeyForDigest: +// plain rootfs trees and (darwin) case-sensitive sparsebundle bundles. +const ( + rootfsCacheDirName = "rootfs" + csCacheDirName = "cs" +) + +// legacyCacheNameForRef reproduces the pre-digest cache naming scheme, which +// flattened the ref itself into a single (intentionally lossy) path +// component. New caches are keyed by digest (cacheKeyForDigest); this helper +// survives to document the old layout. +func legacyCacheNameForRef(ref string) string { + return strings.NewReplacer("/", "_", ":", "_", "@", "_").Replace(ref) +} + +// cacheKeyForDigest returns the relative cache key used under rootfs/ and cs/. +// The current store writes sha256 blobs only; keep the algorithm component in +// the path so the layout remains explicit and non-lossy. +func cacheKeyForDigest(digest string) (string, error) { + h, err := v1.NewHash(digest) + if err != nil { + return "", err + } + if h.Algorithm != "sha256" || h.Hex == "" { + return "", fmt.Errorf("unsupported cache digest %q", digest) + } + return filepath.Join(h.Algorithm, h.Hex), nil +} + +func defaultRootfsForDigest(store, digest string) (string, error) { + key, err := cacheKeyForDigest(digest) + if err != nil { + return "", err + } + return filepath.Join(store, rootfsCacheDirName, key), nil +} + +func legacyRootfsForRef(store, ref string) string { + return filepath.Join(store, rootfsCacheDirName, legacyCacheNameForRef(ref)) +} diff --git a/cmd/elfuse-oci/commands.go b/cmd/elfuse-oci/commands.go index 4d59efe1..125b7471 100644 --- a/cmd/elfuse-oci/commands.go +++ b/cmd/elfuse-oci/commands.go @@ -4,11 +4,12 @@ package main import ( + "fmt" "os" ) // The subcommands share common-flag parsing (common.go) and the OCI -// image-layout store (store.go). pull and inspect are pure store ops. +// image-layout store (store.go). pull/unpack/inspect are pure store ops. // cmdPull implements `elfuse-oci pull [--store] [--platform] `. func cmdPull(args []string) error { @@ -33,6 +34,46 @@ func parsePullArgs(args []string) (commonFlags, string, error) { return cf, ref, err } +// cmdUnpack implements `elfuse-oci unpack [--store] [--rootfs DIR] `. +func cmdUnpack(args []string) error { + cf, rootfs, ref, err := parseUnpackArgs(args) + if err != nil { + return err + } + s, err := cf.openResolvedStore() + if err != nil { + return err + } + digest, err := s.digestFor(ref) + if err != nil { + return err + } + if rootfs == "" { + rootfs, err = defaultRootfsForDigest(cf.store, digest) + if err != nil { + return err + } + } + fmt.Printf("Unpacking %s -> %s\n", ref, rootfs) + if err := unpackImage(s, ref, rootfs); err != nil { + return err + } + fmt.Printf("Unpacked %s\n", ref) + return nil +} + +func parseUnpackArgs(args []string) (commonFlags, string, string, error) { + var cf commonFlags + var rootfs string + fs := newCommandFlagSet("unpack", &cf) + fs.StringVar(&rootfs, "rootfs", "", "") + if err := fs.Parse(args); err != nil { + return cf, "", "", err + } + ref, err := oneArg("unpack", fs.Args(), "") + return cf, rootfs, ref, err +} + // cmdInspect implements `elfuse-oci inspect [--store] [--json] `. func cmdInspect(args []string) error { cf, asJSON, ref, err := parseInspectArgs(args) diff --git a/cmd/elfuse-oci/common_test.go b/cmd/elfuse-oci/common_test.go index 8bf083ee..182f852d 100644 --- a/cmd/elfuse-oci/common_test.go +++ b/cmd/elfuse-oci/common_test.go @@ -60,6 +60,22 @@ func TestParsePullArgs(t *testing.T) { } } +func TestParseUnpackArgs(t *testing.T) { + cf, rootfs, ref, err := parseUnpackArgs([]string{"--rootfs=/tmp/rootfs", "alpine:3"}) + if err != nil { + t.Fatal(err) + } + if cf.platform != defaultPlatform { + t.Errorf("platform = %+v, want default %+v", cf.platform, defaultPlatform) + } + if rootfs != "/tmp/rootfs" { + t.Errorf("rootfs = %q, want /tmp/rootfs", rootfs) + } + if ref != "alpine:3" { + t.Errorf("ref = %q, want alpine:3", ref) + } +} + func TestParseInspectArgs(t *testing.T) { _, asJSON, ref, err := parseInspectArgs([]string{"--json", "alpine:3"}) if err != nil { @@ -80,7 +96,7 @@ func TestParseCommandFlagErrors(t *testing.T) { }{ {"malformed platform", func() error { _, _, err := parsePullArgs([]string{"--platform", "bogus", "alpine:3"}); return err }()}, {"unknown flag", func() error { _, _, err := parsePullArgs([]string{"--unknown", "alpine:3"}); return err }()}, - {"missing ref", func() error { _, _, err := parsePullArgs(nil); return err }()}, + {"missing flag value", func() error { _, _, _, err := parseUnpackArgs([]string{"--rootfs"}); return err }()}, } for _, tc := range cases { if tc.err == nil { diff --git a/cmd/elfuse-oci/main.go b/cmd/elfuse-oci/main.go index d9e77c3a..9308cc22 100644 --- a/cmd/elfuse-oci/main.go +++ b/cmd/elfuse-oci/main.go @@ -3,13 +3,14 @@ // elfuse-oci is the OCI image CLI for elfuse. // -// It owns the OCI image pipeline (pull, store, and inspect for now) -// using go-containerregistry. elfuse itself stays a pure Linux +// It owns the OCI image pipeline (pull, store, inspect, and unpack for +// now) using go-containerregistry. elfuse itself stays a pure Linux // syscall-to-Darwin runtime with no OCI awareness. // // Usage: // // elfuse-oci pull [--store DIR] [--platform os/arch[/variant]] +// elfuse-oci unpack [--store DIR] [--rootfs DIR] // elfuse-oci inspect [--store DIR] [--json] // // is an OCI image reference (docker.io/library/alpine:3, ghcr.io/..., @@ -36,6 +37,7 @@ func usage() { commands: pull Pull an image reference into the local OCI store + unpack Unpack a stored image's layers into a rootfs directory inspect Print a stored image's manifest + config help Show this help version Print the elfuse-oci version @@ -74,6 +76,8 @@ func dispatch(cmd string, rest []string) error { return nil case "pull": return cmdPull(rest) + case "unpack": + return cmdUnpack(rest) case "inspect": return cmdInspect(rest) default: diff --git a/cmd/elfuse-oci/store_test.go b/cmd/elfuse-oci/store_test.go index d8ce31f1..4f507ff2 100644 --- a/cmd/elfuse-oci/store_test.go +++ b/cmd/elfuse-oci/store_test.go @@ -245,3 +245,16 @@ func TestLoadPinsCorruptNullAndPinError(t *testing.T) { }) } } + +func TestCacheKeyForDigestRejectsInvalidAndUnsupported(t *testing.T) { + if _, err := cacheKeyForDigest("not-a-digest"); err == nil { + t.Fatal("cacheKeyForDigest accepted malformed digest") + } + if _, err := defaultRootfsForDigest(t.TempDir(), "not-a-digest"); err == nil { + t.Fatal("defaultRootfsForDigest accepted malformed digest") + } + unsupported := "sha512:" + strings.Repeat("1", 128) + if _, err := cacheKeyForDigest(unsupported); err == nil { + t.Fatalf("cacheKeyForDigest(%q) succeeded, want rejection", unsupported) + } +} diff --git a/cmd/elfuse-oci/unpack.go b/cmd/elfuse-oci/unpack.go new file mode 100644 index 00000000..3161ba06 --- /dev/null +++ b/cmd/elfuse-oci/unpack.go @@ -0,0 +1,445 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/google/go-containerregistry/pkg/v1" +) + +// unpackImage extracts every layer of the stored image (base first) into a +// plain directory rootfs. Each layer is a tar stream (crane decompresses gzip +// and zstd transparently via layer.Uncompressed). +// +// Layer application implements the OCI whiteout conventions: +// - `.wh.` in a directory removes `` (from this and lower layers). +// - `.wh..wh..opq` in a directory clears that directory's existing contents +// before the layer's own additions are applied. +// +// Containment uses os.OpenRoot (Go 1.24+): every write is resolved relative +// to the rootfs and may not escape via ".." or a symlink. os.Root forbids +// absolute symlinks, so absolute symlink targets are rewritten to their +// equivalent relative form, which is behavior-preserving because under +// elfuse's --sysroot both forms resolve to the same guest path. +// +// Ownership is not applied here: elfuse runs as the host user and overrides +// identity at runtime via --user, so the rootfs carries only mode bits. +func unpackImage(s *store, ref, dest string) error { + img, err := s.image(ref) + if err != nil { + return err + } + if _, statErr := os.Lstat(dest); statErr == nil { + // An explicit pre-existing --rootfs directory: merge in place and + // never remove it, failed or not; it is not ours to delete. + return unpackInto(img, dest) + } else if !os.IsNotExist(statErr) { + return statErr + } + // The run paths treat the rootfs path's existence as "fully unpacked", so + // a partial tree must never be visible under dest, not even while this + // unpack is still running: a concurrent run probing dest with os.Stat + // would execute against the half-written tree. Unpack into a temp sibling + // (same volume, so the rename cannot degrade to a copy) and atomically + // rename into place on success. + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + tmp, err := os.MkdirTemp(filepath.Dir(dest), filepath.Base(dest)+".tmp-") + if err != nil { + return err + } + // MkdirTemp creates 0o700; the rootfs root must be traversable once + // published. + if err := os.Chmod(tmp, 0o755); err != nil { + os.RemoveAll(tmp) + return err + } + if err := unpackInto(img, tmp); err != nil { + os.RemoveAll(tmp) + return err + } + if err := os.Rename(tmp, dest); err != nil { + os.RemoveAll(tmp) + if _, statErr := os.Lstat(dest); statErr == nil { + // A concurrent unpack of the same image won the rename; its tree + // is complete, so use it. + return nil + } + return err + } + return nil +} + +func unpackInto(img v1.Image, dest string) error { + root, err := os.OpenRoot(dest) + if err != nil { + return fmt.Errorf("unpack: open rootfs %s: %w", dest, err) + } + defer root.Close() + + layers, err := img.Layers() + if err != nil { + return fmt.Errorf("unpack: list layers: %w", err) + } + for i, layer := range layers { + if err := applyLayer(root, layer); err != nil { + return fmt.Errorf("unpack: layer %d: %w", i, err) + } + } + return nil +} + +func applyLayer(root *os.Root, layer v1.Layer) error { + r, err := layer.Uncompressed() + if err != nil { + return fmt.Errorf("open layer: %w", err) + } + defer r.Close() + // Paths this layer has already created. Whiteouts hide lower-layer + // content only, but tar entry order within a layer is not guaranteed: an + // opaque marker may arrive after the directory's own same-layer children, + // which must survive the clear (Docker's unpacker keeps the same set). + applied := make(map[string]bool) + tr := tar.NewReader(r) + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("read tar entry: %w", err) + } + if err := applyEntry(root, hdr, tr, applied); err != nil { + return fmt.Errorf("entry %q: %w", hdr.Name, err) + } + } +} + +// whiteoutPrefix is the OCI/Docker whiteout marker prefix. +const whiteoutPrefix = ".wh." + +// opaqueMarker is the opaque-directory whiteout marker: a directory's +// existing children are hidden before the layer's own additions apply. +const opaqueMarker = whiteoutPrefix + ".wh..opq" + +// rootRelative strips the leading slash from a cleaned tar path. Some +// builders (GNU tar -P) archive member names and hard-link targets absolute; +// OCI consumers apply both root-relative. Clean has already collapsed any +// ".." in an absolute path against the root, so dropping the slash cannot +// introduce an escape. +func rootRelative(cleaned string) string { + return strings.TrimPrefix(cleaned, "/") +} + +// applyEntry applies one tar header to the rootfs. applied tracks the paths +// the current layer has created so far, so an opaque whiteout arriving after +// its directory's same-layer children does not delete them. +func applyEntry(root *os.Root, hdr *tar.Header, r io.Reader, applied map[string]bool) error { + name := filepath.Clean(hdr.Name) + if strings.HasPrefix(name, "../") || name == ".." { + return fmt.Errorf("unsafe entry path %q", hdr.Name) + } + name = rootRelative(name) + if name == "" || name == "." { + // The layer root itself; nothing to create. + return nil + } + + base := filepath.Base(name) + if base == opaqueMarker { + return clearDirectory(root, filepath.Dir(name), applied) + } + if trimmed, ok := strings.CutPrefix(base, whiteoutPrefix); ok { + // A bare ".wh." would leave an empty target and Join(dir, "") is the + // directory itself; a malformed layer must fail, not delete its + // containing directory. + if trimmed == "" { + return fmt.Errorf("invalid whiteout entry %q", hdr.Name) + } + target := filepath.Join(filepath.Dir(name), trimmed) + return root.RemoveAll(target) + } + applied[name] = true + + // hdr.FileInfo().Mode() maps the tar header's unix mode bits to os.FileMode + // with the special bits (ModeSetuid/Setgid/Sticky) at os.FileMode's high + // positions, not the raw unix positions. os.Root.MkdirAll/OpenFile reject + // any non-permission bits, so split perm (0o777) from special bits and + // re-apply special bits via Chmod (which syscallMode maps to the syscall). + mode := hdr.FileInfo().Mode() + perm := mode.Perm() + special := mode & (os.ModeSetuid | os.ModeSetgid | os.ModeSticky) + switch hdr.Typeflag { + case tar.TypeDir: + return mkdirAll(root, name, perm, special) + case tar.TypeReg, tar.TypeRegA: + if err := ensureParent(root, name); err != nil { + return err + } + // Remove any prior entry (file, symlink, dir remnant) so we never + // write through a symlink planted by a lower layer. + _ = root.RemoveAll(name) + f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return err + } + // Bound the copy to the header's declared size and require exactly + // that many bytes. The tar reader normally guarantees both; enforcing + // them here keeps the entry-size contract out of the caller's hands. + n, err := io.Copy(f, io.LimitReader(r, hdr.Size)) + if err != nil { + f.Close() + return err + } + if n != hdr.Size { + f.Close() + return fmt.Errorf("entry body: read %d bytes, want %d", n, hdr.Size) + } + if err := f.Close(); err != nil { + return err + } + return applyMode(root, name, perm, special) + case tar.TypeSymlink: + // makeSymlink runs ensureParent itself; no second walk here. + return makeSymlink(root, name, hdr.Linkname, mode) + case tar.TypeLink: + if err := ensureParent(root, name); err != nil { + return err + } + _ = root.RemoveAll(name) + // A hard-link target names another member of the same rootfs; an + // absolute target is applied root-relative like the member names + // (the symlink path instead rewrites absolute targets, which + // os.Root would reject here). + return root.Link(rootRelative(filepath.Clean(hdr.Linkname)), name) + case tar.TypeChar, tar.TypeBlock, tar.TypeFifo: + return fmt.Errorf("unsupported special file type %s", tarTypeName(hdr.Typeflag)) + default: + return fmt.Errorf("unsupported tar type %d", hdr.Typeflag) + } +} + +func tarTypeName(t byte) string { + switch t { + case tar.TypeChar: + return "char" + case tar.TypeBlock: + return "block" + case tar.TypeFifo: + return "fifo" + default: + return fmt.Sprintf("%d", t) + } +} + +// makeSymlink creates a symlink at name pointing to target. Absolute targets +// are rewritten to their equivalent relative form so os.Root accepts them +// (it rejects absolute symlinks); the relative form resolves to the same +// guest path under --sysroot, so this is behavior-preserving. +// +// Both name and target are guest paths. Clean an absolute target as a guest path +// first, then strip the leading "/" to make it rootfs-relative and compute the +// link-relative form with filepath.Rel (which needs both sides in the same +// form). +func makeSymlink(root *os.Root, name, target string, mode os.FileMode) error { + if err := ensureParent(root, name); err != nil { + return err + } + _ = root.RemoveAll(name) + if filepath.IsAbs(target) { + tgt := strings.TrimPrefix(filepath.Clean(target), string(filepath.Separator)) + if tgt == "" { + tgt = "." + } + rel, err := filepath.Rel(filepath.Dir(name), tgt) + if err != nil { + return fmt.Errorf("rewrite absolute symlink %q: %w", target, err) + } + target = rel + } + if err := root.Symlink(target, name); err != nil { + return err + } + // Symlink mode is not portable to set across platforms; ignore mode. + _ = mode + return nil +} + +// ensureParent creates name's missing parent directories with the 0o755 +// default. It never chmods: a parent that already exists may carry an exact +// mode from its own tar entry, which must not be reset to the default here. +// +// The walk Lstats every intermediate component instead of calling MkdirAll: +// a lower layer may have planted a symlink (or plain file) where this entry +// needs a directory, and MkdirAll would resolve through it, silently landing +// the entry in whatever the link points at; containment via os.Root still +// holds, but the file ends up in the wrong directory while the entry's own +// path stays unresolved. Replace any such component with a real directory, +// matching containerd's and Docker's unpackers. +func ensureParent(root *os.Root, name string) error { + dir := filepath.Dir(name) + if dir == "." { + return nil + } + cur := "" + for part := range strings.SplitSeq(dir, string(filepath.Separator)) { + cur = filepath.Join(cur, part) + fi, err := root.Lstat(cur) + if err == nil { + if fi.IsDir() { + continue + } + if err := root.RemoveAll(cur); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + if err := root.Mkdir(cur, 0o755); err != nil && !errors.Is(err, fs.ErrExist) { + return err + } + } + return nil +} + +// mkdirAll creates a directory entry and any missing parents, then finalizes +// the entry's own mode. os.Root.Mkdir rejects modes that carry +// setuid/setgid/sticky bits ("unsupported file mode"), so create with the +// permission bits only and finalize via applyMode. Parents go through +// ensureParent so lower-layer symlinks on the path are replaced, not +// traversed. +func mkdirAll(root *os.Root, name string, perm, special os.FileMode) error { + if err := ensureParent(root, name); err != nil { + return err + } + // A lower layer may have left a non-directory here, typically a symlink, + // which Mkdir would otherwise resolve through, silently handing this + // layer's children to whatever the link points at. Replace it with a real + // directory, mirroring the RemoveAll the regular-file path does. + if fi, err := root.Lstat(name); err == nil && !fi.IsDir() { + if err := root.RemoveAll(name); err != nil { + return err + } + } + if err := root.Mkdir(name, perm); err != nil && !errors.Is(err, fs.ErrExist) { + return err + } + return applyMode(root, name, perm, special) +} + +// applyMode finalizes a just-created entry's mode to exactly perm|special. +// The chmod is unconditional: creation modes passed to os.Root.OpenFile and +// MkdirAll are masked by the process umask, so a restrictive host umask +// (e.g. 0077) would otherwise silently corrupt layer permissions. It also +// re-applies setuid/setgid/sticky, which os.Root creation methods reject at +// create time; Chmod -> syscallMode maps os.FileMode's high special-bit flags +// to the corresponding syscall bits. +// +// When the host cannot set the special bits, degrade to the plain permission +// bits rather than aborting the whole unpack. An unprivileged chmod that sets +// setuid/setgid is rejected with EPERM on macOS when the unpacked file's group +// is one the invoking user is not a member of: a new file inherits its parent +// directory's group (BSD semantics), e.g. wheel under /tmp, not the tar's +// root/shadow owner. The rootfs is owned by the invoking user, so these bits +// could not be honored at runtime on such a host regardless. Debian-family +// images (their shadow suite: chage, passwd, ...) would otherwise fail to +// unpack entirely. +func applyMode(root *os.Root, name string, perm, special os.FileMode) error { + err := root.Chmod(name, perm|special) + if shouldDropSpecial(err, special) { + fmt.Fprintf(os.Stderr, + "elfuse-oci: unpack: dropped %s on %q (unprivileged host)\n", + specialBitNames(special), name) + return root.Chmod(name, perm) + } + return err +} + +// specialBitNames returns a "/"-joined list of the special mode bits present in +// mode (setuid, setgid, sticky), so the drop diagnostic names the bit actually +// lost instead of assuming setuid/setgid. +func specialBitNames(mode os.FileMode) string { + var names []string + if mode&os.ModeSetuid != 0 { + names = append(names, "setuid") + } + if mode&os.ModeSetgid != 0 { + names = append(names, "setgid") + } + if mode&os.ModeSticky != 0 { + names = append(names, "sticky") + } + return strings.Join(names, "/") +} + +// shouldDropSpecial reports whether a failed mode-finalizing chmod should be +// retried without the special bits. Only a permission error qualifies, and +// only when special bits were actually requested, so a genuine chmod failure +// (a permission error with no special bits, or any non-permission error) still +// surfaces to the caller unchanged. +func shouldDropSpecial(err error, special os.FileMode) bool { + return err != nil && special != 0 && errors.Is(err, os.ErrPermission) +} + +// clearDirectory removes the existing children of dir (opaque whiteout), +// keeping entries the current layer itself created: opaque markers hide +// lower-layer content, and a marker ordered after its directory's same-layer +// additions must not wipe them. +func clearDirectory(root *os.Root, dir string, applied map[string]bool) error { + fi, err := root.Lstat(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + // The marker's directory may be a symlink (or other non-dir) planted by a + // lower layer. Reading through it would clear the link target's contents + // (files the image author never whited out). Replace it with a real + // empty directory instead: the opaque marker hides all lower content + // under this name anyway, mirroring the non-dir replacement mkdirAll and + // the regular-file path perform. + if !fi.IsDir() { + if applied[dir] { + // This layer created the non-dir itself; there is no lower + // content beneath it for the marker to hide. + return nil + } + if err := root.RemoveAll(dir); err != nil { + return err + } + return root.Mkdir(dir, 0o755) + } + d, err := root.Open(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + entries, err := d.ReadDir(-1) + d.Close() + if err != nil { + return err + } + for _, e := range entries { + child := filepath.Join(dir, e.Name()) + if applied[child] { + continue + } + if err := root.RemoveAll(child); err != nil { + return err + } + } + return nil +} diff --git a/cmd/elfuse-oci/unpack_image_test.go b/cmd/elfuse-oci/unpack_image_test.go new file mode 100644 index 00000000..a6cfdde7 --- /dev/null +++ b/cmd/elfuse-oci/unpack_image_test.go @@ -0,0 +1,597 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/tar" + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +type tarEntry struct { + header tar.Header + body string +} + +func testTarLayer(t *testing.T, entries ...tarEntry) v1.Layer { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, e := range entries { + h := e.header + if h.Typeflag == tar.TypeReg || h.Typeflag == tar.TypeRegA { + h.Size = int64(len(e.body)) + } + if err := tw.WriteHeader(&h); err != nil { + t.Fatal(err) + } + if h.Size > 0 { + if _, err := tw.Write([]byte(e.body)); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + opener := func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(buf.Bytes())), nil + } + layer, err := tarball.LayerFromOpener(opener) + if err != nil { + t.Fatal(err) + } + return layer +} + +func testImageWithLayers(t *testing.T, layers ...v1.Layer) v1.Image { + t.Helper() + img, err := mutate.AppendLayers(empty.Image, layers...) + if err != nil { + t.Fatal(err) + } + diffIDs := make([]v1.Hash, 0, len(layers)) + for _, layer := range layers { + diffID, err := layer.DiffID() + if err != nil { + t.Fatal(err) + } + diffIDs = append(diffIDs, diffID) + } + img, err = mutate.ConfigFile(img, &v1.ConfigFile{ + Architecture: "arm64", + OS: "linux", + Config: v1.Config{Cmd: []string{"/bin/sh"}}, + RootFS: v1.RootFS{Type: "layers", DiffIDs: diffIDs}, + }) + if err != nil { + t.Fatal(err) + } + return img +} + +func TestUnpackImageAppliesLayersInOrder(t *testing.T) { + lower := testTarLayer(t, + tarEntry{header: dirHeader("etc", 0o755)}, + tarEntry{header: regHeader("etc/keep", 0o644, 0), body: "lower"}, + tarEntry{header: regHeader("etc/gone", 0o644, 0), body: "gone"}, + tarEntry{header: dirHeader("opt", 0o755)}, + tarEntry{header: regHeader("opt/lower", 0o644, 0), body: "hidden"}, + tarEntry{header: dirHeader("bin", 0o755)}, + tarEntry{header: regHeader("bin/busybox", 0o755, 0), body: "busy"}, + tarEntry{header: symHeader("bin/sh", "/bin/busybox")}, + ) + upper := testTarLayer(t, + tarEntry{header: regHeader("etc/keep", 0o600, 0), body: "upper"}, + tarEntry{header: tar.Header{Name: "etc/.wh.gone", Typeflag: tar.TypeReg, Mode: 0o644}}, + tarEntry{header: tar.Header{Name: "opt/.wh..wh..opq", Typeflag: tar.TypeReg, Mode: 0o644}}, + tarEntry{header: regHeader("opt/new", 0o644, 0), body: "new"}, + ) + + s := openTestStore(t) + if _, err := s.addImage("local:layered", testImageWithLayers(t, lower, upper)); err != nil { + t.Fatal(err) + } + dest := t.TempDir() + if err := unpackImage(s, "local:layered", dest); err != nil { + t.Fatalf("unpackImage: %v", err) + } + + if b, err := os.ReadFile(filepath.Join(dest, "etc", "keep")); err != nil || string(b) != "upper" { + t.Fatalf("etc/keep = %q, err=%v; want upper", b, err) + } + if fi, err := os.Stat(filepath.Join(dest, "etc", "keep")); err != nil || fi.Mode().Perm() != 0o600 { + t.Fatalf("etc/keep mode = %v, err=%v; want 0600", fi, err) + } + if _, err := os.Stat(filepath.Join(dest, "etc", "gone")); !os.IsNotExist(err) { + t.Fatalf("whiteout target etc/gone = %v, want IsNotExist", err) + } + if _, err := os.Stat(filepath.Join(dest, "opt", "lower")); !os.IsNotExist(err) { + t.Fatalf("opaque-hidden opt/lower = %v, want IsNotExist", err) + } + if b, err := os.ReadFile(filepath.Join(dest, "opt", "new")); err != nil || string(b) != "new" { + t.Fatalf("opt/new = %q, err=%v; want new", b, err) + } + target, err := os.Readlink(filepath.Join(dest, "bin", "sh")) + if err != nil { + t.Fatal(err) + } + if target != "busybox" { + t.Fatalf("bin/sh target = %q, want busybox", target) + } +} + +// TestUnpackImageCleansUpPartialRootfs pins that a failed unpack never leaves +// anything at dest: the run paths infer "unpacked" from the path's existence, +// so a partial tree must not survive (or even be transiently visible under +// dest) to be executed by a later or concurrent run. The temp staging +// directory must not leak either. A pre-existing dest (explicit --rootfs) must +// be preserved. +func TestUnpackImageCleansUpPartialRootfs(t *testing.T) { + good := testTarLayer(t, tarEntry{header: regHeader("ok", 0o644, 0), body: "x"}) + bad := testTarLayer(t, + tarEntry{header: tar.Header{Name: "dev/fifo", Typeflag: tar.TypeFifo, Mode: 0o644}}) + + s := openTestStore(t) + if _, err := s.addImage("local:partial", testImageWithLayers(t, good, bad)); err != nil { + t.Fatal(err) + } + + parent := t.TempDir() + dest := filepath.Join(parent, "rootfs") + if err := unpackImage(s, "local:partial", dest); err == nil { + t.Fatal("unpackImage succeeded, want failure on fifo entry") + } + if _, err := os.Lstat(dest); !os.IsNotExist(err) { + t.Fatalf("partial rootfs still present after failed unpack: err=%v", err) + } + entries, err := os.ReadDir(parent) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("failed unpack left litter next to dest: %v", entries) + } + + pre := t.TempDir() + sentinel := filepath.Join(pre, "keep") + if err := os.WriteFile(sentinel, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + if err := unpackImage(s, "local:partial", pre); err == nil { + t.Fatal("unpackImage succeeded, want failure on fifo entry") + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("pre-existing rootfs dir was deleted on failed unpack: %v", err) + } +} + +// TestUnpackImagePreexistingDestUnpacksInPlace pins the explicit --rootfs +// contract: a destination that already exists is merged into in place rather +// than staged-and-renamed, so files the caller already put there survive a +// successful unpack. +func TestUnpackImagePreexistingDestUnpacksInPlace(t *testing.T) { + layer := testTarLayer(t, tarEntry{header: regHeader("ok", 0o644, 0), body: "x"}) + s := openTestStore(t) + if _, err := s.addImage("local:merge", testImageWithLayers(t, layer)); err != nil { + t.Fatal(err) + } + + dest := t.TempDir() + sentinel := filepath.Join(dest, "keep") + if err := os.WriteFile(sentinel, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + if err := unpackImage(s, "local:merge", dest); err != nil { + t.Fatalf("unpackImage: %v", err) + } + if b, err := os.ReadFile(sentinel); err != nil || string(b) != "keep" { + t.Fatalf("sentinel = %q, err=%v; want preserved by in-place unpack", b, err) + } + if b, err := os.ReadFile(filepath.Join(dest, "ok")); err != nil || string(b) != "x" { + t.Fatalf("ok = %q, err=%v; want unpacked", b, err) + } +} + +func TestApplyLayerErrors(t *testing.T) { + root, _ := newRoot(t) + if err := applyLayer(root, fakeLayer{uncompressedErr: errors.New("open failed")}); err == nil || + !strings.Contains(err.Error(), "open layer") { + t.Fatalf("applyLayer open err = %v, want open layer error", err) + } + if err := applyLayer(root, fakeLayer{uncompressed: "not a tar archive"}); err == nil || + !strings.Contains(err.Error(), "read tar entry") { + t.Fatalf("applyLayer corrupt tar err = %v, want read tar entry error", err) + } +} + +func TestApplyEntryRootNoOpsHardlinkEscapeAndSymlinkReplacement(t *testing.T) { + root, dir := newRoot(t) + for _, name := range []string{".", "/"} { + h := regHeader(name, 0o644, 0) + if err := applyEntry(root, &h, strings.NewReader(""), map[string]bool{}); err != nil { + t.Fatalf("applyEntry root no-op %q: %v", name, err) + } + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("root no-op entries = %v, want empty root", entries) + } + + hardlink := linkHeader("escape-link", "../outside") + if err := applyEntry(root, &hardlink, strings.NewReader(""), map[string]bool{}); err == nil { + t.Fatal("applyEntry accepted hardlink target escaping root") + } + + outside := filepath.Join(t.TempDir(), "outside") + if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, "replace-me")); err != nil { + t.Fatal(err) + } + file := regHeader("replace-me", 0o644, int64(len("inside"))) + if err := applyEntry(root, &file, strings.NewReader("inside"), map[string]bool{}); err != nil { + t.Fatalf("applyEntry replacing symlink: %v", err) + } + if b, err := os.ReadFile(outside); err != nil || string(b) != "outside" { + t.Fatalf("outside target = %q, err=%v; want unchanged", b, err) + } + if li, err := os.Lstat(filepath.Join(dir, "replace-me")); err != nil || li.Mode()&os.ModeSymlink != 0 { + t.Fatalf("replace-me mode = %v, err=%v; want regular file", li, err) + } + if b, err := os.ReadFile(filepath.Join(dir, "replace-me")); err != nil || string(b) != "inside" { + t.Fatalf("replace-me content = %q, err=%v; want inside", b, err) + } +} + +func TestApplyEntryAdditionalErrorBranches(t *testing.T) { + t.Run("unsupported tar type", func(t *testing.T) { + root, _ := newRoot(t) + h := tar.Header{Name: "weird", Typeflag: 'x'} + err := applyEntry(root, &h, strings.NewReader(""), map[string]bool{}) + if err == nil || !strings.Contains(err.Error(), "unsupported tar type") || !strings.Contains(err.Error(), tarTypeName('x')) { + t.Fatalf("unsupported type err = %v, want tar type error", err) + } + }) + + t.Run("absolute symlink to root", func(t *testing.T) { + root, dir := newRoot(t) + h := symHeader("usr/root-link", "/") + if err := applyEntry(root, &h, strings.NewReader(""), map[string]bool{}); err != nil { + t.Fatalf("applyEntry symlink to root: %v", err) + } + target, err := os.Readlink(filepath.Join(dir, "usr", "root-link")) + if err != nil { + t.Fatal(err) + } + if target != ".." { + t.Fatalf("root symlink target = %q, want ..", target) + } + }) + + t.Run("parent path is regular file", func(t *testing.T) { + root, dir := newRoot(t) + parent := regHeader("parent", 0o644, 0) + if err := applyEntry(root, &parent, strings.NewReader(""), map[string]bool{}); err != nil { + t.Fatal(err) + } + // A non-directory on the parent path is replaced with a real + // directory (containerd/Docker behavior), not an error: layers may + // legitimately turn a lower layer's file into a directory. + child := regHeader("parent/child", 0o644, 0) + if err := applyEntry(root, &child, strings.NewReader(""), map[string]bool{}); err != nil { + t.Fatalf("applyEntry child under regular-file parent: %v, want file replaced by directory", err) + } + fi, err := os.Lstat(filepath.Join(dir, "parent")) + if err != nil || !fi.IsDir() { + t.Fatalf("parent = %v, err=%v; want a real directory", fi, err) + } + if _, err := os.Stat(filepath.Join(dir, "parent", "child")); err != nil { + t.Fatalf("parent/child: %v, want created", err) + } + }) + + t.Run("opaque missing directory is no-op", func(t *testing.T) { + root, _ := newRoot(t) + h := tar.Header{Name: "missing/.wh..wh..opq", Typeflag: tar.TypeReg} + if err := applyEntry(root, &h, strings.NewReader(""), map[string]bool{}); err != nil { + t.Fatalf("opaque missing dir: %v", err) + } + }) + + t.Run("opaque marker under lower-layer regular file replaces it", func(t *testing.T) { + root, dir := newRoot(t) + file := regHeader("notdir", 0o644, 0) + if err := applyEntry(root, &file, strings.NewReader(""), map[string]bool{}); err != nil { + t.Fatal(err) + } + // The opaque marker hides all lower content under the name, so a + // lower layer's non-directory there is replaced with an empty real + // directory rather than cleared through or rejected. + h := tar.Header{Name: "notdir/.wh..wh..opq", Typeflag: tar.TypeReg} + if err := applyEntry(root, &h, strings.NewReader(""), map[string]bool{}); err != nil { + t.Fatalf("opaque marker under regular file: %v, want file replaced by empty directory", err) + } + fi, err := os.Lstat(filepath.Join(dir, "notdir")) + if err != nil || !fi.IsDir() { + t.Fatalf("notdir = %v, err=%v; want a real directory", fi, err) + } + }) + + t.Run("opaque marker under same-layer regular file is a no-op", func(t *testing.T) { + root, dir := newRoot(t) + file := regHeader("notdir", 0o644, 0) + applied := map[string]bool{} + if err := applyEntry(root, &file, strings.NewReader(""), applied); err != nil { + t.Fatal(err) + } + h := tar.Header{Name: "notdir/.wh..wh..opq", Typeflag: tar.TypeReg} + if err := applyEntry(root, &h, strings.NewReader(""), applied); err != nil { + t.Fatalf("opaque marker under same-layer file: %v, want no-op", err) + } + fi, err := os.Lstat(filepath.Join(dir, "notdir")) + if err != nil || !fi.Mode().IsRegular() { + t.Fatalf("notdir = %v, err=%v; want the same-layer file kept", fi, err) + } + }) +} + +type fakeLayer struct { + uncompressed string + uncompressedErr error +} + +func (f fakeLayer) Digest() (v1.Hash, error) { + return v1.Hash{Algorithm: "sha256", Hex: strings.Repeat("1", 64)}, nil +} + +func (f fakeLayer) DiffID() (v1.Hash, error) { + return v1.Hash{Algorithm: "sha256", Hex: strings.Repeat("2", 64)}, nil +} + +func (f fakeLayer) Compressed() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("")), nil +} + +func (f fakeLayer) Uncompressed() (io.ReadCloser, error) { + if f.uncompressedErr != nil { + return nil, f.uncompressedErr + } + return io.NopCloser(strings.NewReader(f.uncompressed)), nil +} + +func (f fakeLayer) Size() (int64, error) { + return int64(len(f.uncompressed)), nil +} + +func (f fakeLayer) MediaType() (types.MediaType, error) { + return types.DockerLayer, nil +} + +// TestUnpackOpaqueAfterSameLayerChildren pins the whiteout scoping rule: +// opaque markers hide lower-layer content only. Tar entry order within a +// layer is not guaranteed, so a marker ordered after its directory's own +// same-layer additions must clear the lower content yet keep the additions. +func TestUnpackOpaqueAfterSameLayerChildren(t *testing.T) { + lower := testTarLayer(t, + tarEntry{header: dirHeader("opt", 0o755)}, + tarEntry{header: regHeader("opt/lower", 0o644, 0), body: "hidden"}, + ) + upper := testTarLayer(t, + tarEntry{header: regHeader("opt/new", 0o644, 0), body: "new"}, + tarEntry{header: tar.Header{Name: "opt/.wh..wh..opq", Typeflag: tar.TypeReg, Mode: 0o644}}, + ) + + s := openTestStore(t) + if _, err := s.addImage("local:opq-late", testImageWithLayers(t, lower, upper)); err != nil { + t.Fatal(err) + } + dest := t.TempDir() + if err := unpackImage(s, "local:opq-late", dest); err != nil { + t.Fatalf("unpackImage: %v", err) + } + + if _, err := os.Stat(filepath.Join(dest, "opt", "lower")); !os.IsNotExist(err) { + t.Fatalf("opaque-hidden opt/lower = %v, want IsNotExist", err) + } + if b, err := os.ReadFile(filepath.Join(dest, "opt", "new")); err != nil || string(b) != "new" { + t.Fatalf("opt/new = %q, err=%v; want same-layer addition to survive the late marker", b, err) + } +} + +// TestUnpackRejectsBareWhiteout pins that a malformed ".wh." entry with no +// target suffix fails extraction instead of resolving to Join(dir, "") and +// deleting the containing directory. +func TestUnpackRejectsBareWhiteout(t *testing.T) { + dest := t.TempDir() + if err := os.MkdirAll(filepath.Join(dest, "opt"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dest, "opt", "keep"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(dest) + if err != nil { + t.Fatal(err) + } + defer root.Close() + + hdr := tar.Header{Name: "opt/.wh.", Typeflag: tar.TypeReg, Mode: 0o644} + if err := applyEntry(root, &hdr, strings.NewReader(""), map[string]bool{}); err == nil { + t.Fatal("bare .wh. entry applied, want invalid-whiteout error") + } + if _, err := os.Stat(filepath.Join(dest, "opt", "keep")); err != nil { + t.Fatalf("opt/keep after rejected whiteout: %v, want untouched", err) + } +} + +// TestUnpackDirReplacesLowerSymlink pins that a directory entry replaces a +// lower layer's symlink at the same path. Without the replacement, MkdirAll +// resolves through the link and the layer's children land in the link target, +// materializing a different filesystem tree. +func TestUnpackDirReplacesLowerSymlink(t *testing.T) { + lower := testTarLayer(t, + tarEntry{header: dirHeader("real", 0o755)}, + tarEntry{header: symHeader("dir", "/real")}, + ) + upper := testTarLayer(t, + tarEntry{header: dirHeader("dir", 0o755)}, + tarEntry{header: regHeader("dir/f", 0o644, 0), body: "payload"}, + ) + + s := openTestStore(t) + if _, err := s.addImage("local:dir-over-link", testImageWithLayers(t, lower, upper)); err != nil { + t.Fatal(err) + } + dest := t.TempDir() + if err := unpackImage(s, "local:dir-over-link", dest); err != nil { + t.Fatalf("unpackImage: %v", err) + } + + fi, err := os.Lstat(filepath.Join(dest, "dir")) + if err != nil { + t.Fatal(err) + } + if !fi.IsDir() { + t.Fatalf("dir mode = %v, want a real directory replacing the lower symlink", fi.Mode()) + } + if b, err := os.ReadFile(filepath.Join(dest, "dir", "f")); err != nil || string(b) != "payload" { + t.Fatalf("dir/f = %q, err=%v; want payload", b, err) + } + if _, err := os.Stat(filepath.Join(dest, "real", "f")); !os.IsNotExist(err) { + t.Fatalf("real/f = %v, want IsNotExist (children must not leak through the lower symlink)", err) + } +} + +// TestUnpackParentSymlinkReplaced pins that an entry whose parent path +// component is a lower layer's symlink lands under a real directory at that +// name, not inside the link's target: MkdirAll-style parent creation would +// resolve through the link and write real/sub/f while leaving link/sub/f +// unresolved. +func TestUnpackParentSymlinkReplaced(t *testing.T) { + lower := testTarLayer(t, + tarEntry{header: dirHeader("real", 0o755)}, + tarEntry{header: symHeader("link", "/real")}, + ) + upper := testTarLayer(t, + tarEntry{header: regHeader("link/sub/f", 0o644, 0), body: "payload"}, + ) + + s := openTestStore(t) + if _, err := s.addImage("local:parent-link", testImageWithLayers(t, lower, upper)); err != nil { + t.Fatal(err) + } + dest := t.TempDir() + if err := unpackImage(s, "local:parent-link", dest); err != nil { + t.Fatalf("unpackImage: %v", err) + } + + fi, err := os.Lstat(filepath.Join(dest, "link")) + if err != nil { + t.Fatal(err) + } + if !fi.IsDir() { + t.Fatalf("link mode = %v, want a real directory replacing the lower symlink", fi.Mode()) + } + if b, err := os.ReadFile(filepath.Join(dest, "link", "sub", "f")); err != nil || string(b) != "payload" { + t.Fatalf("link/sub/f = %q, err=%v; want payload", b, err) + } + if _, err := os.Stat(filepath.Join(dest, "real", "sub")); !os.IsNotExist(err) { + t.Fatalf("real/sub = %v, want IsNotExist (entry must not land through the lower symlink)", err) + } +} + +// TestUnpackDirEntryParentSymlinkReplaced is the directory-entry variant of +// TestUnpackParentSymlinkReplaced: a dir entry beneath a lower-layer symlink +// parent must materialize under a real directory, not inside the link target. +func TestUnpackDirEntryParentSymlinkReplaced(t *testing.T) { + lower := testTarLayer(t, + tarEntry{header: dirHeader("real", 0o755)}, + tarEntry{header: symHeader("link", "/real")}, + ) + upper := testTarLayer(t, + tarEntry{header: dirHeader("link/sub", 0o750)}, + ) + + s := openTestStore(t) + if _, err := s.addImage("local:parent-link-dir", testImageWithLayers(t, lower, upper)); err != nil { + t.Fatal(err) + } + dest := t.TempDir() + if err := unpackImage(s, "local:parent-link-dir", dest); err != nil { + t.Fatalf("unpackImage: %v", err) + } + + fi, err := os.Lstat(filepath.Join(dest, "link")) + if err != nil { + t.Fatal(err) + } + if !fi.IsDir() { + t.Fatalf("link mode = %v, want a real directory replacing the lower symlink", fi.Mode()) + } + sub, err := os.Lstat(filepath.Join(dest, "link", "sub")) + if err != nil || !sub.IsDir() || sub.Mode().Perm() != 0o750 { + t.Fatalf("link/sub = %v, err=%v; want a 0750 directory", sub, err) + } + if _, err := os.Stat(filepath.Join(dest, "real", "sub")); !os.IsNotExist(err) { + t.Fatalf("real/sub = %v, want IsNotExist (dir must not land through the lower symlink)", err) + } +} + +// TestUnpackOpaqueThroughSymlinkKeepsTarget pins that an opaque whiteout whose +// directory is a lower layer's symlink does not clear the link target's +// contents: the marker hides lower content under its own name, so the link is +// replaced with an empty real directory and the target's files survive. +func TestUnpackOpaqueThroughSymlinkKeepsTarget(t *testing.T) { + lower := testTarLayer(t, + tarEntry{header: dirHeader("target", 0o755)}, + tarEntry{header: regHeader("target/keep", 0o644, 0), body: "keep"}, + tarEntry{header: symHeader("d", "/target")}, + ) + upper := testTarLayer(t, + tarEntry{header: tar.Header{Name: "d/.wh..wh..opq", Typeflag: tar.TypeReg, Mode: 0o644}}, + ) + + s := openTestStore(t) + if _, err := s.addImage("local:opaque-link", testImageWithLayers(t, lower, upper)); err != nil { + t.Fatal(err) + } + dest := t.TempDir() + if err := unpackImage(s, "local:opaque-link", dest); err != nil { + t.Fatalf("unpackImage: %v", err) + } + + if b, err := os.ReadFile(filepath.Join(dest, "target", "keep")); err != nil || string(b) != "keep" { + t.Fatalf("target/keep = %q, err=%v; want untouched by opaque-through-symlink", b, err) + } + fi, err := os.Lstat(filepath.Join(dest, "d")) + if err != nil { + t.Fatal(err) + } + if !fi.IsDir() { + t.Fatalf("d mode = %v, want a real empty directory replacing the symlink", fi.Mode()) + } + entries, err := os.ReadDir(filepath.Join(dest, "d")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("d entries = %v, want empty", entries) + } +} diff --git a/cmd/elfuse-oci/unpack_test.go b/cmd/elfuse-oci/unpack_test.go new file mode 100644 index 00000000..a4f4e4b2 --- /dev/null +++ b/cmd/elfuse-oci/unpack_test.go @@ -0,0 +1,459 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/tar" + "bytes" + "io" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func newRoot(t *testing.T) (*os.Root, string) { + t.Helper() + dir := t.TempDir() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatalf("OpenRoot: %v", err) + } + t.Cleanup(func() { root.Close() }) + return root, dir +} + +func applyEntries(t *testing.T, root *os.Root, entries []tar.Header) { + t.Helper() + for _, h := range entries { + var content []byte + if (h.Typeflag == tar.TypeReg || h.Typeflag == tar.TypeRegA) && h.Size > 0 { + content = []byte(strings.Repeat("x", int(h.Size))) + } + hdr := h + if err := applyEntry(root, &hdr, bytes.NewReader(content), map[string]bool{}); err != nil { + t.Fatalf("applyEntry %q: %v", h.Name, err) + } + } +} + +func applyEntryWithContent(t *testing.T, root *os.Root, h tar.Header, content string) { + t.Helper() + hdr := h + hdr.Size = int64(len(content)) + if err := applyEntry(root, &hdr, strings.NewReader(content), map[string]bool{}); err != nil { + t.Fatalf("applyEntry %q: %v", h.Name, err) + } +} + +func regHeader(name string, mode int64, size int64) tar.Header { + return tar.Header{Name: name, Mode: mode, Typeflag: tar.TypeReg, Size: size} +} +func dirHeader(name string, mode int64) tar.Header { + return tar.Header{Name: name, Mode: mode, Typeflag: tar.TypeDir} +} +func symHeader(name, target string) tar.Header { + return tar.Header{Name: name, Typeflag: tar.TypeSymlink, Linkname: target} +} +func linkHeader(name, target string) tar.Header { + return tar.Header{Name: name, Typeflag: tar.TypeLink, Linkname: target} +} + +func TestUnpackRegularFilePerm(t *testing.T) { + root, dir := newRoot(t) + applyEntryWithContent(t, root, regHeader("bin/prog", 0o755, 0), "") + applyEntryWithContent(t, root, regHeader("etc/secret", 0o600, 0), "") + + fi, err := os.Stat(filepath.Join(dir, "bin", "prog")) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o755 { + t.Errorf("perm: got %o, want 755", fi.Mode().Perm()) + } + fi, _ = os.Stat(filepath.Join(dir, "etc", "secret")) + if fi.Mode().Perm() != 0o600 { + t.Errorf("perm: got %o, want 600", fi.Mode().Perm()) + } +} + +func TestUnpackOldStyleRegularFile(t *testing.T) { + root, dir := newRoot(t) + applyEntryWithContent(t, root, tar.Header{ + Name: "old-style", + Mode: 0o644, + Typeflag: tar.TypeRegA, + }, "hello") + + got, err := os.ReadFile(filepath.Join(dir, "old-style")) + if err != nil { + t.Fatal(err) + } + if string(got) != "hello" { + t.Errorf("old-style file content = %q, want hello", got) + } +} + +func TestUnpackStickyAndSetuid(t *testing.T) { + root, dir := newRoot(t) + applyEntries(t, root, []tar.Header{ + dirHeader("tmp", 0o1777), + regHeader("bin/su", 0o4755, 0), + }) + + fi, _ := os.Stat(filepath.Join(dir, "tmp")) + if fi.Mode()&os.ModeSticky == 0 { + t.Errorf("tmp missing sticky bit: %o", fi.Mode()) + } + fi, _ = os.Stat(filepath.Join(dir, "bin", "su")) + if fi.Mode()&os.ModeSetuid == 0 { + t.Errorf("su missing setuid bit: %o", fi.Mode()) + } + if fi.Mode().Perm() != 0o755 { + t.Errorf("su perm: got %o, want 755", fi.Mode().Perm()) + } +} + +// TestShouldDropSpecial pins the degrade decision applyMode makes when a +// mode-finalizing chmod fails: retry without the special bits only for a +// permission error that actually carried special bits, so a genuine chmod +// failure still surfaces. This is the portable stand-in for the live EPERM, +// which cannot be forced deterministically (t.TempDir() sits under a +// staff-group path where an unprivileged setgid chmod succeeds). +func TestShouldDropSpecial(t *testing.T) { + for _, tc := range []struct { + name string + err error + special os.FileMode + want bool + }{ + {"eperm with setgid degrades", syscall.EPERM, os.ModeSetgid, true}, + {"wrapped eperm with setuid degrades", + &os.PathError{Op: "chmodat", Path: "usr/bin/su", Err: syscall.EPERM}, + os.ModeSetuid, true}, + {"eperm without special bits surfaces", syscall.EPERM, 0, false}, + {"non-permission error surfaces", syscall.EINVAL, os.ModeSetgid, false}, + {"success is not a degrade", nil, os.ModeSetgid, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := shouldDropSpecial(tc.err, tc.special); got != tc.want { + t.Errorf("shouldDropSpecial(%v, %o) = %v, want %v", + tc.err, tc.special, got, tc.want) + } + }) + } +} + +// TestUnpackSetgidForeignGroupDoesNotAbort pins that a setgid entry never +// aborts the unpack, even on a host that rejects the special-bit chmod (macOS, +// when the file's inherited group is one the invoking user is not in; see +// applyMode). The permission bits must always land; the setgid bit may or may +// not survive depending on the host group, so it is deliberately not asserted. +func TestUnpackSetgidForeignGroupDoesNotAbort(t *testing.T) { + root, dir := newRoot(t) + applyEntries(t, root, []tar.Header{ + dirHeader("usr", 0o755), + dirHeader("usr/bin", 0o755), + // chage in Debian: setgid group shadow, mode 02755. + regHeader("usr/bin/chage", 0o2755, 0), + }) + fi, err := os.Stat(filepath.Join(dir, "usr", "bin", "chage")) + if err != nil { + t.Fatalf("stat chage: %v", err) + } + if fi.Mode().Perm() != 0o755 { + t.Errorf("chage perm: got %o, want 755", fi.Mode().Perm()) + } +} + +// TestUnpackModesSurviveUmask pins that layer permissions are finalized with +// an explicit chmod: creation modes are masked by the process umask, so an +// image mode like 0755 or 0644 must survive a restrictive host umask even +// when no setuid/setgid/sticky bit is present. It also pins that a parent +// directory's exact mode from its own tar entry is not reset to the 0755 +// default by the ensure-parent pass of a later child entry. +func TestUnpackModesSurviveUmask(t *testing.T) { + old := syscall.Umask(0o077) + defer syscall.Umask(old) + + root, dir := newRoot(t) + applyEntries(t, root, []tar.Header{ + dirHeader("opt", 0o755), + regHeader("opt/tool", 0o755, 0), + regHeader("opt/data", 0o644, 0), + dirHeader("secret", 0o700), + regHeader("secret/key", 0o600, 0), + }) + + for _, c := range []struct { + name string + want os.FileMode + }{ + {"opt", 0o755}, + {"opt/tool", 0o755}, + {"opt/data", 0o644}, + {"secret", 0o700}, + {"secret/key", 0o600}, + } { + fi, err := os.Stat(filepath.Join(dir, c.name)) + if err != nil { + t.Fatalf("stat %s: %v", c.name, err) + } + if fi.Mode().Perm() != c.want { + t.Errorf("%s perm: got %o, want %o", c.name, fi.Mode().Perm(), c.want) + } + } +} + +func TestUnpackAbsoluteSymlinkRewritten(t *testing.T) { + root, dir := newRoot(t) + applyEntries(t, root, []tar.Header{ + dirHeader("bin", 0o755), + regHeader("bin/busybox", 0o755, 0), + // /bin/sh -> /bin/busybox (absolute). Should be rewritten to "busybox" + // (relative), resolving to /bin/busybox under the sysroot. + symHeader("bin/sh", "/bin/busybox"), + // /lib/ld -> /lib/ld-musl.so.1 + dirHeader("lib", 0o755), + regHeader("lib/ld-musl.so.1", 0o644, 0), + symHeader("lib/ld", "/lib/ld-musl.so.1"), + }) + + got, err := os.Readlink(filepath.Join(dir, "bin", "sh")) + if err != nil { + t.Fatal(err) + } + if filepath.IsAbs(got) { + t.Errorf("absolute symlink not rewritten: %q", got) + } + if got != "busybox" { + t.Errorf("rewritten target: got %q, want busybox", got) + } + // Resolving the rewritten link must reach the real file. + target := filepath.Join(dir, "bin", got) + if _, err := os.Stat(target); err != nil { + t.Errorf("rewritten link does not resolve: %v", err) + } + got2, _ := os.Readlink(filepath.Join(dir, "lib", "ld")) + if filepath.IsAbs(got2) { + t.Errorf("deep absolute symlink not rewritten: %q", got2) + } +} + +func TestUnpackAbsoluteSymlinkCleansRootTraversal(t *testing.T) { + root, dir := newRoot(t) + applyEntryWithContent(t, root, regHeader("escape", 0o644, 0), "") + applyEntries(t, root, []tar.Header{ + dirHeader("usr", 0o755), + dirHeader("usr/bin", 0o755), + symHeader("usr/bin/link", "/../escape"), + }) + + link := filepath.Join(dir, "usr", "bin", "link") + got, err := os.Readlink(link) + if err != nil { + t.Fatal(err) + } + resolved := filepath.Clean(filepath.Join(filepath.Dir(link), got)) + want := filepath.Join(dir, "escape") + if resolved != want { + t.Errorf("rewritten target resolves to %s, want %s", resolved, want) + } +} + +func TestUnpackRelativeSymlinkPreserved(t *testing.T) { + root, dir := newRoot(t) + applyEntries(t, root, []tar.Header{ + dirHeader("lib", 0o755), + regHeader("lib/real.so", 0o644, 0), + symHeader("lib/link.so", "real.so"), + }) + got, _ := os.Readlink(filepath.Join(dir, "lib", "link.so")) + if got != "real.so" { + t.Errorf("relative symlink changed: got %q, want real.so", got) + } +} + +func TestUnpackWhiteoutRemovesFile(t *testing.T) { + root, dir := newRoot(t) + applyEntries(t, root, []tar.Header{ + dirHeader("etc", 0o755), + regHeader("etc/keep", 0o644, 0), + regHeader("etc/gone", 0o644, 0), + {Name: "etc/.wh.gone", Typeflag: tar.TypeReg}, + }) + if _, err := os.Stat(filepath.Join(dir, "etc", "gone")); !os.IsNotExist(err) { + t.Errorf("whiteout did not remove etc/gone: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "etc", "keep")); err != nil { + t.Errorf("whiteout removed etc/keep: %v", err) + } +} + +func TestUnpackOpaqueClearsDirectory(t *testing.T) { + root, dir := newRoot(t) + applyEntries(t, root, []tar.Header{ + dirHeader("opt", 0o755), + regHeader("opt/lower-a", 0o644, 0), + regHeader("opt/lower-b", 0o644, 0), + // Opaque marker clears opt, then this layer re-adds only lower-a. + {Name: "opt/.wh..wh..opq", Typeflag: tar.TypeReg}, + regHeader("opt/lower-a", 0o644, 0), + }) + if _, err := os.Stat(filepath.Join(dir, "opt", "lower-b")); !os.IsNotExist(err) { + t.Errorf("opaque did not clear opt/lower-b: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "opt", "lower-a")); err != nil { + t.Errorf("opaque removed re-added opt/lower-a: %v", err) + } +} + +func TestUnpackHardlink(t *testing.T) { + root, dir := newRoot(t) + applyEntryWithContent(t, root, regHeader("etc/passwd", 0o644, 5), "hello") + applyEntries(t, root, []tar.Header{linkHeader("etc/passwd-link", "etc/passwd")}) + // Both must refer to the same inode (hardlink), same content. + orig, err := os.Stat(filepath.Join(dir, "etc", "passwd")) + if err != nil { + t.Fatalf("hardlink source missing: %v", err) + } + link, err := os.Stat(filepath.Join(dir, "etc", "passwd-link")) + if err != nil { + t.Fatalf("hardlink target missing: %v", err) + } + if !os.SameFile(orig, link) { + t.Fatal("passwd and passwd-link are distinct inodes, want a hardlink") + } + if b, err := os.ReadFile(filepath.Join(dir, "etc", "passwd-link")); err != nil || string(b) != "hello" { + t.Fatalf("hardlink content = %q, err=%v; want hello", b, err) + } +} + +func TestUnpackSpecialFilesRejected(t *testing.T) { + cases := []struct { + name string + typeflag byte + want string + }{ + {"dev/ttyS0", tar.TypeChar, "char"}, + {"dev/sda", tar.TypeBlock, "block"}, + {"run/pipe", tar.TypeFifo, "fifo"}, + } + for _, tc := range cases { + t.Run(tc.want, func(t *testing.T) { + root, dir := newRoot(t) + hdr := tar.Header{Name: tc.name, Mode: 0o644, Typeflag: tc.typeflag} + err := applyEntry(root, &hdr, strings.NewReader(""), map[string]bool{}) + if err == nil || !strings.Contains(err.Error(), "unsupported special file type "+tc.want) { + t.Fatalf("applyEntry special %s err = %v, want unsupported error", tc.want, err) + } + if _, err := os.Lstat(filepath.Join(dir, tc.name)); !os.IsNotExist(err) { + t.Fatalf("special entry %s on disk: %v, want IsNotExist", tc.name, err) + } + }) + } +} + +func TestUnpackPathEscapeRejected(t *testing.T) { + root, _ := newRoot(t) + hdr := regHeader("../escape", 0o644, 0) + if err := applyEntry(root, &hdr, strings.NewReader(""), map[string]bool{}); err == nil { + t.Fatalf("applyEntry accepted ../escape path") + } +} + +func TestUnpackWritesFileContent(t *testing.T) { + root, dir := newRoot(t) + applyEntryWithContent(t, root, regHeader("msg.txt", 0o644, 5), "hello") + b, err := os.ReadFile(filepath.Join(dir, "msg.txt")) + if err != nil { + t.Fatal(err) + } + if string(b) != "hello" { + t.Errorf("content: got %q, want hello", b) + } +} + +// Ensure applyEntry reads exactly the header's Size bytes from the reader +// (no over-read, no short read). The reader carries trailing bytes beyond +// Size so an over-read would be visible in both the count and the content, +// and a too-small reader must fail rather than write a truncated file. +func TestUnpackReadsExactSize(t *testing.T) { + root, dir := newRoot(t) + content := "abc123" + r := &countingReader{b: []byte(content + "TRAILING-JUNK")} + hdr := regHeader("f", 0o644, int64(len(content))) + if err := applyEntry(root, &hdr, r, map[string]bool{}); err != nil { + t.Fatalf("applyEntry: %v", err) + } + if r.n != len(content) { + t.Errorf("bytes read: got %d, want %d", r.n, len(content)) + } + if b, _ := os.ReadFile(filepath.Join(dir, "f")); string(b) != content { + t.Errorf("content mismatch: got %q", b) + } + + short := &countingReader{b: []byte("abc")} + hdr = regHeader("g", 0o644, int64(len(content))) + if err := applyEntry(root, &hdr, short, map[string]bool{}); err == nil { + t.Fatal("applyEntry accepted a short body, want size-mismatch error") + } +} + +type countingReader struct { + b []byte + n int +} + +func (c *countingReader) Read(p []byte) (int, error) { + if c.n >= len(c.b) { + return 0, io.EOF + } + n := copy(p, c.b[c.n:]) + c.n += n + return n, nil +} + +// TestUnpackAbsoluteMemberNames pins the root-relative application of member +// names archived absolute (GNU tar -P builders): "/etc/foo" must land at +// /etc/foo instead of every os.Root operation rejecting the name +// with "path escapes from parent". +func TestUnpackAbsoluteMemberNames(t *testing.T) { + root, dir := newRoot(t) + applyEntries(t, root, []tar.Header{ + dirHeader("/etc", 0o755), + regHeader("/etc/foo", 0o644, 5), + }) + b, err := os.ReadFile(filepath.Join(dir, "etc", "foo")) + if err != nil { + t.Fatal(err) + } + if string(b) != "xxxxx" { + t.Errorf("content = %q, want xxxxx", b) + } +} + +// TestUnpackAbsoluteHardlinkTarget pins root-relative hard-link targets: a +// layer entry "bin/sh" hardlinked to "/bin/busybox" (absolute Linkname, legal +// in OCI layers) must link to the rootfs's own busybox, not abort the unpack +// with os.Root's "path escapes from parent". +func TestUnpackAbsoluteHardlinkTarget(t *testing.T) { + root, dir := newRoot(t) + applyEntryWithContent(t, root, regHeader("bin/busybox", 0o755, 0), "hello") + applyEntries(t, root, []tar.Header{ + linkHeader("bin/sh", "/bin/busybox"), + }) + a, err := os.Stat(filepath.Join(dir, "bin", "busybox")) + if err != nil { + t.Fatal(err) + } + b, err := os.Stat(filepath.Join(dir, "bin", "sh")) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(a, b) { + t.Error("bin/sh is not a hard link to bin/busybox") + } +} From ff28d8eef1e9f9bfc8301354d11d9db54883add6 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 15 Jul 2026 23:24:06 +0200 Subject: [PATCH 16/22] Add elfuse-oci run command run is the last pipeline stage: resolve the image config into a concrete runspec, materialize the rootfs, and exec the existing `elfuse --sysroot ...` positional launch path, reusing elfuse's HVF bring-up, shebang, and dynamic-linker plumbing rather than reinventing guest launch. elfuse is located as a sibling binary ($ELFUSE_BIN overrides for tests) and replaced via exec so the shell reaps the same pid and terminal signals reach the guest directly. The runspec resolves Entrypoint/Cmd/Env/User/WorkingDir with the usual precedence (--entrypoint drops image Cmd; --env and --clear-env follow env(1) semantics; --workdir must be guest-absolute). A PATH is guaranteed: when neither the image config nor --env supplies one, Docker's conventional default is appended, so a guest whose image omits PATH still has a search path after the --clear-env launch. A relative path command resolves against the working directory and a bare name against the merged PATH inside the image rootfs, following Docker's exec-form rules (elfuse resolves the initial ELF before applying --workdir and does no PATH lookup, so the launcher must); a config-only image WorkingDir no layer ships is created at run time, as runc does. A symbolic --user or image User is resolved against the image's own /etc/passwd and /etc/group through os.Root-bounded, no-follow opens, so a crafted rootfs cannot redirect resolution to host account files. run auto-pulls only when the ref is genuinely absent (errNotPulled) and surfaces store corruption instead of masking it behind a network pull; an explicit --platform must match the pinned image so a ref pulled for another architecture is not silently launched. Before exec, host-truth /etc/{resolv.conf,hosts,hostname} are injected into the rootfs through the same os.Root bounds. Tests cover runspec resolution and precedence, symbolic user lookup, runtime-file injection, and the exec argv shape via the execElfuseForRun seam and a subprocess exec probe. --- cmd/elfuse-oci/commands.go | 115 ++++++- cmd/elfuse-oci/common_test.go | 37 +++ cmd/elfuse-oci/etc.go | 154 ++++++++++ cmd/elfuse-oci/etc_test.go | 293 ++++++++++++++++++ cmd/elfuse-oci/main.go | 23 +- cmd/elfuse-oci/run.go | 64 ++++ cmd/elfuse-oci/run_test.go | 97 ++++++ cmd/elfuse-oci/runspec.go | 357 ++++++++++++++++++++++ cmd/elfuse-oci/runspec_test.go | 455 ++++++++++++++++++++++++++++ cmd/elfuse-oci/test_helpers_test.go | 15 + 10 files changed, 1606 insertions(+), 4 deletions(-) create mode 100644 cmd/elfuse-oci/etc.go create mode 100644 cmd/elfuse-oci/etc_test.go create mode 100644 cmd/elfuse-oci/run.go create mode 100644 cmd/elfuse-oci/run_test.go create mode 100644 cmd/elfuse-oci/runspec.go create mode 100644 cmd/elfuse-oci/runspec_test.go diff --git a/cmd/elfuse-oci/commands.go b/cmd/elfuse-oci/commands.go index 125b7471..c4ee195d 100644 --- a/cmd/elfuse-oci/commands.go +++ b/cmd/elfuse-oci/commands.go @@ -4,12 +4,14 @@ package main import ( + "errors" "fmt" "os" ) -// The subcommands share common-flag parsing (common.go) and the OCI -// image-layout store (store.go). pull/unpack/inspect are pure store ops. +// The four subcommands share common-flag parsing (common.go) and the OCI +// image-layout store (store.go). pull/unpack/inspect are pure store ops; run +// additionally resolves the runspec and execs elfuse (run.go). // cmdPull implements `elfuse-oci pull [--store] [--platform] `. func cmdPull(args []string) error { @@ -98,3 +100,112 @@ func parseInspectArgs(args []string) (commonFlags, bool, string, error) { ref, err := oneArg("inspect", fs.Args(), "") return cf, asJSON, ref, err } + +// cmdRun runs an image: `elfuse-oci run [flags] [args...]`. +// +// Flags are parsed only up to the first positional (the reference); everything +// after the reference is the guest argv tail and is passed verbatim (no flag +// parsing), matching Docker's `run IMAGE ARGS` convention. +func cmdRun(args []string) error { + cf, rf, ref, tail, err := parseRunArgs(args) + if err != nil { + return err + } + s, err := cf.openResolvedStore() + if err != nil { + return err + } + img, err := s.image(ref) + if err != nil { + // Auto-pull only when the ref is simply absent, so `run` is + // self-sufficient on first use. Any other failure (corrupt refs.json, + // unreadable layout) must surface rather than mask itself behind a + // fresh network pull. + if !errors.Is(err, errNotPulled) { + return err + } + if err := pullImage(cf, s, ref); err != nil { + return err + } + img, err = s.image(ref) + if err != nil { + return err + } + } + cfg, err := img.ConfigFile() + if err != nil { + return err + } + // An explicit --platform must match the pinned image: the store pins one + // digest per ref, so a ref pulled for another platform would otherwise + // launch silently under the wrong architecture. + if cf.platformSet { + got := Platform{OS: cfg.OS, Arch: cfg.Architecture, Variant: cfg.Variant} + want := cf.platform + if got.OS != want.OS || got.Arch != want.Arch || + (want.Variant != "" && got.Variant != want.Variant) { + return fmt.Errorf( + "run: %s is pinned for %s, not %s; `pull --platform %s %s` (after rmi) to switch", + ref, got, want, want, ref) + } + } + digest, err := img.Digest() + if err != nil { + return err + } + if rf.rootfs == "" { + rf.rootfs, err = defaultRootfsForDigest(cf.store, digest.String()) + if err != nil { + return err + } + } + + // Ensure the rootfs is unpacked before computing the spec, because + // resolveUser reads /etc/passwd and /etc/group. Re-unpack only if + // absent; a stale rootfs is the user's concern (run `unpack` to refresh). + if _, err := os.Stat(rf.rootfs); err != nil { + if !os.IsNotExist(err) { + return err + } + fmt.Fprintf(os.Stderr, "Unpacking %s -> %s\n", ref, rf.rootfs) + if err := unpackImage(s, ref, rf.rootfs); err != nil { + return err + } + } + + spec, err := computeRunSpec(cfg, rf, rf.rootfs, tail) + if err != nil { + return err + } + // Inject host-truth /etc/{resolv.conf,hosts,hostname} into the rootfs so + // the guest's resolver/hostname work. On the plain path this mutates the + // unpacked rootfs directory (acceptable: --plain-rootfs is the v1/debug + // path; re-runs overwrite the same small files). + if err := injectRuntimeFiles(rf.rootfs); err != nil { + return err + } + + return execElfuseForRun(rf.rootfs, spec) +} + +func parseRunArgs(args []string) (commonFlags, runFlags, string, []string, error) { + var cf commonFlags + var rf runFlags + var env repeatedStringFlag + fs := newCommandFlagSet("run", &cf) + fs.StringVar(&rf.entrypoint, "entrypoint", "", "") + fs.Var(&env, "env", "") + fs.BoolVar(&rf.clearEnv, "clear-env", false, "") + fs.StringVar(&rf.user, "user", "", "") + fs.StringVar(&rf.workdir, "workdir", "", "") + fs.StringVar(&rf.rootfs, "rootfs", "", "") + if err := fs.Parse(args); err != nil { + return cf, rf, "", nil, err + } + rf.env = []string(env) + rest := fs.Args() + if len(rest) == 0 { + return cf, rf, "", nil, fmt.Errorf("run: expected [args...]") + } + return cf, rf, rest[0], rest[1:], nil +} diff --git a/cmd/elfuse-oci/common_test.go b/cmd/elfuse-oci/common_test.go index 182f852d..73dcee99 100644 --- a/cmd/elfuse-oci/common_test.go +++ b/cmd/elfuse-oci/common_test.go @@ -89,6 +89,42 @@ func TestParseInspectArgs(t *testing.T) { } } +func TestParseRunArgs(t *testing.T) { + cf, rf, ref, tail, err := parseRunArgs([]string{ + "--store", "/s", + "--entrypoint", "/bin/sh", + "--env", "A=1", + "--env=B=2", + "--clear-env", + "--user", "1000:1000", + "--workdir", "/work", + "--rootfs", "/tmp/rootfs", + "alpine:3", + "-c", "echo hi", + }) + if err != nil { + t.Fatal(err) + } + if cf.store != "/s" { + t.Errorf("store = %q, want /s", cf.store) + } + if ref != "alpine:3" { + t.Errorf("ref = %q, want alpine:3", ref) + } + if !reflect.DeepEqual(tail, []string{"-c", "echo hi"}) { + t.Errorf("tail = %v, want [-c echo hi]", tail) + } + if rf.entrypoint != "/bin/sh" || rf.user != "1000:1000" || rf.workdir != "/work" || rf.rootfs != "/tmp/rootfs" { + t.Errorf("run flags = %+v", rf) + } + if !rf.clearEnv { + t.Error("clearEnv = false, want true") + } + if !reflect.DeepEqual(rf.env, []string{"A=1", "B=2"}) { + t.Errorf("env = %v, want [A=1 B=2]", rf.env) + } +} + func TestParseCommandFlagErrors(t *testing.T) { cases := []struct { name string @@ -97,6 +133,7 @@ func TestParseCommandFlagErrors(t *testing.T) { {"malformed platform", func() error { _, _, err := parsePullArgs([]string{"--platform", "bogus", "alpine:3"}); return err }()}, {"unknown flag", func() error { _, _, err := parsePullArgs([]string{"--unknown", "alpine:3"}); return err }()}, {"missing flag value", func() error { _, _, _, err := parseUnpackArgs([]string{"--rootfs"}); return err }()}, + {"run missing ref", func() error { _, _, _, _, err := parseRunArgs([]string{"--env", "A=1"}); return err }()}, } for _, tc := range cases { if tc.err == nil { diff --git a/cmd/elfuse-oci/etc.go b/cmd/elfuse-oci/etc.go new file mode 100644 index 00000000..a6c88302 --- /dev/null +++ b/cmd/elfuse-oci/etc.go @@ -0,0 +1,154 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" + "time" +) + +var ( + hostnameForRuntime = os.Hostname + readHostResolvConfig = func() ([]byte, error) { return os.ReadFile("/etc/resolv.conf") } +) + +// injectRuntimeFiles writes host-truth /etc/{resolv.conf,hosts,hostname} into +// sysroot before elfuse launches the guest. Runtimes that consume OCI images +// synthesize these per-run rather than handing the guest the image's (often +// stub or empty) copies: the guest's resolver reads /etc/resolv.conf to find its +// nameserver, and because --sysroot redirects guest absolute paths into the +// rootfs, the guest would otherwise read the image's file, not the host's. +// elfuse does not do network namespacing (the guest uses the host network +// directly), so the host's resolver config is the correct one to hand it. +// +// Overwrite is intentional: these files are runtime-controlled, not image +// content. The caller passes the final sysroot elfuse will receive as +// --sysroot (the per-run COW clone on the case-sensitive path, or the plain +// rootfs directory on the --plain-rootfs path), so writes are isolated to +// this run except when the caller opted into mutating the base tree +// (--no-clone / --plain-rootfs). +func injectRuntimeFiles(sysroot string) error { + // All access goes through os.Root so image-controlled symlinks (a + // symlinked /etc directory or a symlinked target file such as + // etc/resolv.conf -> /etc/resolv.conf) cannot redirect the writes + // outside the rootfs. + root, err := os.OpenRoot(sysroot) + if err != nil { + return err + } + defer root.Close() + + // Guard against a stray non-directory at /etc (e.g. a malformed image): + // replace a symlink rather than chasing it, and reject any other + // non-directory up front; letting it slide would only surface later as + // an opaque "not a directory" from the first runtime-file write. + if li, err := root.Lstat("etc"); err == nil { + switch { + case li.Mode()&os.ModeSymlink != 0: + if err := root.Remove("etc"); err != nil { + return err + } + case !li.IsDir(): + return fmt.Errorf("rootfs /etc is a %s, want a directory", li.Mode().Type()) + } + } else if !os.IsNotExist(err) { + return err + } + if err := root.Mkdir("etc", 0o755); err != nil && !errors.Is(err, fs.ErrExist) { + return err + } + + host, err := hostnameForRuntime() + if err != nil || host == "" { + host = "localhost" + } + + if err := writeRuntimeFile(root, "etc/hostname", []byte(host+"\n")); err != nil { + return err + } + + // Minimal hosts map: localhost + the guest's own hostname, mirroring what + // image runtimes conventionally write. + hosts := "127.0.0.1\tlocalhost " + host + "\n::1\tlocalhost ip6-localhost\n" + if err := writeRuntimeFile(root, "etc/hosts", []byte(hosts)); err != nil { + return err + } + + // resolv.conf: copy the host's verbatim (host-truth) so the guest's DNS + // lookups hit the same nameservers the host uses. Fall back to a minimal + // default if the host file is absent or empty. + resolv, err := readHostResolvConfig() + if err != nil || len(resolv) == 0 { + resolv = []byte("nameserver 8.8.8.8\n") + } + return writeRuntimeFile(root, "etc/resolv.conf", resolv) +} + +// writeRuntimeFile replaces the rootfs-relative name with content. The +// content is written to a unique temp file beside name and renamed into +// place: rename replaces the existing directory entry without following it, +// so a symlink shipped by the image at that name is unlinked rather than +// chased, and a concurrent writer (two --no-clone / --plain-rootfs runs of +// the same digest share the base tree) never observes a missing or +// half-written file the way a remove-then-create sequence would expose. +func writeRuntimeFile(root *os.Root, name string, content []byte) error { + tmp := fmt.Sprintf("%s.tmp.%d.%d", name, os.Getpid(), time.Now().UnixNano()) + f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + if _, err := f.Write(content); err != nil { + f.Close() + _ = root.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + _ = root.Remove(tmp) + return err + } + if err := root.Rename(tmp, name); err != nil { + _ = root.Remove(tmp) + return err + } + return nil +} + +// prepareRootfsForRun performs the per-run rootfs mutations both launch +// paths need, in one place so a new preparation step cannot land in one path +// and silently miss the other: inject the host-truth /etc files, then make +// sure the spec's working directory exists. +func prepareRootfsForRun(sysroot string, spec *runSpec) error { + if err := injectRuntimeFiles(sysroot); err != nil { + return err + } + if err := ensureWorkdir(sysroot, spec.Workdir); err != nil { + return fmt.Errorf("create workdir %s: %w", spec.Workdir, err) + } + return nil +} + +// ensureWorkdir creates the working directory inside the rootfs when it is +// absent. An image config may name a WorkingDir no layer ships (a config-only +// WORKDIR); Docker's runtime creates the directory at container start, so a +// run here must too rather than failing elfuse's chdir. An existing path, +// including one reached through image symlinks, is left untouched. +func ensureWorkdir(sysroot, workdir string) error { + rel := strings.TrimPrefix(workdir, "/") + if rel == "" { + return nil + } + root, err := os.OpenRoot(sysroot) + if err != nil { + return err + } + defer root.Close() + if _, err := root.Stat(rel); err == nil { + return nil + } + return root.MkdirAll(rel, 0o755) +} diff --git a/cmd/elfuse-oci/etc_test.go b/cmd/elfuse-oci/etc_test.go new file mode 100644 index 00000000..f9b47740 --- /dev/null +++ b/cmd/elfuse-oci/etc_test.go @@ -0,0 +1,293 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestInjectRuntimeFiles asserts the three runtime files are written with the +// expected shape, and that a second call overwrites (not appends). +func TestInjectRuntimeFiles(t *testing.T) { + root := t.TempDir() + if err := injectRuntimeFiles(root); err != nil { + t.Fatal(err) + } + + host, err := os.ReadFile(filepath.Join(root, "etc", "hostname")) + if err != nil { + t.Fatalf("hostname: %v", err) + } + hostname := strings.TrimSpace(string(host)) + if hostname == "" { + t.Error("hostname is empty") + } + + hosts, err := os.ReadFile(filepath.Join(root, "etc", "hosts")) + if err != nil { + t.Fatalf("hosts: %v", err) + } + hs := string(hosts) + if !strings.Contains(hs, "127.0.0.1\tlocalhost") { + t.Errorf("hosts missing 127.0.0.1 localhost: %q", hs) + } + if !strings.Contains(hs, "::1\tlocalhost") { + t.Errorf("hosts missing ::1 localhost: %q", hs) + } + if !strings.Contains(hs, hostname) { + t.Errorf("hosts missing hostname %q: %q", hostname, hs) + } + + resolv, err := os.ReadFile(filepath.Join(root, "etc", "resolv.conf")) + if err != nil { + t.Fatalf("resolv.conf: %v", err) + } + // Substring only: the host's nameserver varies across macOS/Linux CI, and + // the fallback is "nameserver 8.8.8.8"; either way a nameserver line is + // present. + if !strings.Contains(string(resolv), "nameserver") { + t.Errorf("resolv.conf missing nameserver: %q", resolv) + } + + // Second call overwrites in place, never appends: hostname stays the same. + if err := injectRuntimeFiles(root); err != nil { + t.Fatal(err) + } + host2, _ := os.ReadFile(filepath.Join(root, "etc", "hostname")) + if strings.TrimSpace(string(host2)) != hostname { + t.Errorf("hostname changed on re-inject: got %q want %q", host2, host) + } + + // Exactly the three runtime files: the temp-and-rename writes must not + // leave *.tmp.* staging litter behind. + entries, err := os.ReadDir(filepath.Join(root, "etc")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 3 { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("etc entries = %v, want exactly hostname, hosts, resolv.conf", names) + } +} + +// TestWriteRuntimeFileLeavesNoTempOnFailure pins that a failed write does not +// leave a staging temp file behind. +func TestWriteRuntimeFileLeavesNoTempOnFailure(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("directory write permissions do not bind as root") + } + dir := t.TempDir() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := os.Chmod(dir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + + if err := writeRuntimeFile(root, "resolv.conf", []byte("nameserver 8.8.8.8\n")); err == nil { + t.Fatal("writeRuntimeFile into read-only dir succeeded, want error") + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("failed write left litter: %v", entries) + } +} + +// TestInjectRuntimeFilesReplacesSymlinkEtc pins the symlink guard: a stray +// /etc symlink (e.g. from a malformed image) is replaced with a real directory +// so the writes cannot escape the rootfs. +func TestInjectRuntimeFilesReplacesSymlinkEtc(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "elsewhere") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + etcLink := filepath.Join(root, "etc") + if err := os.Symlink(target, etcLink); err != nil { + t.Fatal(err) + } + + if err := injectRuntimeFiles(root); err != nil { + t.Fatal(err) + } + + li, err := os.Lstat(etcLink) + if err != nil { + t.Fatalf("Lstat etc: %v", err) + } + if li.Mode()&os.ModeSymlink != 0 { + t.Fatalf("etc is still a symlink: mode %o", li.Mode()) + } + if !li.IsDir() { + t.Fatalf("etc is not a directory: mode %o", li.Mode()) + } + for _, name := range []string{"hostname", "hosts", "resolv.conf"} { + if _, err := os.Stat(filepath.Join(etcLink, name)); err != nil { + t.Errorf("etc/%s missing after symlink replacement: %v", name, err) + } + } + // The symlink target directory must not have received the files. + if _, err := os.Stat(filepath.Join(target, "hostname")); err == nil { + t.Error("hostname leaked into the symlink target directory") + } +} + +// TestInjectRuntimeFilesReplacesSymlinkTargets asserts that a symlink shipped +// by the image AT a runtime file's own name (etc/resolv.conf -> host path) is +// replaced with a regular file rather than followed: the write must not land +// in the symlink's target outside the rootfs. +func TestInjectRuntimeFilesReplacesSymlinkTargets(t *testing.T) { + outside := t.TempDir() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil { + t.Fatal(err) + } + + const sentinel = "host-owned\n" + for _, name := range []string{"hostname", "hosts", "resolv.conf"} { + hostFile := filepath.Join(outside, name) + if err := os.WriteFile(hostFile, []byte(sentinel), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(hostFile, filepath.Join(root, "etc", name)); err != nil { + t.Fatal(err) + } + } + + if err := injectRuntimeFiles(root); err != nil { + t.Fatal(err) + } + + for _, name := range []string{"hostname", "hosts", "resolv.conf"} { + li, err := os.Lstat(filepath.Join(root, "etc", name)) + if err != nil { + t.Fatalf("Lstat etc/%s: %v", name, err) + } + if li.Mode()&os.ModeSymlink != 0 { + t.Errorf("etc/%s is still a symlink after inject", name) + } + got, err := os.ReadFile(filepath.Join(outside, name)) + if err != nil { + t.Fatalf("read outside %s: %v", name, err) + } + if string(got) != sentinel { + t.Errorf("outside %s was overwritten through the symlink: %q", name, got) + } + } +} + +func TestInjectRuntimeFilesFallbacks(t *testing.T) { + oldHostname := hostnameForRuntime + oldReadResolv := readHostResolvConfig + hostnameForRuntime = func() (string, error) { return "", errors.New("hostname unavailable") } + readHostResolvConfig = func() ([]byte, error) { return nil, errors.New("resolv unavailable") } + t.Cleanup(func() { + hostnameForRuntime = oldHostname + readHostResolvConfig = oldReadResolv + }) + + root := t.TempDir() + if err := injectRuntimeFiles(root); err != nil { + t.Fatal(err) + } + hostname, err := os.ReadFile(filepath.Join(root, "etc", "hostname")) + if err != nil { + t.Fatal(err) + } + if string(hostname) != "localhost\n" { + t.Fatalf("fallback hostname = %q, want localhost", hostname) + } + hosts, err := os.ReadFile(filepath.Join(root, "etc", "hosts")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(hosts), "localhost") { + t.Fatalf("fallback hosts = %q, want localhost mapping", hosts) + } + resolv, err := os.ReadFile(filepath.Join(root, "etc", "resolv.conf")) + if err != nil { + t.Fatal(err) + } + if string(resolv) != "nameserver 8.8.8.8\n" { + t.Fatalf("fallback resolv.conf = %q, want Google DNS fallback", resolv) + } +} + +func TestInjectRuntimeFilesFilesystemErrors(t *testing.T) { + rootFile := filepath.Join(t.TempDir(), "sysroot-file") + if err := os.WriteFile(rootFile, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + if err := injectRuntimeFiles(rootFile); err == nil { + t.Fatal("injectRuntimeFiles with file sysroot succeeded, want error") + } +} + +// TestInjectRuntimeFilesRejectsRegularFileEtc pins the up-front check: an +// image shipping /etc as a regular file must fail with a clear error, not a +// confusing "not a directory" from the first runtime-file write. +func TestInjectRuntimeFilesRejectsRegularFileEtc(t *testing.T) { + sysroot := t.TempDir() + if err := os.WriteFile(filepath.Join(sysroot, "etc"), []byte("not a dir"), 0o644); err != nil { + t.Fatal(err) + } + err := injectRuntimeFiles(sysroot) + if err == nil || !strings.Contains(err.Error(), "want a directory") { + t.Fatalf("injectRuntimeFiles err = %v, want explicit non-directory /etc error", err) + } + if b, rerr := os.ReadFile(filepath.Join(sysroot, "etc")); rerr != nil || string(b) != "not a dir" { + t.Fatalf("etc file after rejection = %q, err=%v; want untouched", b, rerr) + } +} + +// TestEnsureWorkdir pins the config-only WORKDIR behavior: a working +// directory no layer ships is created at run time (as runc does), while an +// existing path, including one reached through an image symlink, is left +// untouched. +func TestEnsureWorkdir(t *testing.T) { + t.Run("creates missing", func(t *testing.T) { + rootfs := t.TempDir() + if err := ensureWorkdir(rootfs, "/app/nested"); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(filepath.Join(rootfs, "app", "nested")) + if err != nil || !fi.IsDir() { + t.Fatalf("workdir not created: fi=%v err=%v", fi, err) + } + }) + t.Run("root is a no-op", func(t *testing.T) { + if err := ensureWorkdir(t.TempDir(), "/"); err != nil { + t.Fatal(err) + } + }) + t.Run("existing symlink kept", func(t *testing.T) { + rootfs := t.TempDir() + if err := os.Mkdir(filepath.Join(rootfs, "real"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real", filepath.Join(rootfs, "app")); err != nil { + t.Fatal(err) + } + if err := ensureWorkdir(rootfs, "/app"); err != nil { + t.Fatal(err) + } + fi, err := os.Lstat(filepath.Join(rootfs, "app")) + if err != nil || fi.Mode()&os.ModeSymlink == 0 { + t.Fatalf("existing symlink replaced: fi=%v err=%v", fi, err) + } + }) +} diff --git a/cmd/elfuse-oci/main.go b/cmd/elfuse-oci/main.go index 9308cc22..e6d10c5f 100644 --- a/cmd/elfuse-oci/main.go +++ b/cmd/elfuse-oci/main.go @@ -3,8 +3,11 @@ // elfuse-oci is the OCI image CLI for elfuse. // -// It owns the OCI image pipeline (pull, store, inspect, and unpack for -// now) using go-containerregistry. elfuse itself stays a pure Linux +// It owns the OCI image pipeline (pull, store, inspect, unpack, and run +// orchestration) using go-containerregistry. For `run` it execs the existing +// `elfuse --sysroot ` positional launch path, +// reusing elfuse's HVF bring-up / shebang / dynamic-linker plumbing rather +// than reinventing guest launch. elfuse itself stays a pure Linux // syscall-to-Darwin runtime with no OCI awareness. // // Usage: @@ -12,6 +15,9 @@ // elfuse-oci pull [--store DIR] [--platform os/arch[/variant]] // elfuse-oci unpack [--store DIR] [--rootfs DIR] // elfuse-oci inspect [--store DIR] [--json] +// elfuse-oci run [--store DIR] [--entrypoint E] [--env K=V]... +// [--user UID[:GID]] [--workdir DIR] [--platform ...] +// [args...] // // is an OCI image reference (docker.io/library/alpine:3, ghcr.io/..., // localhost:5000/foo:tag, or name@sha256:...). The default store is @@ -39,6 +45,7 @@ commands: pull Pull an image reference into the local OCI store unpack Unpack a stored image's layers into a rootfs directory inspect Print a stored image's manifest + config + run Pull + unpack + exec the image's entrypoint under elfuse help Show this help version Print the elfuse-oci version @@ -46,6 +53,16 @@ common flags: --store DIR OCI store directory (default $ELFUSE_OCI_STORE or ~/.local/share/elfuse/oci) --platform os/arch[/variant] Target platform (default linux/arm64) + +run flags: + --entrypoint PATH Override the image Entrypoint (drops image Cmd) + --env KEY=VAL Set a guest env var (repeatable; bare KEY inherits + from the host environ) + --clear-env Start the guest env empty (only --env apply) + --user UID[:GID] Run as UID (and GID; defaults to UID). Symbolic names + are resolved against the image /etc/passwd and + /etc/group before exec. + --workdir DIR Guest-absolute initial working directory `) } @@ -80,6 +97,8 @@ func dispatch(cmd string, rest []string) error { return cmdUnpack(rest) case "inspect": return cmdInspect(rest) + case "run": + return cmdRun(rest) default: usage() return fmt.Errorf("unknown command: %s", cmd) diff --git a/cmd/elfuse-oci/run.go b/cmd/elfuse-oci/run.go new file mode 100644 index 00000000..653f5f00 --- /dev/null +++ b/cmd/elfuse-oci/run.go @@ -0,0 +1,64 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "path/filepath" + "syscall" +) + +var execElfuseForRun = execElfuse + +// elfuseBin locates the elfuse binary to exec for `run`. Precedence: +// - $ELFUSE_BIN (an override hook for tests and wrapper scripts); +// - the sibling of this executable (build/elfuse-oci -> build/elfuse). +func elfuseBin() (string, error) { + if p := os.Getenv("ELFUSE_BIN"); p != "" { + return p, nil + } + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locate elfuse: %w", err) + } + return filepath.Join(filepath.Dir(exe), "elfuse"), nil +} + +// execElfuse replaces this process with `elfuse --sysroot --user U:G +// --workdir D --clear-env --env K=V ... `. +// +// --clear-env plus every final env var as an explicit --env makes the guest +// see exactly the runspec env (image Env merged with --env overrides, per the +// precedence matrix) rather than the host environ. +// +// syscall.Exec replaces elfuse-oci in place: the invoking shell reaps +// the same pid, and signals such as Ctrl-C go straight to elfuse rather than +// through a Go middleman. +func execElfuse(rootfs string, spec *runSpec) error { + bin, err := elfuseBin() + if err != nil { + return err + } + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("elfuse binary not found at %s (set $ELFUSE_BIN): %w", bin, err) + } + + argv := []string{ + "elfuse", + "--sysroot", rootfs, + "--user", fmt.Sprintf("%d:%d", spec.UID, spec.GID), + "--workdir", spec.Workdir, + "--clear-env", + } + for _, e := range spec.Env { + argv = append(argv, "--env", e) + } + argv = append(argv, spec.Args...) + + if err := syscall.Exec(bin, argv, os.Environ()); err != nil { + return fmt.Errorf("exec %s: %w", bin, err) + } + return nil // unreachable +} diff --git a/cmd/elfuse-oci/run_test.go b/cmd/elfuse-oci/run_test.go new file mode 100644 index 00000000..f254182b --- /dev/null +++ b/cmd/elfuse-oci/run_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// writeElfuseStub writes a #!/bin/sh stub script that stands in for the +// elfuse binary, and points elfuseBin() at it via $ELFUSE_BIN. spawnElfuseWait +// exec.Command's whatever $ELFUSE_BIN names, so no real elfuse (and no HVF) is +// needed. t.Setenv restores the env on cleanup. +func writeElfuseStub(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "elfuse-stub.sh") + if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("ELFUSE_BIN", p) + return p +} + +func TestElfuseBinEnvAndFallback(t *testing.T) { + want := filepath.Join(t.TempDir(), "elfuse-custom") + t.Setenv("ELFUSE_BIN", want) + got, err := elfuseBin() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("elfuseBin with env = %q, want %q", got, want) + } + + t.Setenv("ELFUSE_BIN", "") + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + want = filepath.Join(filepath.Dir(exe), "elfuse") + got, err = elfuseBin() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("elfuseBin fallback = %q, want %q", got, want) + } +} + +func TestExecElfuseMissingBinary(t *testing.T) { + t.Setenv("ELFUSE_BIN", filepath.Join(t.TempDir(), "missing-elfuse")) + err := execElfuse(t.TempDir(), &runSpec{Args: []string{"/bin/true"}, Workdir: "/", UID: 0, GID: 0}) + if err == nil || !strings.Contains(err.Error(), "elfuse binary not found") { + t.Fatalf("execElfuse missing binary err = %v, want not found", err) + } +} + +func TestExecElfuseSuccessSubprocess(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, "argv.txt") + stub := filepath.Join(dir, "elfuse-stub.sh") + body := "printf '%s\\n' \"$@\" > \"$ELFUSE_EXEC_ARGV_OUT\"\nexit 17\n" + if err := os.WriteFile(stub, []byte("#!/bin/sh\n"+body), 0o755); err != nil { + t.Fatal(err) + } + rootfs := filepath.Join(dir, "rootfs") + if err := os.MkdirAll(rootfs, 0o755); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(os.Args[0], "-test.run=^$") + cmd.Env = append(os.Environ(), + "ELFUSE_EXEC_ELFUSE_TEST=1", + "ELFUSE_BIN="+stub, + "ELFUSE_EXEC_ROOTFS="+rootfs, + "ELFUSE_EXEC_ARGV_OUT="+outPath, + ) + err := cmd.Run() + exit, ok := err.(*exec.ExitError) + if !ok || exit.ExitCode() != 17 { + t.Fatalf("execElfuse subprocess err = %T %v, want stub exit 17", err, err) + } + b, err := os.ReadFile(outPath) + if err != nil { + t.Fatal(err) + } + out := string(b) + for _, want := range []string{"--sysroot", rootfs, "--user", "1:2", "--workdir", "/", "--clear-env", "--env", "A=1", "/bin/echo", "hi"} { + if !strings.Contains(out, want) { + t.Fatalf("exec argv missing %q in:\n%s", want, out) + } + } +} diff --git a/cmd/elfuse-oci/runspec.go b/cmd/elfuse-oci/runspec.go new file mode 100644 index 00000000..e57d657f --- /dev/null +++ b/cmd/elfuse-oci/runspec.go @@ -0,0 +1,357 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/google/go-containerregistry/pkg/v1" +) + +// runSpec is the fully-resolved launch specification handed to elfuse. +type runSpec struct { + // Args is the final command vector: resolved Entrypoint followed by the + // resolved Cmd (image Cmd, the CLI tail, or nothing per the precedence + // matrix below). + Args []string + // Env is the final environment (image Env, overridden/appended by --env; + // --clear-env starts from empty). Bare KEY entries are expanded against + // the host environ here so elfuse receives only KEY=VAL. + Env []string + // Workdir is the guest-absolute initial working directory. + Workdir string + // UID/GID are the resolved numeric identity. + UID uint32 + GID uint32 +} + +// defaultGuestPath is Docker's conventional default PATH. computeRunSpec +// appends it when neither the image config nor --env supplies a PATH: run +// launches elfuse with --clear-env, so a guest whose image config omits PATH +// would otherwise start with no search path at all. +const defaultGuestPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +// runFlags are the run-specific flags parsed between the common flags and the +// image reference. Everything after the reference is the guest argv tail and +// is not flag-parsed. +type runFlags struct { + entrypoint string + env []string + clearEnv bool + user string + workdir string + rootfs string +} + +// computeRunSpec applies the Entrypoint/Cmd/Env/WorkingDir/User precedence. +// +// Command (Docker/OCI semantics, matching the branch's runspec.c): +// - --entrypoint overrides the image Entrypoint AND discards the image Cmd. +// The CLI tail then becomes the new Cmd; with no tail the command is just +// the --entrypoint. +// - Without --entrypoint, a non-empty CLI tail replaces the image Cmd while +// the image Entrypoint is kept. +// - With neither --entrypoint nor a tail, the command is image Entrypoint + +// image Cmd. +// +// Env: +// - --clear-env starts from empty; otherwise the base is the image Env. +// - --env KEY=VAL overrides any existing KEY and appends if new. +// - --env KEY (bare) inherits KEY from the host environ (resolved here). +// - a PATH is guaranteed: when the merged result carries none, Docker's +// conventional default is appended (defaultGuestPath). +// +// WorkingDir: --workdir, else image WorkingDir, else "/". +// User: --user, else image User, resolved to numeric uid:gid against the +// rootfs /etc/passwd and /etc/group. +func computeRunSpec(cfg *v1.ConfigFile, rf runFlags, rootfs string, tail []string) (*runSpec, error) { + args := resolveArgs(cfg.Config.Entrypoint, cfg.Config.Cmd, rf.entrypoint, tail) + if len(args) == 0 { + return nil, fmt.Errorf("no command: image has no Entrypoint/Cmd and none given") + } + + env := resolveEnv(cfg.Config.Env, rf.env, rf.clearEnv) + if !slices.ContainsFunc(env, func(kv string) bool { + return strings.HasPrefix(kv, "PATH=") + }) { + env = append(env, "PATH="+defaultGuestPath) + } + + workdir := rf.workdir + if workdir == "" { + workdir = cfg.Config.WorkingDir + } + if workdir == "" { + workdir = "/" + } + if !filepath.IsAbs(workdir) { + return nil, fmt.Errorf("workdir %q is not guest-absolute", workdir) + } + + // Docker resolves a relative path command (one containing a slash, like + // "./server") against the working directory and a bare name against the + // merged PATH. elfuse resolves the initial ELF before elfuse_launch + // chdirs to --workdir and performs no PATH lookup, so both happen here: + // WorkingDir /app with Entrypoint ./server must load /app/server (not + // ./server relative to wherever the user invoked elfuse-oci), and an + // image Cmd of ["node"] must resolve inside the rootfs via PATH. + switch { + case filepath.IsAbs(args[0]): + case strings.Contains(args[0], "/"): + args[0] = filepath.Join(workdir, args[0]) + default: + resolved, err := lookPathInRootfs(rootfs, envValue(env, "PATH"), args[0]) + if err != nil { + return nil, err + } + args[0] = resolved + } + + user := rf.user + if user == "" { + user = cfg.Config.User + } + uid, gid, err := resolveUser(rootfs, user) + if err != nil { + return nil, err + } + + return &runSpec{ + Args: args, + Env: env, + Workdir: workdir, + UID: uid, + GID: gid, + }, nil +} + +// resolveArgs implements the Entrypoint/Cmd precedence described above. +func resolveArgs(imgEntry, imgCmd []string, cliEntry string, tail []string) []string { + if cliEntry != "" { + // --entrypoint clobbers image Entrypoint and image Cmd. The CLI tail, + // if any, is the new Cmd. + return append([]string{cliEntry}, tail...) + } + if len(tail) > 0 { + // CLI args replace image Cmd, keep image Entrypoint. + return slices.Concat(imgEntry, tail) + } + // No --entrypoint, no tail: image Entrypoint + image Cmd. + return slices.Concat(imgEntry, imgCmd) +} + +// resolveEnv builds the final environment list. +func resolveEnv(imgEnv []string, overrides []string, clearEnv bool) []string { + var out []string + seen := map[string]int{} + set := func(k, v string) { + if idx, ok := seen[k]; ok { + out[idx] = k + "=" + v + return + } + seen[k] = len(out) + out = append(out, k+"="+v) + } + if !clearEnv { + for _, kv := range imgEnv { + // Drop empty-key entries ("=VAL") instead of forwarding them: + // elfuse rejects --env with an empty variable name, and an image + // carrying such an entry still starts under Docker. + if k, v, ok := strings.Cut(kv, "="); ok && k != "" { + set(k, v) + } + } + } + for _, e := range overrides { + if k, v, ok := strings.Cut(e, "="); ok { + set(k, v) + continue + } + // Bare KEY: inherit from the host environ, or skip if unset. + if v, ok := os.LookupEnv(e); ok { + set(e, v) + } + } + return out +} + +// resolveUser resolves a user spec ("uid", "uid:gid", "name", "name:group") +// to numeric uid:gid against the rootfs /etc/passwd and /etc/group. A bare +// numeric uid defaults gid to uid (matching elfuse's --user convention). +// "root" resolves through /etc/passwd like any other name (its gid can +// differ from 0), but falls back to 0:0 when the rootfs has no usable +// passwd entry for it: root's uid is 0 by definition, so FROM scratch-style +// images with USER root keep working. An explicit ":group" part is still +// resolved normally. +func resolveUser(rootfs, spec string) (uint32, uint32, error) { + if spec == "" { + return 0, 0, nil + } + userPart, groupPart, _ := strings.Cut(spec, ":") + + uid, puidGid, err := resolveUserPart(rootfs, userPart) + if err != nil { + if userPart != "root" { + return 0, 0, err + } + // No readable /etc/passwd or no root entry: uid 0 by definition, + // gid 0 as the only sane default. + uid, puidGid = 0, 0 + } + var gid uint32 + switch { + case groupPart == "": + gid = puidGid // passwd gid, or == uid for bare numeric + case isAllDigits(groupPart): + g, err := strconv.ParseUint(groupPart, 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("invalid gid %q: %w", groupPart, err) + } + gid = uint32(g) + default: + g, err := lookupGroup(rootfs, groupPart) + if err != nil { + return 0, 0, err + } + gid = g + } + return uid, gid, nil +} + +// resolveUserPart resolves the user component to (uid, defaultGid). For a +// numeric uid the default gid is the uid itself; for a name it is the gid +// field of the matching /etc/passwd entry. +func resolveUserPart(rootfs, part string) (uint32, uint32, error) { + if isAllDigits(part) { + u, err := strconv.ParseUint(part, 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("invalid uid %q: %w", part, err) + } + uid := uint32(u) + return uid, uid, nil + } + return lookupPasswd(rootfs, part) +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + +// envValue returns the value of key in a resolved KEY=VALUE environment +// slice, or "" when absent. resolveEnv dedups keys, so the first match is +// the only one. +func envValue(env []string, key string) string { + for _, kv := range env { + if v, ok := strings.CutPrefix(kv, key+"="); ok { + return v + } + } + return "" +} + +// lookPathInRootfs resolves a bare command name against the merged guest PATH +// inside the image rootfs, as Docker does for exec-form commands. Candidates +// resolve through os.Root so image symlinks stay confined to the rootfs; a +// match must be a regular file with an execute bit. Relative or empty PATH +// entries are skipped: they would be cwd-relative at exec time, which the +// launcher cannot honor. +func lookPathInRootfs(rootfs, pathList, name string) (string, error) { + root, err := os.OpenRoot(rootfs) + if err != nil { + return "", err + } + defer root.Close() + for dir := range strings.SplitSeq(pathList, ":") { + if !filepath.IsAbs(dir) { + continue + } + guest := filepath.Join(dir, name) + st, err := root.Stat(strings.TrimPrefix(guest, "/")) + if err != nil || !st.Mode().IsRegular() || st.Mode()&0o111 == 0 { + continue + } + return guest, nil + } + return "", fmt.Errorf("%q: executable file not found in image PATH", name) +} + +// openInRootfs opens a rootfs-relative path via os.Root so an +// image-controlled symlink (e.g. etc/passwd -> /etc/passwd) cannot redirect +// the read to host files outside the rootfs. The returned file stays valid +// after the root handle is closed. +func openInRootfs(rootfs, name string) (*os.File, error) { + root, err := os.OpenRoot(rootfs) + if err != nil { + return nil, err + } + defer root.Close() + return root.Open(name) +} + +// findColonEntry scans the colon-separated database / (e.g. +// etc/passwd) for the line whose first field is name and has at least +// minFields fields, returning the fields. Errors name the file guest-absolute +// so callers only add their own context prefix. +func findColonEntry(rootfs, file, name string, minFields int) ([]string, error) { + f, err := openInRootfs(rootfs, file) + if err != nil { + return nil, fmt.Errorf("open /%s: %w", file, err) + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Split(sc.Text(), ":") + if len(fields) >= minFields && fields[0] == name { + return fields, nil + } + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("scan /%s: %w", file, err) + } + return nil, fmt.Errorf("not found in /%s", file) +} + +// lookupPasswd finds name in /etc/passwd, returning (uid, gid). +func lookupPasswd(rootfs, name string) (uint32, uint32, error) { + fields, err := findColonEntry(rootfs, "etc/passwd", name, 4) + if err != nil { + return 0, 0, fmt.Errorf("resolve user %q: %w", name, err) + } + uid, err := strconv.ParseUint(fields[2], 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("resolve user %q: bad uid in /etc/passwd: %w", name, err) + } + gid, err := strconv.ParseUint(fields[3], 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("resolve user %q: bad gid in /etc/passwd: %w", name, err) + } + return uint32(uid), uint32(gid), nil +} + +// lookupGroup finds name in /etc/group, returning gid. +func lookupGroup(rootfs, name string) (uint32, error) { + fields, err := findColonEntry(rootfs, "etc/group", name, 3) + if err != nil { + return 0, fmt.Errorf("resolve group %q: %w", name, err) + } + gid, err := strconv.ParseUint(fields[2], 10, 32) + if err != nil { + return 0, fmt.Errorf("resolve group %q: bad gid in /etc/group: %w", name, err) + } + return uint32(gid), nil +} diff --git a/cmd/elfuse-oci/runspec_test.go b/cmd/elfuse-oci/runspec_test.go new file mode 100644 index 00000000..f60ef3e2 --- /dev/null +++ b/cmd/elfuse-oci/runspec_test.go @@ -0,0 +1,455 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/v1" +) + +func TestResolveArgs(t *testing.T) { + cases := []struct { + name string + imgEntry, imgCmd []string + cliEntry string + tail []string + want []string + }{ + {"image entry+cmd, no overrides", []string{"/ep"}, []string{"-c"}, "", nil, []string{"/ep", "-c"}}, + {"tail replaces cmd, keeps entry", []string{"/ep"}, []string{"-c"}, "", []string{"-x"}, []string{"/ep", "-x"}}, + {"--entrypoint clobbers entry+cmd, no tail", []string{"/ep"}, []string{"-c"}, "/new", nil, []string{"/new"}}, + {"--entrypoint + tail", []string{"/ep"}, []string{"-c"}, "/new", []string{"-x"}, []string{"/new", "-x"}}, + {"no entrypoint, image cmd", nil, []string{"/bin/sh"}, "", nil, []string{"/bin/sh"}}, + {"no entrypoint, tail replaces cmd", nil, []string{"/bin/sh"}, "", []string{"/bin/echo", "hi"}, []string{"/bin/echo", "hi"}}, + {"nothing at all", nil, nil, "", nil, nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := resolveArgs(c.imgEntry, c.imgCmd, c.cliEntry, c.tail) + if !reflect.DeepEqual(got, c.want) { + t.Errorf("resolveArgs: got %v, want %v", got, c.want) + } + }) + } +} + +func TestResolveEnv(t *testing.T) { + t.Setenv("ELFUSE_TEST_HOST", "from-host") + cases := []struct { + name string + imgEnv []string + overrides []string + clearEnv bool + want []string + }{ + {"image env only", []string{"A=1", "B=2"}, nil, false, []string{"A=1", "B=2"}}, + {"override existing", []string{"A=1"}, []string{"A=9"}, false, []string{"A=9"}}, + {"append new", []string{"A=1"}, []string{"B=2"}, false, []string{"A=1", "B=2"}}, + {"clear-env drops image env", []string{"A=1"}, []string{"B=2"}, true, []string{"B=2"}}, + {"bare KEY inherits host", []string{"A=1"}, []string{"ELFUSE_TEST_HOST"}, false, []string{"A=1", "ELFUSE_TEST_HOST=from-host"}}, + {"bare KEY unset on host is skipped", []string{"A=1"}, []string{"DEFINITELY_UNSET_XYZ"}, false, []string{"A=1"}}, + {"empty-key image entry dropped", []string{"=1", "A=2"}, nil, false, []string{"A=2"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := resolveEnv(c.imgEnv, c.overrides, c.clearEnv) + if !reflect.DeepEqual(got, c.want) { + t.Errorf("resolveEnv: got %v, want %v", got, c.want) + } + }) + } +} + +func TestResolveUser(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte( + "root:x:0:0:root:/root:/bin/sh\nbin:x:1:1:bin:/bin:/sbin/nologin\nnobody:x:65534:65534:nobody:/:/sbin/nologin\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte( + "root:x:0:\nbin:x:1:\nstaff:x:20:\n"), 0o644); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + spec string + wantUID uint32 + wantGID uint32 + wantErr bool + }{ + {"empty is root", "", 0, 0, false}, + {"root name", "root", 0, 0, false}, + {"bare numeric uid defaults gid=uid", "1000", 1000, 1000, false}, + {"numeric uid:gid", "1000:20", 1000, 20, false}, + {"name from passwd", "bin", 1, 1, false}, + {"name:group", "bin:staff", 1, 20, false}, + {"name:numeric gid", "bin:99", 1, 99, false}, + {"unknown user errors", "ghost", 0, 0, true}, + {"unknown group errors", "bin:ghost", 0, 0, true}, + {"root:group resolves the group part", "root:staff", 0, 20, false}, + {"root with unknown group errors", "root:ghost", 0, 0, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + uid, gid, err := resolveUser(root, c.spec) + if (err != nil) != c.wantErr { + t.Fatalf("resolveUser(%q): err=%v, wantErr=%v", c.spec, err, c.wantErr) + } + if c.wantErr { + return + } + if uid != c.wantUID || gid != c.wantGID { + t.Errorf("resolveUser(%q): uid=%d gid=%d, want %d:%d", c.spec, uid, gid, c.wantUID, c.wantGID) + } + }) + } +} + +// TestResolveUserRootGidFromPasswd pins that "root" resolves through +// /etc/passwd like any other name: a root entry with a non-zero gid wins +// over the 0:0 fallback. +func TestResolveUserRootGidFromPasswd(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte( + "root:x:0:50:root:/root:/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + uid, gid, err := resolveUser(root, "root") + if err != nil { + t.Fatalf("resolveUser(root): %v", err) + } + if uid != 0 || gid != 50 { + t.Errorf("resolveUser(root): uid=%d gid=%d, want 0:50", uid, gid) + } +} + +// TestResolveUserRootWithoutPasswd pins the FROM scratch fallback: with no +// readable /etc/passwd (or one lacking a root entry), "root" must resolve +// to 0:0 instead of erroring. +func TestResolveUserRootWithoutPasswd(t *testing.T) { + cases := []struct { + name string + passwd string // written to etc/passwd when non-empty + }{ + {"no passwd", ""}, + {"no root entry", "bin:x:1:1:bin:/bin:/sbin/nologin\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + if c.passwd != "" { + if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte(c.passwd), 0o644); err != nil { + t.Fatal(err) + } + } + uid, gid, err := resolveUser(root, "root") + if err != nil { + t.Fatalf("resolveUser(root): %v", err) + } + if uid != 0 || gid != 0 { + t.Errorf("resolveUser(root): uid=%d gid=%d, want 0:0", uid, gid) + } + }) + } +} + +func TestLookupPasswdScannerError(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil { + t.Fatal(err) + } + longLine := strings.Repeat("x", bufio.MaxScanTokenSize+1) + if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte(longLine), 0o644); err != nil { + t.Fatal(err) + } + _, _, err := lookupPasswd(root, "root") + if err == nil || !strings.Contains(err.Error(), "scan /etc/passwd") { + t.Fatalf("lookupPasswd err = %v, want scan /etc/passwd error", err) + } +} + +// TestLookupPasswdRejectsSymlinkEscape pins the rootfs-bounded open: an image +// whose etc/passwd is a symlink to a file outside the rootfs must not have +// user resolution read that host file. +func TestLookupPasswdRejectsSymlinkEscape(t *testing.T) { + outside := t.TempDir() + hostPasswd := filepath.Join(outside, "passwd") + if err := os.WriteFile(hostPasswd, []byte("evil:x:0:0::/:/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(hostPasswd, filepath.Join(root, "etc", "passwd")); err != nil { + t.Fatal(err) + } + if _, _, err := lookupPasswd(root, "evil"); err == nil { + t.Fatal("lookupPasswd resolved a user through a symlink escaping the rootfs") + } + + if err := os.Symlink(hostPasswd, filepath.Join(root, "etc", "group")); err != nil { + t.Fatal(err) + } + if _, err := lookupGroup(root, "evil"); err == nil { + t.Fatal("lookupGroup resolved a group through a symlink escaping the rootfs") + } +} + +func TestLookupGroupScannerError(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil { + t.Fatal(err) + } + longLine := strings.Repeat("x", bufio.MaxScanTokenSize+1) + if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte(longLine), 0o644); err != nil { + t.Fatal(err) + } + _, err := lookupGroup(root, "root") + if err == nil || !strings.Contains(err.Error(), "scan /etc/group") { + t.Fatalf("lookupGroup err = %v, want scan /etc/group error", err) + } +} + +// TestComputeRunSpecNoCommand exercises the empty-command error branch: no +// image Entrypoint/Cmd and no --entrypoint/tail yields an error. computeRunSpec +// takes a *v1.ConfigFile directly, so no real image is needed; with User empty, +// resolveUser returns 0:0 without touching /etc/passwd. +func TestComputeRunSpecNoCommand(t *testing.T) { + cfg := &v1.ConfigFile{Config: v1.Config{}} // no Entrypoint, no Cmd + if _, err := computeRunSpec(cfg, runFlags{}, t.TempDir(), nil); err == nil || + !strings.Contains(err.Error(), "no command") { + t.Fatalf("err=%v, want an error containing %q", err, "no command") + } +} + +// TestComputeRunSpecWorkdirNotAbsolute covers the non-absolute workdir error +// branch. The command check (runspec.go:73) runs before the workdir check +// (:89), so the config must carry a valid Cmd to reach it. A subtest covers +// the image-config WorkingDir path too. +func TestComputeRunSpecWorkdirNotAbsolute(t *testing.T) { + t.Run("flag workdir", func(t *testing.T) { + cfg := &v1.ConfigFile{Config: v1.Config{Cmd: []string{"/hello"}}} + rf := runFlags{workdir: "relative/path"} + _, err := computeRunSpec(cfg, rf, t.TempDir(), nil) + if err == nil || !strings.Contains(err.Error(), "not guest-absolute") { + t.Fatalf("err=%v, want an error containing %q", err, "not guest-absolute") + } + }) + t.Run("image workdir", func(t *testing.T) { + cfg := &v1.ConfigFile{Config: v1.Config{Cmd: []string{"/hello"}, WorkingDir: "rel"}} + _, err := computeRunSpec(cfg, runFlags{}, t.TempDir(), nil) + if err == nil || !strings.Contains(err.Error(), "not guest-absolute") { + t.Fatalf("err=%v, want an error containing %q", err, "not guest-absolute") + } + }) +} + +func writeUserFiles(t *testing.T, root, passwd, group string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte(passwd), 0o644); err != nil { + t.Fatal(err) + } + if group != "" { + if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte(group), 0o644); err != nil { + t.Fatal(err) + } + } +} + +// TestComputeRunSpecRelativeEntrypoint pins the Docker rules for exec-form +// commands: a relative path entrypoint (contains a slash) resolves against +// the working directory, and a bare name resolves via the merged PATH inside +// the image rootfs. elfuse resolves the initial ELF before chdiring to +// --workdir and does no PATH lookup, so both must happen in the spec. +func TestComputeRunSpecRelativeEntrypoint(t *testing.T) { + rootfs := t.TempDir() + if err := os.MkdirAll(filepath.Join(rootfs, "usr", "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootfs, "usr", "bin", "node"), []byte("#!"), 0o755); err != nil { + t.Fatal(err) + } + cases := []struct { + name string + args []string + want string + wantErr string + }{ + {"dot-relative", []string{"./server"}, "/app/server", ""}, + {"subdir-relative", []string{"bin/tool"}, "/app/bin/tool", ""}, + {"bare name via image PATH", []string{"node"}, "/usr/bin/node", ""}, + {"bare name absent", []string{"missing"}, "", "not found in image PATH"}, + {"absolute untouched", []string{"/entry"}, "/entry", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &v1.ConfigFile{Config: v1.Config{ + Entrypoint: tc.args, + WorkingDir: "/app", + }} + spec, err := computeRunSpec(cfg, runFlags{}, rootfs, nil) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want an error containing %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if spec.Args[0] != tc.want { + t.Errorf("Args[0] = %q, want %q", spec.Args[0], tc.want) + } + }) + } +} +func TestComputeRunSpecSuccessFullPrecedence(t *testing.T) { + root := t.TempDir() + writeUserFiles(t, root, + "root:x:0:0:root:/root:/bin/sh\nbin:x:1:1:bin:/bin:/sbin/nologin\n", + "root:x:0:\nstaff:x:20:\n", + ) + cfg := &v1.ConfigFile{Config: v1.Config{ + Entrypoint: []string{"/entry"}, + Cmd: []string{"image-cmd"}, + Env: []string{"A=1", "B=2"}, + WorkingDir: "/image-workdir", + User: "root", + }} + rf := runFlags{ + env: []string{"B=9", "C=3"}, + workdir: "/flag-workdir", + user: "bin:staff", + } + spec, err := computeRunSpec(cfg, rf, root, []string{"tail-cmd", "arg"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(spec.Args, []string{"/entry", "tail-cmd", "arg"}) { + t.Fatalf("Args = %v, want entrypoint plus CLI tail", spec.Args) + } + if !reflect.DeepEqual(spec.Env, []string{"A=1", "B=9", "C=3", "PATH=" + defaultGuestPath}) { + t.Fatalf("Env = %v, want ordered override plus default PATH", spec.Env) + } + if spec.Workdir != "/flag-workdir" { + t.Fatalf("Workdir = %q, want flag workdir", spec.Workdir) + } + if spec.UID != 1 || spec.GID != 20 { + t.Fatalf("UID:GID = %d:%d, want 1:20", spec.UID, spec.GID) + } +} + +// TestComputeRunSpecDefaultPath pins the PATH guarantee: the guest always +// receives a PATH, the image's own PATH is never rewritten, and an --env +// override wins over both. +func TestComputeRunSpecDefaultPath(t *testing.T) { + cases := []struct { + name string + imgEnv []string + env []string + clearEnv bool + want []string + }{ + {"no PATH anywhere gets the default", []string{"A=1"}, nil, false, + []string{"A=1", "PATH=" + defaultGuestPath}}, + {"image PATH is preserved", []string{"PATH=/opt/bin"}, nil, false, + []string{"PATH=/opt/bin"}}, + {"--env PATH wins", []string{"PATH=/opt/bin"}, []string{"PATH=/bin"}, false, + []string{"PATH=/bin"}}, + {"--clear-env still yields a PATH", []string{"PATH=/opt/bin"}, nil, true, + []string{"PATH=" + defaultGuestPath}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := &v1.ConfigFile{Config: v1.Config{ + Cmd: []string{"/bin/true"}, + Env: c.imgEnv, + }} + rf := runFlags{env: c.env, clearEnv: c.clearEnv} + spec, err := computeRunSpec(cfg, rf, t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(spec.Env, c.want) { + t.Errorf("Env = %v, want %v", spec.Env, c.want) + } + }) + } +} + +func TestResolveArgsDoesNotMutateInputs(t *testing.T) { + entry := []string{"/entry"} + cmd := []string{"image-cmd"} + tail := []string{"tail"} + got := resolveArgs(entry, cmd, "", tail) + got[0] = "/changed" + if !reflect.DeepEqual(entry, []string{"/entry"}) { + t.Fatalf("entry mutated to %v", entry) + } + if !reflect.DeepEqual(cmd, []string{"image-cmd"}) { + t.Fatalf("cmd mutated to %v", cmd) + } + if !reflect.DeepEqual(tail, []string{"tail"}) { + t.Fatalf("tail mutated to %v", tail) + } +} + +func TestResolveEnvDuplicateOrdering(t *testing.T) { + got := resolveEnv([]string{"A=1", "B=2"}, []string{"A=3", "C=4", "B=5"}, false) + want := []string{"A=3", "B=5", "C=4"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("resolveEnv = %v, want %v", got, want) + } +} + +func TestResolveUserErrorBranches(t *testing.T) { + cases := []struct { + name string + passwd string // when empty, /etc/passwd is not written + group string // when empty, /etc/group is not written + user string + wantErr string + }{ + {"missing passwd", "", "", "bin", "open /etc/passwd"}, + {"bad passwd uid", "bin:x:not-a-uid:1:bin:/bin:/bin/sh\n", "", "bin", "bad uid"}, + {"bad passwd gid", "bin:x:1:not-a-gid:bin:/bin:/bin/sh\n", "", "bin", "bad gid"}, + {"missing group", "bin:x:1:1:bin:/bin:/bin/sh\n", "", "bin:staff", "open /etc/group"}, + {"bad group gid", "bin:x:1:1:bin:/bin:/bin/sh\n", "staff:x:not-a-gid:\n", "bin:staff", "bad gid"}, + {"numeric uid overflow", "", "", strings.Repeat("9", 20), "invalid uid"}, + {"numeric gid overflow", "", "", "1:" + strings.Repeat("9", 20), "invalid gid"}, + {"empty user part", "bin:x:1:1:bin:/bin:/bin/sh\n", "staff:x:20:\n", ":staff", `resolve user ""`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + if tc.passwd != "" { + writeUserFiles(t, root, tc.passwd, tc.group) + } + _, _, err := resolveUser(root, tc.user) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("resolveUser(%q) err = %v, want %q", tc.user, err, tc.wantErr) + } + }) + } +} diff --git a/cmd/elfuse-oci/test_helpers_test.go b/cmd/elfuse-oci/test_helpers_test.go index fcb0c7af..e07c21d0 100644 --- a/cmd/elfuse-oci/test_helpers_test.go +++ b/cmd/elfuse-oci/test_helpers_test.go @@ -6,6 +6,7 @@ package main import ( "archive/tar" "bytes" + "fmt" "io" "os" "os/exec" @@ -30,6 +31,20 @@ func TestMain(m *testing.M) { main() return } + if os.Getenv("ELFUSE_EXEC_ELFUSE_TEST") == "1" { + spec := &runSpec{ + Args: []string{"/bin/echo", "hi"}, + Env: []string{"A=1"}, + Workdir: "/", + UID: 1, + GID: 2, + } + if err := execElfuse(os.Getenv("ELFUSE_EXEC_ROOTFS"), spec); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(98) + } + os.Exit(99) + } os.Exit(m.Run()) } From 0d7afdf3d3228b3fe413c3d87f7e214d3ee7e6d1 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 15 Jul 2026 23:24:37 +0200 Subject: [PATCH 17/22] Add macOS sparsebundle rootfs with COW clones A plain directory rootfs on the default case-insensitive APFS volume folds Linux filenames that differ only by case. Default runs now use a case-sensitive APFS sparsebundle per pinned manifest digest, with a per-run clonefile COW rootfs so guest writes never mutate the warm base tree and repeated runs skip the unpack. Liveness and lifecycle transitions are decided by per-digest advisory flocks in the bundle directory (bundlelock.go), not by pids or directory scans: every live run holds run.lock shared from before the volume is attached until guest exit, so a killed run cannot leak liveness, and attach.lock serializes provisioning (always acquired before run.lock, making the exclusive-to-shared downgrade in provision race-free). Provisioning reuses a mount a live run still holds and only force-detaches one proven stale by an exclusive run.lock probe, so a second run of the same digest can never rip the rootfs out from under a live guest. --keep clones carry an .elfuse-keep marker so sweeps preserve them. The plain-rootfs path remains available with --plain-rootfs, and the non-Darwin stub keeps elfuse-oci buildable for pull/inspect/unpack tests on Linux. Darwin tests drive runCaseSensitive through seams for provisioning, clonefile, spawn, cleanup, and exit, and cover sparsebundle provisioning, mount/detach, attach-failure teardown, and the lock protocol with a fake hdiutil; the mount-probe and force-detach hooks are function variables so tests need no real disk images. The spawn path intercepts SIGHUP alongside INT/TERM/QUIT and installs the handler before the child starts, so a hangup or an early signal still flows through the forward/reap/teardown path. elfuse is invoked with a "--" separator before the guest command, so an image Entrypoint beginning with "-" cannot steer the host launcher. hdiutil attach failures keep stderr in the error message (stdout stays clean for the plist parse), and the parsed mount path is XML-entity-decoded so a store path containing "&" or quotes still round-trips. --- cmd/elfuse-oci/bundlelock.go | 150 ++++++++++ cmd/elfuse-oci/bundlelock_test.go | 140 ++++++++++ cmd/elfuse-oci/commands.go | 41 ++- cmd/elfuse-oci/common_test.go | 7 + cmd/elfuse-oci/csrun.go | 171 ++++++++++++ cmd/elfuse-oci/csrun_darwin_test.go | 364 ++++++++++++++++++++++++ cmd/elfuse-oci/csrun_other.go | 22 ++ cmd/elfuse-oci/main.go | 9 + cmd/elfuse-oci/run.go | 109 ++++++-- cmd/elfuse-oci/run_test.go | 110 ++++++++ cmd/elfuse-oci/runspec.go | 6 + cmd/elfuse-oci/sparsebundle.go | 313 +++++++++++++++++++++ cmd/elfuse-oci/sparsebundle_test.go | 412 ++++++++++++++++++++++++++++ cmd/elfuse-oci/store.go | 14 +- go.mod | 6 +- 15 files changed, 1833 insertions(+), 41 deletions(-) create mode 100644 cmd/elfuse-oci/bundlelock.go create mode 100644 cmd/elfuse-oci/bundlelock_test.go create mode 100644 cmd/elfuse-oci/csrun.go create mode 100644 cmd/elfuse-oci/csrun_darwin_test.go create mode 100644 cmd/elfuse-oci/csrun_other.go create mode 100644 cmd/elfuse-oci/sparsebundle.go create mode 100644 cmd/elfuse-oci/sparsebundle_test.go diff --git a/cmd/elfuse-oci/bundlelock.go b/cmd/elfuse-oci/bundlelock.go new file mode 100644 index 00000000..0d314ffd --- /dev/null +++ b/cmd/elfuse-oci/bundlelock.go @@ -0,0 +1,150 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "syscall" +) + +// Per-digest bundle locks. +// +// A case-sensitive sparsebundle bundle (/cs///) is shared +// mutable state: concurrent `run`s of one digest share its attached volume, +// while prune --cache and rmi --force want to detach and remove it. Liveness +// is decided by advisory flocks, not by pids or directory scans: a held +// lock proves a live holder regardless of pid reuse, and a free lock proves +// the holder is gone regardless of what the directory contains. +// +// Two lock files live in the bundle directory, deliberately OUTSIDE the +// mounted volume: hdiutil detach -force revokes descriptors inside the +// volume, which would silently drop a lock held there, and the locks must be +// probeable while the volume is not attached at all. +// +// - attach.lock: exclusive, serializes bundle lifecycle transitions. A run +// holds it (blocking) across provisioning (stale-mount recovery, +// hdiutil create/attach) and the last-one-out detach; a sweep holds it +// (non-blocking) for its whole reap-detach-remove sequence. +// - run.lock: every live run holds it shared from before the volume is +// attached until the guest exits (the process exiting releases it, so a +// killed run cannot leak liveness). Anyone holding it exclusively has +// proven there are zero live runs: sweeps take it non-blocking (busy => +// skip the bundle), and provision probes it to tell a stale leftover +// mount from one that is live. +// +// Lock ordering: attach.lock is always acquired before run.lock is taken +// exclusively. That makes the EX->SH downgrade in provision safe (flock +// downgrades by release-and-reacquire, but no EX taker can slip in without +// attach.lock, which the downgrader holds) and rules out lock-order cycles +// with the store-level .lock, which prune/rmi already hold around the sweep +// while runs never take bundle locks under the store lock. + +// errCacheBusy reports that a bundle lock is held by a live run (or an +// in-flight provision), so the caller must not detach or remove the bundle. +var errCacheBusy = errors.New("in use by a live run") + +func attachLockPath(bundle string) string { return filepath.Join(bundle, "attach.lock") } +func runLockPath(bundle string) string { return filepath.Join(bundle, "run.lock") } + +// flockFile is an open file holding (or having held) an advisory flock. +type flockFile struct { + f *os.File +} + +// acquireFlock opens path (creating it if absent) and takes the flock mode +// `how` (syscall.LOCK_SH or LOCK_EX, optionally |LOCK_NB). A non-blocking +// request that loses returns errCacheBusy (wrapped with the path). +// +// A sweeper removes the whole bundle directory, lock files included, +// while holding both locks. A racing acquirer may then have opened the path +// just before the unlink and be holding a lock on an orphaned inode no later +// process can observe. Guard against that: after locking, verify the path +// still resolves to the locked inode; otherwise retry against the recreated +// file. The retry count is a defense bound, not a correctness knob; one +// retry per concurrent unlink is the worst case. +func acquireFlock(path string, how int) (*flockFile, error) { + for range 16 { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, err + } + if err := flockRetryIntr(int(f.Fd()), how); err != nil { + f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return nil, fmt.Errorf("%s: %w", path, errCacheBusy) + } + return nil, fmt.Errorf("lock %s: %w", path, err) + } + var pathSt, fdSt syscall.Stat_t + if err := syscall.Stat(path, &pathSt); err != nil { + f.Close() + if errors.Is(err, syscall.ENOENT) { + continue // unlinked under us; retry on the recreated file + } + return nil, fmt.Errorf("lock %s: %w", path, err) + } + if err := syscall.Fstat(int(f.Fd()), &fdSt); err != nil { + f.Close() + return nil, fmt.Errorf("lock %s: %w", path, err) + } + if pathSt.Dev == fdSt.Dev && pathSt.Ino == fdSt.Ino { + return &flockFile{f: f}, nil + } + f.Close() // path now names a different file; lock that one instead + } + return nil, fmt.Errorf("lock %s: persistent unlink race", path) +} + +// flockRetryIntr issues flock, retrying on EINTR (a blocking acquisition may +// be interrupted by the signal forwarding the run wrapper installs). +func flockRetryIntr(fd, how int) error { + for { + err := syscall.Flock(fd, how) + if !errors.Is(err, syscall.EINTR) { + return err + } + } +} + +// Downgrade converts a held exclusive lock to shared. flock implements this +// as release-then-reacquire, so it is race-free only while the caller holds +// attach.lock: every exclusive taker of run.lock acquires attach.lock first, +// so none can slip into the gap. +func (l *flockFile) Downgrade() error { + return flockRetryIntr(int(l.f.Fd()), syscall.LOCK_SH) +} + +// Close releases the lock and closes the file. Safe on nil and after a prior +// Close. +func (l *flockFile) Close() error { + if l == nil || l.f == nil { + return nil + } + _ = syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN) + err := l.f.Close() + l.f = nil + return err +} + +// acquireAttachLock takes the exclusive attach.lock for bundleDir, recreating +// the directory when a concurrent sweep removed it. A prune --cache --all can +// win both bundle locks and RemoveAll the whole bundle dir (lock files +// included) while a provisioning run is blocked on attach.lock; that run then +// wakes on the orphaned lock inode, retries, and finds the open of the lock +// path failing with ENOENT on the vanished parent. Recreating the dir and +// retrying lets the run re-provision the swept bundle from scratch instead of +// failing; a few tries bound the pathological case of back-to-back sweeps. +func acquireAttachLock(bundleDir string) (*flockFile, error) { + lock, err := acquireFlock(attachLockPath(bundleDir), syscall.LOCK_EX) + for tries := 0; errors.Is(err, syscall.ENOENT) && tries < 4; tries++ { + if mkErr := os.MkdirAll(bundleDir, 0o755); mkErr != nil { + return nil, mkErr + } + lock, err = acquireFlock(attachLockPath(bundleDir), syscall.LOCK_EX) + } + return lock, err +} diff --git a/cmd/elfuse-oci/bundlelock_test.go b/cmd/elfuse-oci/bundlelock_test.go new file mode 100644 index 00000000..05ee7451 --- /dev/null +++ b/cmd/elfuse-oci/bundlelock_test.go @@ -0,0 +1,140 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestAcquireFlockSharedCoexists(t *testing.T) { + path := filepath.Join(t.TempDir(), "run.lock") + a, err := acquireFlock(path, syscall.LOCK_SH) + if err != nil { + t.Fatal(err) + } + defer a.Close() + b, err := acquireFlock(path, syscall.LOCK_SH|syscall.LOCK_NB) + if err != nil { + t.Fatalf("second shared lock: %v, want success", err) + } + defer b.Close() +} + +func TestAcquireFlockExclusiveBlockedIsCacheBusy(t *testing.T) { + path := filepath.Join(t.TempDir(), "run.lock") + a, err := acquireFlock(path, syscall.LOCK_SH) + if err != nil { + t.Fatal(err) + } + defer a.Close() + _, err = acquireFlock(path, syscall.LOCK_EX|syscall.LOCK_NB) + if !errors.Is(err, errCacheBusy) { + t.Fatalf("exclusive over shared err = %v, want errCacheBusy", err) + } + + // Releasing the shared lock frees the exclusive probe. + if err := a.Close(); err != nil { + t.Fatal(err) + } + b, err := acquireFlock(path, syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + t.Fatalf("exclusive after release: %v, want success", err) + } + defer b.Close() +} + +func TestFlockDowngradeAdmitsSharedBlocksExclusive(t *testing.T) { + path := filepath.Join(t.TempDir(), "run.lock") + a, err := acquireFlock(path, syscall.LOCK_EX) + if err != nil { + t.Fatal(err) + } + defer a.Close() + if _, err := acquireFlock(path, syscall.LOCK_SH|syscall.LOCK_NB); !errors.Is(err, errCacheBusy) { + t.Fatalf("shared over exclusive err = %v, want errCacheBusy", err) + } + if err := a.Downgrade(); err != nil { + t.Fatalf("Downgrade: %v", err) + } + b, err := acquireFlock(path, syscall.LOCK_SH|syscall.LOCK_NB) + if err != nil { + t.Fatalf("shared after downgrade: %v, want success", err) + } + defer b.Close() + if _, err := acquireFlock(path, syscall.LOCK_EX|syscall.LOCK_NB); !errors.Is(err, errCacheBusy) { + t.Fatalf("exclusive after downgrade err = %v, want errCacheBusy", err) + } +} + +// TestAcquireFlockUnlinkRace pins the verify-retry: when a sweeper unlinks +// the lock file while another process still holds a lock on the orphaned +// inode, a fresh acquire must land on the recreated file, not block on or +// share fate with the orphan. +func TestAcquireFlockUnlinkRace(t *testing.T) { + path := filepath.Join(t.TempDir(), "run.lock") + orphan, err := acquireFlock(path, syscall.LOCK_EX) + if err != nil { + t.Fatal(err) + } + defer orphan.Close() + // Simulate the sweeper's RemoveAll of the bundle: the path is gone while + // the orphan's lock is still held on the old inode. + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + fresh, err := acquireFlock(path, syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + t.Fatalf("acquire after unlink: %v, want success on recreated file", err) + } + defer fresh.Close() +} + +func TestFlockCloseIdempotentAndNilSafe(t *testing.T) { + path := filepath.Join(t.TempDir(), "run.lock") + a, err := acquireFlock(path, syscall.LOCK_EX) + if err != nil { + t.Fatal(err) + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + if err := a.Close(); err != nil { + t.Fatalf("second Close: %v, want nil", err) + } + var nilLock *flockFile + if err := nilLock.Close(); err != nil { + t.Fatalf("nil Close: %v, want nil", err) + } +} + +func TestBundleLockPaths(t *testing.T) { + if got := attachLockPath("/store/cs/sha256/ab"); got != "/store/cs/sha256/ab/attach.lock" { + t.Fatalf("attachLockPath = %q", got) + } + if got := runLockPath("/store/cs/sha256/ab"); got != "/store/cs/sha256/ab/run.lock" { + t.Fatalf("runLockPath = %q", got) + } +} + +// TestAcquireAttachLockRecreatesSweptBundleDir pins the provision-vs-sweep +// race recovery: when a prune --cache --all removed the whole bundle dir +// after provision's MkdirAll, the attach.lock open fails with ENOENT on the +// vanished parent. acquireAttachLock must recreate the dir and take the lock +// so the run re-provisions instead of failing. +func TestAcquireAttachLockRecreatesSweptBundleDir(t *testing.T) { + bundle := filepath.Join(t.TempDir(), "sha256-deadbeef") + // The dir is deliberately never created: this is the post-sweep state. + lock, err := acquireAttachLock(bundle) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if _, err := os.Stat(attachLockPath(bundle)); err != nil { + t.Fatalf("attach.lock not recreated in swept bundle dir: %v", err) + } +} diff --git a/cmd/elfuse-oci/commands.go b/cmd/elfuse-oci/commands.go index c4ee195d..54539a4f 100644 --- a/cmd/elfuse-oci/commands.go +++ b/cmd/elfuse-oci/commands.go @@ -11,7 +11,7 @@ import ( // The four subcommands share common-flag parsing (common.go) and the OCI // image-layout store (store.go). pull/unpack/inspect are pure store ops; run -// additionally resolves the runspec and execs elfuse (run.go). +// additionally resolves the runspec and execs elfuse (run.go, csrun.go). // cmdPull implements `elfuse-oci pull [--store] [--platform] `. func cmdPull(args []string) error { @@ -68,7 +68,7 @@ func parseUnpackArgs(args []string) (commonFlags, string, string, error) { var cf commonFlags var rootfs string fs := newCommandFlagSet("unpack", &cf) - fs.StringVar(&rootfs, "rootfs", "", "") + fs.StringVar(&rootfs, "rootfs", "", "unpack into DIR (default: the store's digest-keyed rootfs cache)") if err := fs.Parse(args); err != nil { return cf, "", "", err } @@ -93,7 +93,7 @@ func parseInspectArgs(args []string) (commonFlags, bool, string, error) { var cf commonFlags var asJSON bool fs := newCommandFlagSet("inspect", &cf) - fs.BoolVar(&asJSON, "json", false, "") + fs.BoolVar(&asJSON, "json", false, "print the raw image config JSON") if err := fs.Parse(args); err != nil { return cf, false, "", err } @@ -153,13 +153,26 @@ func cmdRun(args []string) error { if err != nil { return err } + digestStr := digest.String() + + // Choose the rootfs path. Default: a case-sensitive APFS sparsebundle so + // the guest's case-sensitive filenames don't collide on the host's + // case-insensitive volume, with a per-run COW clone for isolation and + // warm-run speed. --plain-rootfs (or an explicit --rootfs) opts out to the + // plain-directory path (syscall.Exec, no mount lifecycle). + useCS := rf.rootfs == "" && !rf.plainRootfs + + if useCS { + return runCaseSensitive(cf, s, ref, digestStr, cfg, rf, tail) + } + + // Plain-directory path. if rf.rootfs == "" { - rf.rootfs, err = defaultRootfsForDigest(cf.store, digest.String()) + rf.rootfs, err = defaultRootfsForDigest(cf.store, digestStr) if err != nil { return err } } - // Ensure the rootfs is unpacked before computing the spec, because // resolveUser reads /etc/passwd and /etc/group. Re-unpack only if // absent; a stale rootfs is the user's concern (run `unpack` to refresh). @@ -172,7 +185,6 @@ func cmdRun(args []string) error { return err } } - spec, err := computeRunSpec(cfg, rf, rf.rootfs, tail) if err != nil { return err @@ -184,7 +196,6 @@ func cmdRun(args []string) error { if err := injectRuntimeFiles(rf.rootfs); err != nil { return err } - return execElfuseForRun(rf.rootfs, spec) } @@ -193,12 +204,16 @@ func parseRunArgs(args []string) (commonFlags, runFlags, string, []string, error var rf runFlags var env repeatedStringFlag fs := newCommandFlagSet("run", &cf) - fs.StringVar(&rf.entrypoint, "entrypoint", "", "") - fs.Var(&env, "env", "") - fs.BoolVar(&rf.clearEnv, "clear-env", false, "") - fs.StringVar(&rf.user, "user", "", "") - fs.StringVar(&rf.workdir, "workdir", "", "") - fs.StringVar(&rf.rootfs, "rootfs", "", "") + fs.StringVar(&rf.entrypoint, "entrypoint", "", "override the image Entrypoint (drops the image Cmd)") + fs.Var(&env, "env", "set a guest env var KEY=VAL (repeatable; bare KEY inherits from the host)") + fs.BoolVar(&rf.clearEnv, "clear-env", false, "start the guest env empty (only --env applies)") + fs.StringVar(&rf.user, "user", "", "run as UID[:GID] or name[:group] resolved via the image /etc/passwd,group") + fs.StringVar(&rf.workdir, "workdir", "", "guest-absolute initial working directory") + fs.StringVar(&rf.rootfs, "rootfs", "", "use an explicit rootfs directory (plain dir, no sparsebundle)") + fs.BoolVar(&rf.plainRootfs, "plain-rootfs", false, "use a plain directory rootfs instead of the macOS sparsebundle") + fs.StringVar(&rf.sparseSize, "sparse-size", "", "sparsebundle virtual size (default 16g; macOS only)") + fs.BoolVar(&rf.noClone, "no-clone", false, "run against the base tree without a per-run COW clone (macOS only)") + fs.BoolVar(&rf.keepRootfs, "keep", false, "keep the per-run COW clone and mount for inspection (macOS only)") if err := fs.Parse(args); err != nil { return cf, rf, "", nil, err } diff --git a/cmd/elfuse-oci/common_test.go b/cmd/elfuse-oci/common_test.go index 73dcee99..a65d120d 100644 --- a/cmd/elfuse-oci/common_test.go +++ b/cmd/elfuse-oci/common_test.go @@ -99,6 +99,10 @@ func TestParseRunArgs(t *testing.T) { "--user", "1000:1000", "--workdir", "/work", "--rootfs", "/tmp/rootfs", + "--plain-rootfs", + "--sparse-size", "32g", + "--no-clone", + "--keep", "alpine:3", "-c", "echo hi", }) @@ -117,6 +121,9 @@ func TestParseRunArgs(t *testing.T) { if rf.entrypoint != "/bin/sh" || rf.user != "1000:1000" || rf.workdir != "/work" || rf.rootfs != "/tmp/rootfs" { t.Errorf("run flags = %+v", rf) } + if !rf.plainRootfs || rf.sparseSize != "32g" || !rf.noClone || !rf.keepRootfs { + t.Errorf("sparse run flags = %+v", rf) + } if !rf.clearEnv { t.Error("clearEnv = false, want true") } diff --git a/cmd/elfuse-oci/csrun.go b/cmd/elfuse-oci/csrun.go new file mode 100644 index 00000000..80a0710d --- /dev/null +++ b/cmd/elfuse-oci/csrun.go @@ -0,0 +1,171 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/google/go-containerregistry/pkg/v1" + "golang.org/x/sys/unix" +) + +var ( + ensureCaseSensitiveRootfsForRun = ensureCaseSensitiveRootfs + clonefileForRun = unix.Clonefile + spawnElfuseWaitForRun = spawnElfuseWait + cleanupCloneAndMountForRun = cleanupCloneAndMount + osExitForRun = os.Exit + runNowUnixNano = func() int64 { return time.Now().UnixNano() } +) + +// csBundleDirForDigest is /cs//: it holds the case-sensitive +// sparsebundle image and the attach mount point for one pinned manifest digest. +func csBundleDirForDigest(store, digest string) (string, error) { + key, err := cacheKeyForDigest(digest) + if err != nil { + return "", err + } + return filepath.Join(store, csCacheDirName, key), nil +} + +// ensureCaseSensitiveRootfs provisions (creating if absent) and attaches a +// case-sensitive APFS sparsebundle for ref, unpacking the image's layers into +// /rootfs when that base tree is absent. It returns the attached mount +// (the caller must Close it to detach) and the rootfs path to use as --sysroot. +// +// The unpacked base tree persists in the sparsebundle image file across +// attach/detach cycles, so warm re-runs skip the (slow) unpack and pay only the +// attach. +func ensureCaseSensitiveRootfs(cf commonFlags, s *store, ref, digest, size string) (*csMount, string, error) { + bundle, err := csBundleDirForDigest(cf.store, digest) + if err != nil { + return nil, "", err + } + mountPath := filepath.Join(bundle, "mnt") + m, err := provisionCaseSensitive(bundle, mountPath, size) + if err != nil { + return nil, "", err + } + rootfs := m.rootfsDir() + if _, err := os.Stat(rootfs); err != nil { + if !os.IsNotExist(err) { + return nil, "", errors.Join(err, closeMount(m)) + } + fmt.Fprintf(os.Stderr, "Unpacking %s -> %s\n", ref, rootfs) + if err := unpackImage(s, ref, rootfs); err != nil { + return nil, "", errors.Join(err, closeMount(m)) + } + } + return m, rootfs, nil +} + +// runCaseSensitive is the default `run` path: attach the digest-keyed +// case-sensitive sparsebundle, make a per-run COW clone of the warm base tree, +// exec elfuse against the clone, then tear the clone down and detach. On the +// happy path it does not return: it os.Exits with elfuse's status. It returns +// an error on setup failure, and on a post-run cleanup failure after a guest +// exit of zero; when the guest exits nonzero, the cleanup error is only +// printed and the guest status still wins (os.Exit), so a guest failure code +// is never masked by teardown. +// +// The clone lives in the same APFS volume as the base tree (clonefile is +// intra-volume only), so it is instant and free until the guest writes (COW). +// It isolates each run's mutations from the warm base, so re-runs stay clean. +func runCaseSensitive(cf commonFlags, s *store, ref, digest string, cfg *v1.ConfigFile, rf runFlags, tail []string) error { + m, baseRootfs, err := ensureCaseSensitiveRootfsForRun(cf, s, ref, digest, rf.sparseSize) + if err != nil { + return err + } + // NOTE: we cannot defer m.Close() because os.Exit below skips defers, + // which would leak the attached sparsebundle. Close explicitly on every + // exit path. + + // Per-run COW clone. --no-clone runs against the base tree directly (mutations + // then persist into the warm tree; useful for debugging or when clonefile is + // unavailable). + sysroot := baseRootfs + var cloneDir string + if !rf.noClone { + cloneDir = filepath.Join(m.mountPath, fmt.Sprintf("run-%d-%d", os.Getpid(), runNowUnixNano())) + if err := os.RemoveAll(cloneDir); err != nil { + err = fmt.Errorf("remove stale COW clone %s: %w", cloneDir, err) + return errors.Join(err, closeMount(m)) + } + if err := clonefileForRun(baseRootfs, cloneDir, unix.CLONE_NOFOLLOW); err != nil { + err = fmt.Errorf("COW clone %s -> %s: %w", baseRootfs, cloneDir, err) + return errors.Join(err, closeMount(m)) + } + sysroot = cloneDir + } + + // Any failure past this point must tear down the clone and the mount. + fail := func(err error) error { + return errors.Join(err, cleanupCloneAndMountForRun(cloneDir, rf.keepRootfs, m)) + } + spec, err := computeRunSpec(cfg, rf, sysroot, tail) + if err != nil { + return fail(err) + } + // On the clone path sysroot is the ephemeral COW clone, so the warm base + // tree stays clean; under --no-clone sysroot is the base tree and the + // injected /etc files are overwritten in place (the user opted into + // mutating the base). + if err := prepareRootfsForRun(sysroot, spec); err != nil { + return fail(err) + } + + code, err := spawnElfuseWaitForRun(sysroot, spec) + var cleanupErr error + if rf.keepRootfs { + // --keep leaves the clone and the mount in place for inspection. The + // clone lives in the sparsebundle volume, so the mount must stay + // attached for it to be reachable on the host; a later run reattaches + // (detaching this stale mount first) and the kept clone reappears. + if cloneDir != "" { + fmt.Fprintf(os.Stderr, "kept clone: %s\n", cloneDir) + } + fmt.Fprintf(os.Stderr, "mount stays attached: %s\n", m.mountPath) + } else { + cleanupErr = cleanupCloneAndMountForRun(cloneDir, false, m) + } + if err != nil { + return errors.Join(err, cleanupErr) + } + if cleanupErr != nil { + if code != 0 { + fmt.Fprintf(os.Stderr, "elfuse-oci: cleanup after exit %d: %v\n", code, cleanupErr) + osExitForRun(code) + return nil // unreachable + } + return cleanupErr + } + osExitForRun(code) + return nil // unreachable +} + +func cleanupCloneAndMount(cloneDir string, keep bool, m *csMount) error { + return errors.Join(removeClone(cloneDir, keep), closeMount(m)) +} + +func closeMount(m *csMount) error { + if err := m.Close(); err != nil { + return fmt.Errorf("detach %s: %w", m.mountPath, err) + } + return nil +} + +// removeClone deletes the ephemeral COW clone unless --keep was requested or +// there is none (the --no-clone path). +func removeClone(cloneDir string, keep bool) error { + if cloneDir == "" || keep { + return nil + } + return os.RemoveAll(cloneDir) +} diff --git a/cmd/elfuse-oci/csrun_darwin_test.go b/cmd/elfuse-oci/csrun_darwin_test.go new file mode 100644 index 00000000..3516c767 --- /dev/null +++ b/cmd/elfuse-oci/csrun_darwin_test.go @@ -0,0 +1,364 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/v1" + "golang.org/x/sys/unix" +) + +type runExitCode int + +func withCSRunSeams(t *testing.T) { + t.Helper() + oldEnsure := ensureCaseSensitiveRootfsForRun + oldClone := clonefileForRun + oldSpawn := spawnElfuseWaitForRun + oldCleanup := cleanupCloneAndMountForRun + oldExit := osExitForRun + oldNow := runNowUnixNano + t.Cleanup(func() { + ensureCaseSensitiveRootfsForRun = oldEnsure + clonefileForRun = oldClone + spawnElfuseWaitForRun = oldSpawn + cleanupCloneAndMountForRun = oldCleanup + osExitForRun = oldExit + runNowUnixNano = oldNow + }) +} + +func TestRunCaseSensitiveCloneSpawnCleanupAndExit(t *testing.T) { + withCSRunSeams(t) + mount := t.TempDir() + base := filepath.Join(mount, "rootfs") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + m := &csMount{mountPath: mount} + runNowUnixNano = func() int64 { return 123 } + expectedClone := filepath.Join(mount, fmt.Sprintf("run-%d-123", os.Getpid())) + var clonedSrc, clonedDst string + ensureCaseSensitiveRootfsForRun = func(cf commonFlags, s *store, ref, digest, size string) (*csMount, string, error) { + if cf.store != "store" || ref != "local:a" || digest != "sha256:"+strings.Repeat("7", 64) || size != "64m" { + t.Fatalf("ensure args = store=%q ref=%q digest=%q size=%q", cf.store, ref, digest, size) + } + return m, base, nil + } + clonefileForRun = func(src, dst string, flags int) error { + clonedSrc, clonedDst = src, dst + if flags != unix.CLONE_NOFOLLOW { + t.Fatalf("clone flags = %d, want CLONE_NOFOLLOW", flags) + } + return os.MkdirAll(dst, 0o755) + } + var spawnRootfs string + var spawnSpec *runSpec + spawnElfuseWaitForRun = func(rootfs string, spec *runSpec) (int, error) { + spawnRootfs = rootfs + spawnSpec = spec + return 7, nil + } + var cleanupClone string + var cleanupKeep bool + cleanupCloneAndMountForRun = func(cloneDir string, keep bool, got *csMount) error { + cleanupClone, cleanupKeep = cloneDir, keep + if got != m { + t.Fatalf("cleanup mount = %+v, want fake mount", got) + } + return nil + } + osExitForRun = func(code int) { panic(runExitCode(code)) } + + defer func() { + r := recover() + code, ok := r.(runExitCode) + if !ok || code != 7 { + t.Fatalf("runCaseSensitive panic = %T %v, want exit code 7", r, r) + } + if clonedSrc != base || clonedDst != expectedClone { + t.Fatalf("clone = %q -> %q, want %q -> %q", clonedSrc, clonedDst, base, expectedClone) + } + if spawnRootfs != expectedClone { + t.Fatalf("spawn rootfs = %q, want clone %q", spawnRootfs, expectedClone) + } + if spawnSpec == nil || !reflect.DeepEqual(spawnSpec.Args, []string{"/cmd"}) { + t.Fatalf("spawn spec = %+v, want /cmd", spawnSpec) + } + if cleanupClone != expectedClone || cleanupKeep { + t.Fatalf("cleanup clone=%q keep=%v, want clone and keep=false", cleanupClone, cleanupKeep) + } + }() + + cfg := &v1.ConfigFile{Config: v1.Config{Cmd: []string{"/cmd"}}} + err := runCaseSensitive( + commonFlags{store: "store"}, + &store{}, + "local:a", + "sha256:"+strings.Repeat("7", 64), + cfg, + runFlags{sparseSize: "64m"}, + nil, + ) + t.Fatalf("runCaseSensitive returned %v, want osExitForRun panic", err) +} + +func TestRunCaseSensitiveNoCloneKeepSkipsCleanup(t *testing.T) { + withCSRunSeams(t) + mount := t.TempDir() + base := filepath.Join(mount, "rootfs") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + ensureCaseSensitiveRootfsForRun = func(commonFlags, *store, string, string, string) (*csMount, string, error) { + return &csMount{mountPath: mount}, base, nil + } + clonefileForRun = func(string, string, int) error { + t.Fatal("clonefile called with --no-clone") + return nil + } + spawnElfuseWaitForRun = func(rootfs string, spec *runSpec) (int, error) { + if rootfs != base { + t.Fatalf("spawn rootfs = %q, want base rootfs", rootfs) + } + return 0, nil + } + cleanupCloneAndMountForRun = func(string, bool, *csMount) error { + t.Fatal("cleanup called with --keep") + return nil + } + osExitForRun = func(code int) { panic(runExitCode(code)) } + + defer func() { + r := recover() + code, ok := r.(runExitCode) + if !ok || code != 0 { + t.Fatalf("runCaseSensitive panic = %T %v, want exit code 0", r, r) + } + }() + err := runCaseSensitive(commonFlags{}, &store{}, "local:a", "sha256:"+strings.Repeat("8", 64), + &v1.ConfigFile{Config: v1.Config{Cmd: []string{"/cmd"}}}, + runFlags{noClone: true, keepRootfs: true}, + nil) + t.Fatalf("runCaseSensitive returned %v, want exit panic", err) +} + +func TestRunCaseSensitiveSpecErrorCleansCloneAndMount(t *testing.T) { + withCSRunSeams(t) + mount := t.TempDir() + base := filepath.Join(mount, "rootfs") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + m := &csMount{mountPath: mount} + runNowUnixNano = func() int64 { return 456 } + expectedClone := filepath.Join(mount, fmt.Sprintf("run-%d-456", os.Getpid())) + ensureCaseSensitiveRootfsForRun = func(commonFlags, *store, string, string, string) (*csMount, string, error) { + return m, base, nil + } + clonefileForRun = func(src, dst string, flags int) error { + return os.MkdirAll(dst, 0o755) + } + spawnElfuseWaitForRun = func(string, *runSpec) (int, error) { + t.Fatal("spawn called after spec error") + return 0, nil + } + var cleanupClone string + cleanupCloneAndMountForRun = func(cloneDir string, keep bool, got *csMount) error { + cleanupClone = cloneDir + if keep { + t.Fatal("cleanup keep = true, want false") + } + if got != m { + t.Fatalf("cleanup mount = %+v, want fake mount", got) + } + return nil + } + osExitForRun = func(code int) { t.Fatalf("exit called after spec error with code %d", code) } + + err := runCaseSensitive(commonFlags{}, &store{}, "local:a", "sha256:"+strings.Repeat("9", 64), + &v1.ConfigFile{Config: v1.Config{}}, + runFlags{}, + nil) + if err == nil || !strings.Contains(err.Error(), "no command") { + t.Fatalf("runCaseSensitive spec err = %v, want no command", err) + } + if cleanupClone != expectedClone { + t.Fatalf("cleanup clone = %q, want %q", cleanupClone, expectedClone) + } +} + +func TestEnsureCaseSensitiveRootfsProvisionsUnpacksAndSkipsExisting(t *testing.T) { + t.Run("unpacks missing rootfs", func(t *testing.T) { + installFakeHdiutil(t) + withDarwinCacheSeams(t, func(string) bool { return false }, nil) + actualMount := filepath.Join(t.TempDir(), "actual-mount") + if err := os.MkdirAll(actualMount, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("HDIUTIL_MOUNT", actualMount) + s := openTestStore(t) + digest, err := s.addImage("local:tiny", tinyImage(t)) + if err != nil { + t.Fatal(err) + } + + m, rootfs, err := ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:tiny", digest, "32m") + if err != nil { + t.Fatalf("ensureCaseSensitiveRootfs: %v", err) + } + t.Cleanup(func() { _ = m.Close() }) + if rootfs != filepath.Join(actualMount, "rootfs") { + t.Fatalf("rootfs = %q, want actual mount rootfs", rootfs) + } + if b, err := os.ReadFile(filepath.Join(rootfs, "hello")); err != nil || string(b) != "world" { + t.Fatalf("rootfs hello = %q, err=%v; want world", b, err) + } + }) + + t.Run("keeps existing rootfs", func(t *testing.T) { + installFakeHdiutil(t) + withDarwinCacheSeams(t, func(string) bool { return false }, nil) + actualMount := filepath.Join(t.TempDir(), "actual-mount") + rootfs := filepath.Join(actualMount, "rootfs") + if err := os.MkdirAll(rootfs, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootfs, "marker"), []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("HDIUTIL_MOUNT", actualMount) + s := openTestStore(t) + digest, err := s.addImage("local:tiny", tinyImage(t)) + if err != nil { + t.Fatal(err) + } + + m, gotRootfs, err := ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:tiny", digest, "32m") + if err != nil { + t.Fatalf("ensureCaseSensitiveRootfs existing: %v", err) + } + t.Cleanup(func() { _ = m.Close() }) + if gotRootfs != rootfs { + t.Fatalf("rootfs = %q, want %q", gotRootfs, rootfs) + } + if b, err := os.ReadFile(filepath.Join(rootfs, "marker")); err != nil || string(b) != "keep" { + t.Fatalf("marker = %q, err=%v; want keep", b, err) + } + if _, err := os.Stat(filepath.Join(rootfs, "hello")); !os.IsNotExist(err) { + t.Fatalf("existing rootfs was unpacked over: %v", err) + } + }) +} + +func TestEnsureCaseSensitiveRootfsClosesMountOnUnpackError(t *testing.T) { + installFakeHdiutil(t) + withDarwinCacheSeams(t, func(string) bool { return false }, nil) + actualMount := filepath.Join(t.TempDir(), "actual-mount") + if err := os.MkdirAll(actualMount, 0o755); err != nil { + t.Fatal(err) + } + detachLog := filepath.Join(t.TempDir(), "detach.log") + t.Setenv("HDIUTIL_MOUNT", actualMount) + t.Setenv("HDIUTIL_DETACH_LOG", detachLog) + s := openTestStore(t) + + _, _, err := ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:missing", "sha256:"+strings.Repeat("a", 64), "32m") + if err == nil || !strings.Contains(err.Error(), "not pulled") { + t.Fatalf("ensureCaseSensitiveRootfs err = %v, want unpack not-pulled error", err) + } + b, readErr := os.ReadFile(detachLog) + if readErr != nil { + t.Fatal(readErr) + } + if !strings.Contains(string(b), actualMount) { + t.Fatalf("detach log = %q, want actual mount %s", b, actualMount) + } +} + +func TestCleanupCloneAndMountAndCloseMount(t *testing.T) { + oldDetach := detachForce + var detached string + detachForce = func(path string) error { + detached = path + return nil + } + t.Cleanup(func() { detachForce = oldDetach }) + + clone := filepath.Join(t.TempDir(), "clone") + if err := os.MkdirAll(clone, 0o755); err != nil { + t.Fatal(err) + } + m := &csMount{mountPath: "/tmp/cleanup-mount", owned: true} + if err := cleanupCloneAndMount(clone, false, m); err != nil { + t.Fatalf("cleanupCloneAndMount: %v", err) + } + if _, err := os.Stat(clone); !os.IsNotExist(err) { + t.Fatalf("clone after cleanup: %v, want IsNotExist", err) + } + if detached != "/tmp/cleanup-mount" || m.owned { + t.Fatalf("detached=%q owned=%v, want detached mount and owned=false", detached, m.owned) + } + + detachForce = func(path string) error { return fmt.Errorf("detach boom") } + err := closeMount(&csMount{mountPath: "/tmp/bad-mount", owned: true}) + if err == nil || !strings.Contains(err.Error(), "detach /tmp/bad-mount") || !strings.Contains(err.Error(), "detach boom") { + t.Fatalf("closeMount err = %v, want wrapped detach error", err) + } +} + +func TestCmdRunDefaultCaseSensitivePath(t *testing.T) { + withCSRunSeams(t) + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/image-cmd"})); err != nil { + t.Fatal(err) + } + mount := t.TempDir() + base := filepath.Join(mount, "rootfs") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + runNowUnixNano = func() int64 { return 789 } + ensureCaseSensitiveRootfsForRun = func(cf commonFlags, _ *store, ref, digest, size string) (*csMount, string, error) { + if cf.store != s.root || ref != "local:a" || size != "" { + t.Fatalf("ensure from cmdRun got store=%q ref=%q size=%q", cf.store, ref, size) + } + if !strings.HasPrefix(digest, "sha256:") { + t.Fatalf("ensure digest = %q, want sha256 digest", digest) + } + return &csMount{mountPath: mount}, base, nil + } + clonefileForRun = func(src, dst string, flags int) error { + return os.MkdirAll(dst, 0o755) + } + spawnElfuseWaitForRun = func(rootfs string, spec *runSpec) (int, error) { + if !strings.Contains(rootfs, fmt.Sprintf("run-%d-789", os.Getpid())) { + t.Fatalf("spawn rootfs = %q, want generated clone", rootfs) + } + if !reflect.DeepEqual(spec.Args, []string{"/image-cmd"}) { + t.Fatalf("spec args = %v, want image cmd", spec.Args) + } + return 0, nil + } + cleanupCloneAndMountForRun = func(string, bool, *csMount) error { return nil } + osExitForRun = func(code int) { panic(runExitCode(code)) } + + defer func() { + r := recover() + code, ok := r.(runExitCode) + if !ok || code != 0 { + t.Fatalf("cmdRun default sparse path panic = %T %v, want exit 0", r, r) + } + }() + err := cmdRun([]string{"--store", s.root, "local:a"}) + t.Fatalf("cmdRun returned %v, want exit panic", err) +} diff --git a/cmd/elfuse-oci/csrun_other.go b/cmd/elfuse-oci/csrun_other.go new file mode 100644 index 00000000..b16d9b68 --- /dev/null +++ b/cmd/elfuse-oci/csrun_other.go @@ -0,0 +1,22 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build !darwin + +package main + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/v1" +) + +// runCaseSensitive is the macOS case-sensitive sparsebundle + COW clone path. +// On non-Darwin there is no APFS/hdiutil/clonefile, and `run` is unusable +// anyway without Hypervisor.framework, so the default (case-sensitive) path +// reports a clear error and directs the user at --plain-rootfs. This stub +// exists so elfuse-oci compiles on Linux, where pull/inspect/unpack and +// conformance/interop tests run. +func runCaseSensitive(cf commonFlags, s *store, ref, digest string, cfg *v1.ConfigFile, rf runFlags, tail []string) error { + return fmt.Errorf("case-sensitive sparsebundle rootfs requires macOS; pass --plain-rootfs for a plain directory") +} diff --git a/cmd/elfuse-oci/main.go b/cmd/elfuse-oci/main.go index e6d10c5f..15407a27 100644 --- a/cmd/elfuse-oci/main.go +++ b/cmd/elfuse-oci/main.go @@ -63,6 +63,15 @@ run flags: are resolved against the image /etc/passwd and /etc/group before exec. --workdir DIR Guest-absolute initial working directory + --rootfs DIR Use an explicit rootfs directory (implies a plain dir, + no sparsebundle/clone lifecycle) + --plain-rootfs Use a plain directory rootfs instead of the default + macOS case-sensitive sparsebundle + --sparse-size SIZE Sparsebundle virtual size (default 16g; macOS only) + --no-clone Run against the base tree, skipping the per-run COW + clone (mutations persist; macOS only) + --keep Keep the per-run COW clone and mount for inspection + (macOS only) `) } diff --git a/cmd/elfuse-oci/run.go b/cmd/elfuse-oci/run.go index 653f5f00..50aaeb3b 100644 --- a/cmd/elfuse-oci/run.go +++ b/cmd/elfuse-oci/run.go @@ -6,6 +6,8 @@ package main import ( "fmt" "os" + "os/exec" + "os/signal" "path/filepath" "syscall" ) @@ -26,25 +28,17 @@ func elfuseBin() (string, error) { return filepath.Join(filepath.Dir(exe), "elfuse"), nil } -// execElfuse replaces this process with `elfuse --sysroot --user U:G -// --workdir D --clear-env --env K=V ... `. +// elfuseArgv builds the argv for `elfuse --sysroot --user U:G +// --workdir D --clear-env --env K=V ... -- `. // -// --clear-env plus every final env var as an explicit --env makes the guest -// see exactly the runspec env (image Env merged with --env overrides, per the +// --clear-env plus every final env var as an explicit --env makes the guest see +// exactly the runspec env (image Env merged with --env overrides, per the // precedence matrix) rather than the host environ. // -// syscall.Exec replaces elfuse-oci in place: the invoking shell reaps -// the same pid, and signals such as Ctrl-C go straight to elfuse rather than -// through a Go middleman. -func execElfuse(rootfs string, spec *runSpec) error { - bin, err := elfuseBin() - if err != nil { - return err - } - if _, err := os.Stat(bin); err != nil { - return fmt.Errorf("elfuse binary not found at %s (set $ELFUSE_BIN): %w", bin, err) - } - +// "--" ends elfuse's own option parsing: spec.Args comes from untrusted image +// config, so an Entrypoint beginning with "-" must reach the guest as its +// argv, not steer the host launcher (e.g. an image config carrying "--gdb"). +func elfuseArgv(rootfs string, spec *runSpec) []string { argv := []string{ "elfuse", "--sysroot", rootfs, @@ -55,10 +49,91 @@ func execElfuse(rootfs string, spec *runSpec) error { for _, e := range spec.Env { argv = append(argv, "--env", e) } + argv = append(argv, "--") argv = append(argv, spec.Args...) + return argv +} - if err := syscall.Exec(bin, argv, os.Environ()); err != nil { +// execElfuse replaces this process with elfuse (syscall.Exec). Used for the +// plain-rootfs path, which owns no mount to tear down: elfuse-oci +// becomes elfuse in place, so the invoking shell reaps the same pid and +// terminal signals such as Ctrl-C go straight to elfuse rather than through a +// Go middleman. +func execElfuse(rootfs string, spec *runSpec) error { + bin, err := elfuseBin() + if err != nil { + return err + } + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("elfuse binary not found at %s (set $ELFUSE_BIN): %w", bin, err) + } + if err := syscall.Exec(bin, elfuseArgv(rootfs, spec), os.Environ()); err != nil { return fmt.Errorf("exec %s: %w", bin, err) } return nil // unreachable } + +// spawnElfuseWait runs elfuse as a child and waits for it, returning the exit +// status the way a shell would (exit code, or 128+signal for signal death). +// Unlike execElfuse, elfuse-oci stays alive to reap the child, letting the +// case-sensitive path tear down its mount and COW clone after elfuse exits. +// +// The child shares this process's process group, so terminal signals (Ctrl-C) +// reach it directly; we additionally forward any such signal we receive to the +// child so a signal targeted at elfuse-oci alone still propagates, and we +// survive to reap and report the child's status rather than dying first. +func spawnElfuseWait(rootfs string, spec *runSpec) (int, error) { + bin, err := elfuseBin() + if err != nil { + return 0, err + } + if _, err := os.Stat(bin); err != nil { + return 0, fmt.Errorf("elfuse binary not found at %s (set $ELFUSE_BIN): %w", bin, err) + } + // exec.Command uses `bin` as argv[0], so drop the leading "elfuse" + // program-name that elfuseArgv includes for syscall.Exec's sake; otherwise + // elfuse would see "elfuse" as its first positional and try to boot a + // guest path named "elfuse". + cmd := exec.Command(bin, elfuseArgv(rootfs, spec)[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Intercept before Start so no window exists where a signal takes the + // default action and kills this wrapper between launching the child and + // entering the forward/reap loop; the channel buffers until then. SIGHUP + // is included so a terminal hangup also flows through the forward/reap + // path and the caller's mount/clone teardown still runs. + sigCh := make(chan os.Signal, 4) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, + syscall.SIGHUP) + defer signal.Stop(sigCh) + + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("spawn %s: %w", bin, err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + for { + select { + case err := <-done: + state := cmd.ProcessState + if state == nil { + return 0, err + } + if ws, ok := state.Sys().(syscall.WaitStatus); ok { + if ws.Signaled() { + return 128 + int(ws.Signal()), nil + } + return ws.ExitStatus(), nil + } + return state.ExitCode(), nil + case sig := <-sigCh: + if cmd.Process != nil { + _ = cmd.Process.Signal(sig) + } + } + } +} diff --git a/cmd/elfuse-oci/run_test.go b/cmd/elfuse-oci/run_test.go index f254182b..b8b9a2a7 100644 --- a/cmd/elfuse-oci/run_test.go +++ b/cmd/elfuse-oci/run_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" ) @@ -25,6 +26,72 @@ func writeElfuseStub(t *testing.T, body string) string { return p } +// TestSpawnElfuseWaitExitCode verifies the child's exit code is returned as-is. +func TestSpawnElfuseWaitExitCode(t *testing.T) { + writeElfuseStub(t, "exit 42") + spec := &runSpec{Args: []string{"/hello"}, Workdir: "/", UID: 0, GID: 0} + code, err := spawnElfuseWait(t.TempDir(), spec) + if err != nil { + t.Fatalf("spawnElfuseWait: %v", err) + } + if code != 42 { + t.Errorf("exit code: got %d, want 42", code) + } +} + +// TestSpawnElfuseWaitSignalDeath verifies signal death is reported the +// shell-style way: 128 + signal. The child kills itself with SIGTERM (15), so +// cmd.Wait() observes WaitStatus.Signaled() independently of elfuse-oci's own +// signal forwarding. +func TestSpawnElfuseWaitSignalDeath(t *testing.T) { + writeElfuseStub(t, "kill -TERM $$") + spec := &runSpec{Args: []string{"/hello"}, Workdir: "/", UID: 0, GID: 0} + code, err := spawnElfuseWait(t.TempDir(), spec) + if err != nil { + t.Fatalf("spawnElfuseWait: %v", err) + } + if code != 143 { // 128 + SIGTERM(15) + t.Errorf("signal death: got %d, want 143 (128+15)", code) + } +} + +// TestElfuseArgvShape verifies the argv handed to elfuse is exactly +// elfuseArgv(rootfs, spec) minus the leading "elfuse" program-name (exec.Command +// prepends the binary path as argv[0], so spawnElfuseWait drops elfuseArgv[0]). +// The stub records its own argv ($@, which excludes $0) one per line. +func TestElfuseArgvShape(t *testing.T) { + outPath := filepath.Join(t.TempDir(), "argv.txt") + t.Setenv("ELFUSE_ARGV_OUT", outPath) + writeElfuseStub(t, `printf '%s\n' "$@" > "$ELFUSE_ARGV_OUT"`) + + rootfs := t.TempDir() + spec := &runSpec{ + Args: []string{"/bin/echo", "hi"}, + Env: []string{"A=1", "B=2"}, + Workdir: "/work", + UID: 1000, + GID: 1000, + } + wantArgv := elfuseArgv(rootfs, spec)[1:] + + code, err := spawnElfuseWait(rootfs, spec) + if err != nil { + t.Fatalf("spawnElfuseWait: %v", err) + } + if code != 0 { + t.Fatalf("stub exited %d, want 0", code) + } + + data, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("read argv out: %v", err) + } + gotLines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if !reflect.DeepEqual(gotLines, wantArgv) { + t.Errorf("argv:\n got %v\nwant %v", gotLines, wantArgv) + } +} + func TestElfuseBinEnvAndFallback(t *testing.T) { want := filepath.Join(t.TempDir(), "elfuse-custom") t.Setenv("ELFUSE_BIN", want) @@ -95,3 +162,46 @@ func TestExecElfuseSuccessSubprocess(t *testing.T) { } } } + +func TestSpawnElfuseWaitMissingAndStartErrors(t *testing.T) { + t.Setenv("ELFUSE_BIN", filepath.Join(t.TempDir(), "missing-elfuse")) + if _, err := spawnElfuseWait(t.TempDir(), &runSpec{Args: []string{"/bin/true"}, Workdir: "/", UID: 0, GID: 0}); err == nil || + !strings.Contains(err.Error(), "elfuse binary not found") { + t.Fatalf("spawn missing binary err = %v, want not found", err) + } + + nonExecutable := filepath.Join(t.TempDir(), "elfuse-not-executable") + if err := os.WriteFile(nonExecutable, []byte("#!/bin/sh\nexit 0\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("ELFUSE_BIN", nonExecutable) + if _, err := spawnElfuseWait(t.TempDir(), &runSpec{Args: []string{"/bin/true"}, Workdir: "/", UID: 0, GID: 0}); err == nil || + !strings.Contains(err.Error(), "spawn") { + t.Fatalf("spawn start err = %v, want spawn error", err) + } +} + +// TestElfuseArgvSeparatesGuestArgs pins the "--" end-of-options marker: the +// guest command comes from untrusted image config, so an Entrypoint that +// begins with "-" must arrive as guest argv, not be parsed as an elfuse +// option by the host launcher. +func TestElfuseArgvSeparatesGuestArgs(t *testing.T) { + spec := &runSpec{ + Args: []string{"--gdb", "1234"}, + Workdir: "/", + } + argv := elfuseArgv("/rootfs", spec) + sep := -1 + for i, a := range argv { + if a == "--" { + sep = i + break + } + } + if sep < 0 { + t.Fatalf("argv %v carries no \"--\" separator before guest args", argv) + } + if !reflect.DeepEqual(argv[sep+1:], spec.Args) { + t.Fatalf("argv after -- = %v, want %v", argv[sep+1:], spec.Args) + } +} diff --git a/cmd/elfuse-oci/runspec.go b/cmd/elfuse-oci/runspec.go index e57d657f..ee7d31c4 100644 --- a/cmd/elfuse-oci/runspec.go +++ b/cmd/elfuse-oci/runspec.go @@ -48,6 +48,12 @@ type runFlags struct { user string workdir string rootfs string + + // Case-sensitive sparsebundle and COW clone controls. + plainRootfs bool // --plain-rootfs: skip the sparsebundle, use a plain dir + sparseSize string // --sparse-size SIZE: sparsebundle virtual size (default 16g) + noClone bool // --no-clone: run against the base tree, no COW clone + keepRootfs bool // --keep: do not remove the COW clone on exit } // computeRunSpec applies the Entrypoint/Cmd/Env/WorkingDir/User precedence. diff --git a/cmd/elfuse-oci/sparsebundle.go b/cmd/elfuse-oci/sparsebundle.go new file mode 100644 index 00000000..5069de46 --- /dev/null +++ b/cmd/elfuse-oci/sparsebundle.go @@ -0,0 +1,313 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "bytes" + "errors" + "fmt" + "html" + "os" + "os/exec" + "path/filepath" + "regexp" + "syscall" +) + +var isMountPointFn = isMountPoint + +// csMount is a case-sensitive APFS sparsebundle attached at a mount point. +// It mirrors the C sysroot_create_mount machinery in src/core/sysroot.c so +// the guest rootfs is case-sensitive (the host volume is not), fixing +// the case-collision limitation of a plain-directory rootfs. +type csMount struct { + mountPath string // where the volume is attached + owned bool // we attached (or share) it; tear down on Close + bundleDir string // bundle directory holding the lock files; "" = no locking (unit tests) + runLock *flockFile // shared liveness lock held for this run's lifetime +} + +// defaultSparseSize is the sparsebundle's virtual size. APFS sparsebundles are +// sparse, so this is a ceiling, not preallocation; 16g matches the C side and +// comfortably covers base images (the actual disk use is the unpacked size). +const defaultSparseSize = "16g" + +// provisionCaseSensitive creates (if absent) and attaches a case-sensitive +// APFS sparsebundle. The unpacked base tree lives at /rootfs and +// persists in the sparsebundle image file across attach/detach cycles, so warm +// re-runs skip the unpack. The caller must Close the returned mount (which +// detaches it when this is the last live run) when done. +// +// Locking (see bundlelock.go): the whole provision runs under an exclusive +// attach.lock, and the returned mount holds run.lock shared until Close, so +// this run is visible to prune/rmi sweeps from BEFORE the volume is +// attached: there is no window in which the mount exists but no liveness +// marker does. An attached leftover mount is detached only after winning the +// run.lock exclusive probe, which proves no live run is executing out of it; +// when the probe reports busy the mount belongs to live runs of the same +// digest and is shared instead of ripped out from under them. +func provisionCaseSensitive(bundleDir, mountPath, size string) (*csMount, error) { + if size == "" { + size = defaultSparseSize + } + if err := os.MkdirAll(bundleDir, 0o755); err != nil { + return nil, err + } + image := filepath.Join(bundleDir, "rootfs.sparsebundle") + + attachLock, err := acquireAttachLock(bundleDir) + if err != nil { + return nil, err + } + defer attachLock.Close() + + // Probe run.lock. Winning it exclusively proves zero live runs: any + // attached mount is stale (crash, kill, --keep) and safe to detach; hold + // the lock and downgrade to shared once provisioned (safe under + // attach.lock, see Downgrade). Losing the probe proves live runs exist: + // take it shared (which cannot block, since exclusive takers must hold + // attach.lock) and never detach. + staleDetachOK := false + runLock, err := acquireFlock(runLockPath(bundleDir), syscall.LOCK_EX|syscall.LOCK_NB) + switch { + case err == nil: + staleDetachOK = true + case errors.Is(err, errCacheBusy): + runLock, err = acquireFlock(runLockPath(bundleDir), syscall.LOCK_SH) + if err != nil { + return nil, err + } + default: + return nil, err + } + fail := func(err error) (*csMount, error) { + runLock.Close() + return nil, err + } + + if _, err := os.Stat(image); os.IsNotExist(err) { + out, err := exec.Command("hdiutil", "create", + "-fs", "Case-sensitive APFS", + "-size", size, + "-type", "SPARSEBUNDLE", + "-volname", "elfuse_sysroot", + image).CombinedOutput() + if err != nil { + return fail(fmt.Errorf("hdiutil create %s: %w: %s", image, err, out)) + } + } else if err != nil { + return fail(err) + } + + // Reject a symlinked mount path before any mount-status probe or detach: + // isMountPoint/detachForce follow the link (os.Stat) and could force-detach + // an unrelated volume. clearDir has the same guard, but only runs after the + // detach below. + if li, err := os.Lstat(mountPath); err == nil && li.Mode()&os.ModeSymlink != 0 { + return fail(fmt.Errorf("mount path %s is a symlink; refusing to detach/clear", mountPath)) + } + + if isMountPointFn(mountPath) { + if !staleDetachOK { + // Live runs of this digest own the attach; share it. + return &csMount{mountPath: mountPath, owned: true, bundleDir: bundleDir, runLock: runLock}, nil + } + // A prior run left the volume attached (crash, kill, --keep) and the + // won run.lock probe proves nothing is executing out of it: detach so + // we own a clean attach. + if err := detachForce(mountPath); err != nil { + return fail(fmt.Errorf("detach stale %s: %w", mountPath, err)) + } + } + // Ensure the mount point is an empty directory so hdiutil will mount onto + // it. + if err := clearDir(mountPath); err != nil { + return fail(err) + } + + // Keep stdout (the plist) separate from stderr: the failure message must + // carry hdiutil's diagnostic, which Output() alone would discard, while + // CombinedOutput() would corrupt the plist parse. + attach := exec.Command("hdiutil", "attach", + "-mountpoint", mountPath, "-plist", image) + var attachStderr bytes.Buffer + attach.Stderr = &attachStderr + out, err := attach.Output() + if err != nil { + return fail(fmt.Errorf("hdiutil attach %s: %w: %s%s", image, err, out, + attachStderr.Bytes())) + } + actual, err := parseMountpoint(string(out)) + if err != nil { + err = fmt.Errorf("parse attach plist for %s: %w", image, err) + return fail(detachAfterAttachError(mountPath, err)) + } + + if err := writeSpotlightMarker(actual); err != nil { + err = fmt.Errorf("spotlight marker: %w", err) + return fail(detachAfterAttachError(actual, err)) + } + if staleDetachOK { + if err := runLock.Downgrade(); err != nil { + return fail(detachAfterAttachError(actual, err)) + } + } + return &csMount{mountPath: actual, owned: true, bundleDir: bundleDir, runLock: runLock}, nil +} + +// rootfsDir is the base unpacked tree inside the volume. +func (m *csMount) rootfsDir() string { return filepath.Join(m.mountPath, "rootfs") } + +// Close releases this run's liveness lock and detaches the volume when this +// was the last live run of the digest (last-one-out): with concurrent runs +// sharing one attach, an unconditional detach here would rip the rootfs out +// from under the survivors. A csMount without a bundleDir (unit tests, +// hand-built mounts) has no locks to consult and detaches unconditionally. +func (m *csMount) Close() error { + if !m.owned { + return nil + } + if m.bundleDir == "" { + if err := detachForce(m.mountPath); err != nil { + return err + } + m.owned = false + return nil + } + // Take attach.lock BEFORE releasing our shared run.lock: it fences out a + // concurrent sweep, which could otherwise win both locks between our + // release and our probe and remove the bundle we are about to detach. If + // the lifecycle lock is busy (another provision or a sweep), its holder + // owns the mount's fate; just drop our liveness and go. + attachLock, err := acquireFlock(attachLockPath(m.bundleDir), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + m.runLock.Close() + m.runLock = nil + m.owned = false + if errors.Is(err, errCacheBusy) { + return nil + } + return err + } + defer attachLock.Close() + if err := m.runLock.Close(); err != nil { + m.owned = false + return err + } + m.runLock = nil + m.owned = false + runLock, err := acquireFlock(runLockPath(m.bundleDir), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + if errors.Is(err, errCacheBusy) { + // Other live runs share the attach; leave the volume to them. + return nil + } + return err + } + defer runLock.Close() + return detachForce(m.mountPath) +} + +func detachAfterAttachError(mountPath string, cause error) error { + if err := detachForce(mountPath); err != nil { + return errors.Join(cause, fmt.Errorf("detach %s: %w", mountPath, err)) + } + return cause +} + +var detachForce = func(mountPath string) error { + out, err := exec.Command("hdiutil", "detach", "-force", mountPath).CombinedOutput() + if err != nil { + return fmt.Errorf("hdiutil detach %s: %w: %s", mountPath, err, out) + } + return nil +} + +// writeSpotlightMarker drops .metadata_never_index so Spotlight does not index +// the (potentially large) rootfs volume. +func writeSpotlightMarker(mountPath string) error { + return touchFile(filepath.Join(mountPath, ".metadata_never_index")) +} + +// touchFile creates (or truncates) an empty marker file. The markers carry +// no content; only their existence matters. +func touchFile(path string) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + return f.Close() +} + +// isMountPoint reports whether path is currently a mount point by comparing its +// device id against its parent's. +func isMountPoint(path string) bool { + if fi, err := os.Stat(path); err != nil || !fi.IsDir() { + return false + } + dev, ok := devOf(path) + if !ok { + return false + } + parent, ok := devOf(filepath.Dir(path)) + if !ok { + return false + } + return dev != parent +} + +func devOf(path string) (int64, bool) { + var st syscall.Stat_t + if err := syscall.Stat(path, &st); err != nil { + return 0, false + } + return int64(st.Dev), true +} + +// clearDir removes all children of dir (creating it if absent) without removing +// dir itself, so hdiutil can mount onto it. A symlink at dir is rejected: +// ReadDir/RemoveAll would follow it and empty the link's target instead of the +// mount point, so a corrupt or tampered store must fail here rather than +// delete files elsewhere. +func clearDir(dir string) error { + if li, err := os.Lstat(dir); err == nil { + if li.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("mount point %s is a symlink; refusing to clear it", dir) + } + } else if !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, e := range entries { + if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil { + return err + } + } + return nil +} + +var mountpointRe = regexp.MustCompile(`mount-point\s*([^<]+)`) + +// parseMountpoint extracts the mount-point from an hdiutil attach -plist output +// (mirroring the C parse_attach_mountpoint string scan). The scan reads raw +// XML text content, so entity references must be decoded: a store path +// containing "&" or "'" is otherwise returned in its escaped form and every +// later use (markers, rootfs, detach) targets a nonexistent path. +// html.UnescapeString covers the XML predefined entities plus numeric +// references. +func parseMountpoint(plist string) (string, error) { + m := mountpointRe.FindStringSubmatch(plist) + if m == nil { + return "", fmt.Errorf("mount-point key not found in plist") + } + return html.UnescapeString(m[1]), nil +} diff --git a/cmd/elfuse-oci/sparsebundle_test.go b/cmd/elfuse-oci/sparsebundle_test.go new file mode 100644 index 00000000..01b027c8 --- /dev/null +++ b/cmd/elfuse-oci/sparsebundle_test.go @@ -0,0 +1,412 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// withDarwinCacheSeams swaps the darwin mount-probe and force-detach seams +// for a test and restores them on cleanup. +func withDarwinCacheSeams(t *testing.T, isMount func(string) bool, detach func(string) error) { + t.Helper() + oldIsMount := isMountPointFn + oldDetach := detachForce + if isMount != nil { + isMountPointFn = isMount + } + if detach != nil { + detachForce = detach + } + t.Cleanup(func() { + isMountPointFn = oldIsMount + detachForce = oldDetach + }) +} + +// hdiutil attach -plist output is an array of system entity dictionaries. We +// only care about the mount-point. This fixture is a trimmed-down capture of the +// real shape (system-entities -> dict -> mount-point key/string). +const attachPlistFixture = ` + + + + + system-entities + + + content-hint + Apple_APFS + dev-entry + /dev/disk3s1 + mount-point + /Volumes/elfuse_sysroot + potentially-mountable + 1 + + + + +` + +func TestParseMountpoint(t *testing.T) { + got, err := parseMountpoint(attachPlistFixture) + if err != nil { + t.Fatal(err) + } + if got != "/Volumes/elfuse_sysroot" { + t.Errorf("got %q, want /Volumes/elfuse_sysroot", got) + } +} + +func TestParseMountpointMissing(t *testing.T) { + if _, err := parseMountpoint(""); err == nil { + t.Fatal("expected error when mount-point absent") + } +} + +func TestParseMountpointWhitespaceBetweenKeyAndString(t *testing.T) { + in := `mount-point + /Volumes/x` + got, err := parseMountpoint(in) + if err != nil { + t.Fatal(err) + } + if got != "/Volumes/x" { + t.Errorf("got %q, want /Volumes/x", got) + } +} + +func TestIsMountPointPlainDir(t *testing.T) { + dir := t.TempDir() + if isMountPoint(dir) { + t.Errorf("fresh temp dir reported as mount point") + } +} + +func TestRemoveCloneRemovesDir(t *testing.T) { + clone := filepath.Join(t.TempDir(), "run-1-1") + if err := os.MkdirAll(clone, 0o755); err != nil { + t.Fatal(err) + } + if err := removeClone(clone, false); err != nil { + t.Fatalf("removeClone: %v", err) + } + if _, err := os.Stat(clone); !os.IsNotExist(err) { + t.Fatalf("clone after removeClone: %v, want IsNotExist", err) + } +} + +func TestRemoveCloneKeepLeavesDir(t *testing.T) { + clone := filepath.Join(t.TempDir(), "run-1-1") + if err := os.MkdirAll(clone, 0o755); err != nil { + t.Fatal(err) + } + if err := removeClone(clone, true); err != nil { + t.Fatalf("removeClone keep: %v", err) + } + if _, err := os.Stat(clone); err != nil { + t.Fatalf("clone after keep: %v, want present", err) + } +} + +func TestCSMountCloseReportsDetachError(t *testing.T) { + oldDetach := detachForce + t.Cleanup(func() { detachForce = oldDetach }) + detachForce = func(path string) error { + return fmt.Errorf("detach failed for %s", path) + } + + m := &csMount{mountPath: "/tmp/elfuse-test-mount", owned: true} + err := m.Close() + if err == nil || !strings.Contains(err.Error(), "detach failed") { + t.Fatalf("Close err = %v, want detach failure", err) + } + if !m.owned { + t.Fatal("Close cleared ownership after failed detach") + } +} + +func TestCSMount(t *testing.T) { + t.Run("rootfsDir", func(t *testing.T) { + m := &csMount{mountPath: "/tmp/elfuse-mounted", owned: true} + if got := m.rootfsDir(); got != "/tmp/elfuse-mounted/rootfs" { + t.Fatalf("rootfsDir = %q, want /tmp/elfuse-mounted/rootfs", got) + } + }) + + t.Run("close detaches once", func(t *testing.T) { + oldDetach := detachForce + var detached []string + detachForce = func(path string) error { + detached = append(detached, path) + return nil + } + t.Cleanup(func() { detachForce = oldDetach }) + + m := &csMount{mountPath: "/tmp/elfuse-mounted", owned: true} + if err := m.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if m.owned { + t.Fatal("Close left mount owned after successful detach") + } + if len(detached) != 1 || detached[0] != "/tmp/elfuse-mounted" { + t.Fatalf("detached = %v, want [/tmp/elfuse-mounted]", detached) + } + if err := m.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + if len(detached) != 1 { + t.Fatalf("second Close detached again: %v", detached) + } + }) + + t.Run("detachAfterAttachError joins both errors", func(t *testing.T) { + oldDetach := detachForce + detachForce = func(path string) error { return errors.New("detach failed") } + t.Cleanup(func() { detachForce = oldDetach }) + + err := detachAfterAttachError("/tmp/mnt", errors.New("attach parse failed")) + if err == nil || !strings.Contains(err.Error(), "attach parse failed") || !strings.Contains(err.Error(), "detach failed") { + t.Fatalf("detachAfterAttachError = %v, want joined cause and detach error", err) + } + }) +} + +func TestSparsebundleFilesystemHelpers(t *testing.T) { + dir := t.TempDir() + if err := writeSpotlightMarker(dir); err != nil { + t.Fatalf("writeSpotlightMarker: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, ".metadata_never_index")); err != nil { + t.Fatalf("spotlight marker missing: %v", err) + } + + childFile := filepath.Join(dir, "file") + childDir := filepath.Join(dir, "subdir") + if err := os.WriteFile(childFile, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(childDir, 0o755); err != nil { + t.Fatal(err) + } + if err := clearDir(dir); err != nil { + t.Fatalf("clearDir existing: %v", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("clearDir left entries %v, want empty dir", entries) + } + + missing := filepath.Join(t.TempDir(), "created") + if err := clearDir(missing); err != nil { + t.Fatalf("clearDir missing: %v", err) + } + if fi, err := os.Stat(missing); err != nil || !fi.IsDir() { + t.Fatalf("clearDir missing produced fi=%v err=%v, want dir", fi, err) + } + + // A symlinked mount dir must be rejected, not followed: clearing through + // it would empty the link's target directory outside the OCI cache. + target := t.TempDir() + if err := os.WriteFile(filepath.Join(target, "precious"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "mnt") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if err := clearDir(link); err == nil { + t.Fatal("clearDir followed a symlinked mount dir, want error") + } + if _, err := os.Stat(filepath.Join(target, "precious")); err != nil { + t.Fatalf("symlink target contents were removed: %v", err) + } + + if _, ok := devOf(dir); !ok { + t.Fatal("devOf temp dir returned ok=false") + } + if _, ok := devOf(filepath.Join(dir, "does-not-exist")); ok { + t.Fatal("devOf missing path returned ok=true") + } +} + +func installFakeHdiutil(t *testing.T) { + t.Helper() + dir := t.TempDir() + script := filepath.Join(dir, "hdiutil") + body := `#!/bin/sh +case "$1" in +create) + if [ "${HDIUTIL_FAIL_CREATE:-}" = "1" ]; then + echo "create failed" + exit 7 + fi + for last do :; done + mkdir -p "$last" + exit 0 + ;; +attach) + if [ "${HDIUTIL_FAIL_ATTACH:-}" = "1" ]; then + echo "attach diagnostic on stderr" >&2 + exit 5 + fi + if [ "${HDIUTIL_BAD_PLIST:-}" = "1" ]; then + printf '' + exit 0 + fi + printf 'mount-point%s' "$HDIUTIL_MOUNT" + exit 0 + ;; +detach) + if [ -n "${HDIUTIL_DETACH_LOG:-}" ]; then + echo "$3" >> "$HDIUTIL_DETACH_LOG" + fi + exit 0 + ;; +*) + echo "unexpected hdiutil command $1" + exit 9 + ;; +esac +` + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func TestProvisionCaseSensitiveWithFakeHdiutilSuccess(t *testing.T) { + installFakeHdiutil(t) + withDarwinCacheSeams(t, func(string) bool { return false }, nil) + bundle := filepath.Join(t.TempDir(), "bundle") + requestedMount := filepath.Join(t.TempDir(), "requested-mount") + actualMount := filepath.Join(t.TempDir(), "actual-mount") + if err := os.MkdirAll(actualMount, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("HDIUTIL_MOUNT", actualMount) + + m, err := provisionCaseSensitive(bundle, requestedMount, "32m") + if err != nil { + t.Fatalf("provisionCaseSensitive: %v", err) + } + if _, err := os.Stat(filepath.Join(bundle, "rootfs.sparsebundle")); err != nil { + t.Fatalf("sparsebundle image not created: %v", err) + } + if m.mountPath != actualMount || !m.owned { + t.Fatalf("mount = %+v, want actual mount and owned", m) + } + if _, err := os.Stat(filepath.Join(actualMount, ".metadata_never_index")); err != nil { + t.Fatalf("spotlight marker missing: %v", err) + } + if err := m.Close(); err != nil { + t.Fatalf("Close fake mount: %v", err) + } +} + +func TestProvisionCaseSensitiveWithFakeHdiutilFailures(t *testing.T) { + cases := []struct { + name string + // setup configures the fake hdiutil for this failure mode and returns the + // requested mount path plus the mount path the detach log must record + // ("" to skip the detach-log check). + setup func(t *testing.T, detachLog string) (requestedMount, wantDetach string) + wantErr []string + }{ + { + name: "create failure", + setup: func(t *testing.T, detachLog string) (string, string) { + t.Setenv("HDIUTIL_FAIL_CREATE", "1") + t.Setenv("HDIUTIL_MOUNT", t.TempDir()) + return filepath.Join(t.TempDir(), "mnt"), "" + }, + wantErr: []string{"hdiutil create", "create failed"}, + }, + { + // hdiutil writes its diagnostics to stderr, which must reach the + // error message: with a bare Output() the operator only sees + // "exit status N". + name: "attach failure surfaces hdiutil stderr", + setup: func(t *testing.T, detachLog string) (string, string) { + t.Setenv("HDIUTIL_FAIL_ATTACH", "1") + t.Setenv("HDIUTIL_MOUNT", t.TempDir()) + return filepath.Join(t.TempDir(), "mnt"), "" + }, + wantErr: []string{"hdiutil attach", "attach diagnostic on stderr"}, + }, + { + name: "bad attach plist detaches requested mount", + setup: func(t *testing.T, detachLog string) (string, string) { + t.Setenv("HDIUTIL_BAD_PLIST", "1") + t.Setenv("HDIUTIL_DETACH_LOG", detachLog) + requestedMount := filepath.Join(t.TempDir(), "requested") + return requestedMount, requestedMount + }, + wantErr: []string{"parse attach plist"}, + }, + { + name: "marker failure detaches actual mount", + setup: func(t *testing.T, detachLog string) (string, string) { + actualMount := filepath.Join(t.TempDir(), "missing-parent", "actual") + t.Setenv("HDIUTIL_MOUNT", actualMount) + t.Setenv("HDIUTIL_DETACH_LOG", detachLog) + return filepath.Join(t.TempDir(), "requested"), actualMount + }, + wantErr: []string{"spotlight marker"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + installFakeHdiutil(t) + withDarwinCacheSeams(t, func(string) bool { return false }, nil) + detachLog := filepath.Join(t.TempDir(), "detach.log") + requestedMount, wantDetach := tc.setup(t, detachLog) + _, err := provisionCaseSensitive(filepath.Join(t.TempDir(), "bundle"), requestedMount, "32m") + if err == nil { + t.Fatalf("provisionCaseSensitive succeeded, want error containing %v", tc.wantErr) + } + for _, want := range tc.wantErr { + if !strings.Contains(err.Error(), want) { + t.Fatalf("err = %v, want substring %q", err, want) + } + } + if wantDetach != "" { + b, readErr := os.ReadFile(detachLog) + if readErr != nil { + t.Fatal(readErr) + } + if !strings.Contains(string(b), wantDetach) { + t.Fatalf("detach log = %q, want mount %s", b, wantDetach) + } + } + }) + } +} + +// TestParseMountpointDecodesXMLEntities pins entity decoding: hdiutil's plist +// escapes XML-special characters in the mount path (a store path may carry +// "&" or "'"), and the raw escaped form would make every later use of the +// path (markers, rootfs, detach) target a nonexistent location. +func TestParseMountpointDecodesXMLEntities(t *testing.T) { + in := `mount-point/tmp/a & b's store/mnt` + got, err := parseMountpoint(in) + if err != nil { + t.Fatal(err) + } + if want := "/tmp/a & b's store/mnt"; got != want { + t.Fatalf("parseMountpoint = %q, want %q", got, want) + } +} diff --git a/cmd/elfuse-oci/store.go b/cmd/elfuse-oci/store.go index ec7972af..657f7561 100644 --- a/cmd/elfuse-oci/store.go +++ b/cmd/elfuse-oci/store.go @@ -163,18 +163,14 @@ func fsyncDir(dir string) error { // a pull racing an rmi); without it, last-writer-wins on refs.json can drop a // just-recorded pin. func (s *store) lock() (func(), error) { - f, err := os.OpenFile(filepath.Join(s.root, ".lock"), os.O_CREATE|os.O_RDWR, 0o644) + // acquireFlock rather than a bare Flock: it retries EINTR (run's signal + // forwarding can interrupt a blocking wait) and re-checks the lock file's + // identity, one lock discipline for the whole package. + l, err := acquireFlock(filepath.Join(s.root, ".lock"), syscall.LOCK_EX) if err != nil { - return nil, fmt.Errorf("store: open lock: %w", err) - } - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { - f.Close() return nil, fmt.Errorf("store: lock: %w", err) } - return func() { - _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) - _ = f.Close() - }, nil + return func() { _ = l.Close() }, nil } // withLock runs fn while holding the store lock. Results cross the closure diff --git a/go.mod b/go.mod index d004d0b6..69efb907 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/sysprog21/elfuse go 1.26.4 -require github.com/google/go-containerregistry v0.21.7 +require ( + github.com/google/go-containerregistry v0.21.7 + golang.org/x/sys v0.46.0 +) require ( github.com/docker/cli v29.5.3+incompatible // indirect @@ -12,6 +15,5 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect gotest.tools/v3 v3.5.2 // indirect ) From 2ad31d6aedf3396748a0d34096859a0ff6a34040 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 15 Jul 2026 23:33:29 +0200 Subject: [PATCH 18/22] Add OCI lifecycle commands and reachability GC Add list/images, rmi, and prune on top of the OCI image-layout store. rmi and prune use reachability GC: shared manifests, configs, and layers stay on disk while any remaining ref reaches them. Cache cleanup handles plain rootfs caches and macOS sparsebundle caches through platform-specific seams, with safety rules for active workloads: a prune without --all never touches a still-pinned digest's cache, and a cache a live run still uses is never reclaimed. Liveness is the same flock discipline on both cache kinds: the bundle's run.lock, and for the plain digest-keyed rootfs a sibling .lock that a run holds shared from before the existence probe until guest exit. The plain run path execs elfuse in place, so the lock descriptor is made exec-survivable: the kernel releases it exactly when the elfuse process exits, SIGKILL included. prune takes each lock exclusively non-blocking and skips busy caches (dry runs never advertise them); rmi refuses a busy cache even with --force, before any pin or descriptor is touched. The lock is a sibling of the cache dir, not inside it, because the dir is the guest's / and its existence is unpackImage's atomic-rename publication signal. prune's GC sweep and list's snapshot run under the store lock, closing the windows where a concurrent pull's fresh blobs could be reclaimed before their descriptor lands or a listing could observe a half-removed ref. prune scopes its GC-plus-cache sweep with store.withLock and drops the lock before the reporting output; list and rmi keep the explicit acquire-and-defer because their entire body is the critical section, so a closure would only add indentation. list reports the full os/arch/variant platform. Two corrections to the unpack path come with the locking, because both are what the lock discipline needs to be true. unpackImage now takes the caller's already-resolved image rather than re-resolving a ref: the caller keys the cache by the digest it resolved, so a re-resolution could observe a different pin from a concurrent repull and fill digest A's cache with image B's content. resolveImageForUse pairs the resolution with the digest lock, which is why the fix lands here rather than in the commit that introduced unpackImage. Layer application also tracks the ancestors of each entry, so an opaque whiteout arriving after an implicit parent directory no longer clears content the same layer just added. Tests cover reachability GC (shared blobs, stale temp blobs, digest prefixes), the prune and rmi busy semantics for both cache kinds (live holder, stale lock file, dry-run visibility), lock survival across the exec boundary (FD_CLOEXEC clearing plus a child-process flock-lifetime proxy), which runs hold the lock at exec time, and end-to-end command wrappers over pull/run/list/rmi/prune. The darwin cache sweep is driven through the isMountPointFn and detachForce seams with reapSweepableClones deciding the reap set, so the sweep is testable without real disk images. --- cmd/elfuse-oci/bundlelock_test.go | 68 ++ cmd/elfuse-oci/cache_darwin.go | 316 +++++ cmd/elfuse-oci/cache_darwin_test.go | 612 ++++++++++ cmd/elfuse-oci/cache_key.go | 13 - cmd/elfuse-oci/cache_other.go | 54 + cmd/elfuse-oci/clone_sweep.go | 150 +++ cmd/elfuse-oci/commands.go | 124 +- cmd/elfuse-oci/commands_integration_test.go | 580 +++++++++ cmd/elfuse-oci/common_test.go | 47 +- cmd/elfuse-oci/csrun.go | 68 +- cmd/elfuse-oci/csrun_darwin_test.go | 171 ++- cmd/elfuse-oci/csrun_other.go | 2 +- cmd/elfuse-oci/gc.go | 415 +++++++ cmd/elfuse-oci/inspect.go | 13 +- cmd/elfuse-oci/inspect_test.go | 74 ++ cmd/elfuse-oci/lifecycle_darwin_test.go | 119 ++ cmd/elfuse-oci/lifecycle_test.go | 1218 +++++++++++++++++++ cmd/elfuse-oci/list.go | 143 +++ cmd/elfuse-oci/main.go | 30 +- cmd/elfuse-oci/main_command_test.go | 69 ++ cmd/elfuse-oci/prune.go | 223 ++++ cmd/elfuse-oci/rmi.go | 59 + cmd/elfuse-oci/rootfslock.go | 169 +++ cmd/elfuse-oci/run.go | 53 +- cmd/elfuse-oci/run_test.go | 30 +- cmd/elfuse-oci/sparsebundle.go | 10 - cmd/elfuse-oci/sparsebundle_test.go | 151 ++- cmd/elfuse-oci/store.go | 191 ++- cmd/elfuse-oci/store_test.go | 303 +++++ cmd/elfuse-oci/test_helpers_test.go | 22 +- cmd/elfuse-oci/unpack.go | 80 +- cmd/elfuse-oci/unpack_image_test.go | 94 +- cmd/elfuse-oci/unpack_test.go | 12 +- 33 files changed, 5466 insertions(+), 217 deletions(-) create mode 100644 cmd/elfuse-oci/cache_darwin.go create mode 100644 cmd/elfuse-oci/cache_darwin_test.go create mode 100644 cmd/elfuse-oci/cache_other.go create mode 100644 cmd/elfuse-oci/clone_sweep.go create mode 100644 cmd/elfuse-oci/commands_integration_test.go create mode 100644 cmd/elfuse-oci/gc.go create mode 100644 cmd/elfuse-oci/lifecycle_darwin_test.go create mode 100644 cmd/elfuse-oci/lifecycle_test.go create mode 100644 cmd/elfuse-oci/list.go create mode 100644 cmd/elfuse-oci/prune.go create mode 100644 cmd/elfuse-oci/rmi.go create mode 100644 cmd/elfuse-oci/rootfslock.go diff --git a/cmd/elfuse-oci/bundlelock_test.go b/cmd/elfuse-oci/bundlelock_test.go index 05ee7451..6b80d31e 100644 --- a/cmd/elfuse-oci/bundlelock_test.go +++ b/cmd/elfuse-oci/bundlelock_test.go @@ -6,9 +6,12 @@ package main import ( "errors" "os" + "os/exec" "path/filepath" "syscall" "testing" + + "golang.org/x/sys/unix" ) func TestAcquireFlockSharedCoexists(t *testing.T) { @@ -121,6 +124,71 @@ func TestBundleLockPaths(t *testing.T) { } } +// TestPreserveAcrossExecClearsCloexec pins the exec-survival half of the +// plain-rootfs run lock: Go opens files close-on-exec, so without the +// FD_SETFD clear the flock would silently drop at syscall.Exec. +func TestPreserveAcrossExecClearsCloexec(t *testing.T) { + l, err := acquireFlock(filepath.Join(t.TempDir(), "x.lock"), syscall.LOCK_SH) + if err != nil { + t.Fatal(err) + } + defer l.Close() + flags, err := unix.FcntlInt(l.f.Fd(), unix.F_GETFD, 0) + if err != nil { + t.Fatal(err) + } + if flags&unix.FD_CLOEXEC == 0 { + t.Fatal("lock fd unexpectedly not close-on-exec before PreserveAcrossExec") + } + if err := l.PreserveAcrossExec(); err != nil { + t.Fatal(err) + } + flags, err = unix.FcntlInt(l.f.Fd(), unix.F_GETFD, 0) + if err != nil { + t.Fatal(err) + } + if flags&unix.FD_CLOEXEC != 0 { + t.Fatal("FD_CLOEXEC still set after PreserveAcrossExec") + } +} + +// TestRootfsLockSurvivesIntoChildProcess models the exec handoff with the +// closest testable analog: hand the lock's descriptor to a child via +// ExtraFiles (a dup shares the open file description, exactly like exec +// inheritance), drop the parent's fd WITHOUT unlocking, and require the +// flock to live precisely as long as the child: released by the kernel at +// kill, no unlock code run. An in-process syscall.Exec is untestable by +// construction; this pins the same kernel behavior the run path relies on. +func TestRootfsLockSurvivesIntoChildProcess(t *testing.T) { + dir := filepath.Join(t.TempDir(), "cache") + hold, err := acquireRootfsRunLock(dir) + if err != nil { + t.Fatal(err) + } + child := exec.Command("/bin/sleep", "30") + child.ExtraFiles = []*os.File{hold.f} + if err := child.Start(); err != nil { + t.Fatal(err) + } + // Close the parent's fd only (no LOCK_UN), mirroring the process image + // being replaced. The child's dup keeps the description locked. + if err := hold.f.Close(); err != nil { + t.Fatal(err) + } + hold.f = nil + + if !rootfsCacheBusy(dir) { + t.Error("lock not held while child lives; exec inheritance would drop it") + } + if err := child.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = child.Wait() + if rootfsCacheBusy(dir) { + t.Error("lock still held after child death; kernel should have released it") + } +} + // TestAcquireAttachLockRecreatesSweptBundleDir pins the provision-vs-sweep // race recovery: when a prune --cache --all removed the whole bundle dir // after provision's MkdirAll, the attach.lock open fails with ENOENT on the diff --git a/cmd/elfuse-oci/cache_darwin.go b/cmd/elfuse-oci/cache_darwin.go new file mode 100644 index 00000000..5a09ebef --- /dev/null +++ b/cmd/elfuse-oci/cache_darwin.go @@ -0,0 +1,316 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "syscall" +) + +// On Darwin an unpacked cache can be either (or both) of: +// - a case-sensitive APFS sparsebundle bundle at cs/// holding the +// warm unpacked base tree (image file rootfs.sparsebundle + mount point mnt), +// - a plain rootfs// directory (the --plain-rootfs path). +// +// cacheExists / removeRefCaches / pruneCaches are the lifecycle seam the +// cross-platform gc.go/rmi.go/prune.go code calls; the darwin versions add the +// sparsebundle lifecycle (detach a still-mounted volume before removing its +// bundle directory) on top of the shared rootfs sweep. + +// cacheHasKeptData reports whether digest's cache holds run --keep retained +// output. A --keep run drops the `kept` sidecar beside the sparsebundle (outside +// the mounted volume, like the bundle flocks), so this is a cheap stat that does +// not need to attach a cold, detached bundle to inspect its clones. rmi refuses +// to reclaim such a cache without force. +func cacheHasKeptData(root, digest string) (bool, error) { + bundle, err := csBundleDirForDigest(root, digest) + if err != nil { + // An unparseable digest key has no bundle and so no kept data; a real + // rmi target is always a valid digest. + return false, nil + } + if _, err := os.Stat(keptSidecarPath(bundle)); err == nil { + return true, nil + } else if !os.IsNotExist(err) { + return false, err + } + return false, nil +} + +// cacheExists reports whether digest has any unpacked cache under the store: the +// case-sensitive sparsebundle bundle and/or the plain rootfs directory. +func cacheExists(root, digest string) bool { + bundle, err := csBundleDirForDigest(root, digest) + if err == nil { + if _, err := os.Stat(bundle); err == nil { + return true + } + } + rootfs, err := defaultRootfsForDigest(root, digest) + if err != nil { + return false + } + if _, err := os.Stat(rootfs); err == nil { + return true + } + return false +} + +// removeRefCaches deletes digest's unpacked caches: the case-sensitive +// sparsebundle and the plain rootfs, when a digest has both. A crash leftover +// mount is recovered (orphan clones reaped, stale volume detached) so the +// bundle can be removed, but a volume that still hosts a live run's clone, or a +// plain rootfs a live --plain-rootfs run holds, refuses: dropping the cache +// (via rmi) means "reclaim derived state", not "rip the rootfs out from under a +// running guest". +// +// Both cache locks are preflighted before either form is deleted, so a busy +// side leaves BOTH caches intact. Deleting the sparsebundle first and only then +// discovering the plain rootfs is busy (or vice versa) would strand a +// half-deleted cache under a still-live pin. The plain reference lock is taken +// first, matching a run's acquisition order (reference lock, then bundle +// locks); both acquisitions are non-blocking, so the order cannot deadlock. +func removeRefCaches(s *store, digest string) error { + bundle, err := csBundleDirForDigest(s.root, digest) + if err != nil { + return err + } + rootfs, err := defaultRootfsForDigest(s.root, digest) + if err != nil { + return err + } + busyErr := fmt.Errorf("cache for %s is in use by a live run; stop it before removing the image", digest) + + // Preflight the plain rootfs lock. An ENOENT (no rootfs/ scaffolding) means + // no plain cache and no plain run can exist, so there is nothing to hold. + rootfsUnlock, rootfsBusy, err := lockRootfsCacheForRemoval(rootfs) + haveRootfsLock := err == nil + if err != nil && !os.IsNotExist(err) { + return err + } + if rootfsBusy { + return busyErr + } + if haveRootfsLock { + defer rootfsUnlock() + } + + if _, err := os.Stat(bundle); err == nil { + _, busy, unlock, err := sweepCSBundle(bundle) + if err != nil { + return err + } + if busy { + // The plain lock (if held) is released by the deferred unlock; no + // cache was deleted, so the refusal leaves both forms intact. + return busyErr + } + // Hold the bundle locks across the removal: a concurrent run's + // provision would otherwise race in between the sweep and the + // RemoveAll and lose its freshly attached volume. + err = os.RemoveAll(bundle) + unlock() + if err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + + // Plain rootfs, removed under the lock already held from the preflight. + if haveRootfsLock { + if err := os.RemoveAll(rootfs); err != nil { + return err + } + if err := os.Remove(rootfsCacheLockPath(rootfs)); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +// pruneCaches drops elfuse's unpacked caches. Without opts.all, only caches for +// refs no longer pinned (orphan caches) are dropped; with opts.all, every +// cache. The plain rootfs sweep is shared (pruneRootfsCaches); the darwin-only +// sparsebundle sweep walks cs/// plus legacy cs// directories, +// detaching a still-mounted volume before removing its bundle. The bytes +// reported for a sparsebundle are the on-disk allocation of its image file +// (dirSize of rootfs.sparsebundle), not the 16g virtual ceiling and not a live +// mount's contents. +func (s *store) pruneCaches(opts pruneOpts) (pruneReport, error) { + live, err := s.liveCacheKeys() + if err != nil { + return pruneReport{}, err + } + rep, err := pruneRootfsCaches(s, live, opts) + if err != nil { + return rep, err + } + + csBase := filepath.Join(s.root, csCacheDirName) + entries, err := os.ReadDir(csBase) + if err != nil { + if os.IsNotExist(err) { + return rep, nil + } + return rep, err + } + for _, e := range entries { + if !e.IsDir() { + continue + } + top := filepath.Join(csBase, e.Name()) + if e.Name() == "sha256" { + children, err := os.ReadDir(top) + if err != nil { + return rep, err + } + for _, child := range children { + if !child.IsDir() { + continue + } + key := filepath.Join("sha256", child.Name()) + bundle := filepath.Join(top, child.Name()) + var err error + rep, err = pruneCSBundle(rep, bundle, key, live, opts) + if err != nil { + return rep, err + } + } + continue + } + + // Legacy ref-named sparsebundle caches are no longer live under the + // digest-keyed scheme; prune --cache reclaims them as orphan caches. + var err error + rep, err = pruneCSBundle(rep, top, "", live, opts) + if err != nil { + return rep, err + } + } + return rep, nil +} + +func pruneCSBundle(rep pruneReport, bundle, key string, live map[string]bool, opts pruneOpts) (pruneReport, error) { + // A still-pinned digest is off-limits to a non---all prune BEFORE any + // sweep: its attached volume may belong to an active run, and + // sweepCSBundle cannot tell a crashed leftover mount from a live one by + // mount state alone. A crashed pinned bundle's stale mount is recovered + // by the next run's provision (which re-attaches cleanly) or by an + // explicit prune --cache --all. + if key != "" && !opts.all && live[key] { + return rep, nil + } + + // Crash recovery: if a killed run left this bundle's volume attached, + // reap orphan COW clones inside it and detach the stale mount. If a live + // run still owns a clone in the volume, leave the whole bundle alone; + // force-detaching would rip the rootfs out from under that guest + // (reachable with --all, or via a legacy/unpinned bundle). A dry-run + // makes the same decisions read-only: it reports the orphan clones a + // real prune would reap and skips a busy bundle a real prune would + // leave, but never detaches or removes anything. + if opts.dryRun { + // A busy bundle (a live run holds run.lock) is left alone, exactly as + // a real prune would; otherwise report the clones a real sweep would + // reap. Read-only: probe the locks and list, never detach or remove. + if csBundleBusy(bundle) { + return rep, nil + } + mnt := filepath.Join(bundle, "mnt") + if isMountPointFn(mnt) { + rep.CacheDirs = append(rep.CacheDirs, listSweepableClones(mnt)...) + } + image := filepath.Join(bundle, "rootfs.sparsebundle") + rep.Bytes += dirSize(image) + rep.CacheDirs = append(rep.CacheDirs, bundle) + return rep, nil + } + + reaped, busy, unlock, err := sweepCSBundle(bundle) + if err != nil { + return rep, err + } + if len(reaped) > 0 { + rep.CacheDirs = append(rep.CacheDirs, reaped...) + } + if busy { + return rep, nil + } + + image := filepath.Join(bundle, "rootfs.sparsebundle") + rep.Bytes += dirSize(image) + // sweepCSBundle already detached a stale mount if there was one, and holds + // the bundle locks so a concurrent provision cannot re-populate the bundle + // between here and the removal. + err = os.RemoveAll(bundle) + unlock() + if err != nil { + return rep, err + } + rep.CacheDirs = append(rep.CacheDirs, bundle) + return rep, nil +} + +// sweepCSBundle performs crash recovery for one sparsebundle bundle under the +// per-bundle advisory flocks. It acquires attach.lock and run.lock +// exclusively (non-blocking): if either is held, a live run (or an in-flight +// provision) owns the bundle, so it returns busy=true without touching +// anything. Holding run.lock exclusively proves no run is executing out of the +// volume, so every clone left inside is abandoned by construction: reap the +// sweepable ones (all but --keep-marked clones, plus unpack leftovers) and +// detach a still-attached stale mount. +// +// On success it returns the reaped clone directories and an unlock func that +// releases both locks. The caller must invoke unlock AFTER it finishes with +// the bundle (typically after os.RemoveAll), so a concurrent provision cannot +// re-attach and re-populate the bundle in the gap. On busy or error the +// returned unlock is a non-nil no-op, so callers may always defer it. +func sweepCSBundle(bundle string) (reaped []string, busy bool, unlock func(), err error) { + noop := func() {} + attachLock, err := acquireFlock(attachLockPath(bundle), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + if errors.Is(err, errCacheBusy) { + return nil, true, noop, nil + } + return nil, false, noop, err + } + runLock, err := acquireFlock(runLockPath(bundle), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + attachLock.Close() + if errors.Is(err, errCacheBusy) { + return nil, true, noop, nil + } + return nil, false, noop, err + } + release := func() { + runLock.Close() + attachLock.Close() + } + + mnt := filepath.Join(bundle, "mnt") + // Reject a symlinked mount path before any mount-status probe, clone reap, + // or detach: isMountPoint (os.Stat) and detachForce follow the link, so a + // tampered store with mnt symlinked at an unrelated attached volume would + // otherwise get that volume's contents reaped and the volume force-detached. + // provisionCaseSensitive guards its own attach path the same way; the + // destructive sweep (prune --cache, rmi) needs the guard too. + if li, err := os.Lstat(mnt); err == nil && li.Mode()&os.ModeSymlink != 0 { + release() + return nil, false, noop, fmt.Errorf("mount path %s is a symlink; refusing to detach/reap", mnt) + } + if isMountPointFn(mnt) { + reaped = reapSweepableClones(mnt) + if err := detachForce(mnt); err != nil { + release() + return reaped, false, noop, fmt.Errorf("detach %s: %w", mnt, err) + } + } + return reaped, false, release, nil +} diff --git a/cmd/elfuse-oci/cache_darwin_test.go b/cmd/elfuse-oci/cache_darwin_test.go new file mode 100644 index 00000000..10465300 --- /dev/null +++ b/cmd/elfuse-oci/cache_darwin_test.go @@ -0,0 +1,612 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "errors" + "os" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" +) + +func withDarwinCacheSeams(t *testing.T, isMount func(string) bool, detach func(string) error) { + t.Helper() + oldIsMount := isMountPointFn + oldDetach := detachForce + if isMount != nil { + isMountPointFn = isMount + } + if detach != nil { + detachForce = detach + } + t.Cleanup(func() { + isMountPointFn = oldIsMount + detachForce = oldDetach + }) +} + +// holdRunLock takes a shared run.lock on the bundle for the test's lifetime, +// simulating a live run so a sweep sees the bundle busy. +func holdRunLock(t *testing.T, bundle string) { + t.Helper() + if err := os.MkdirAll(bundle, 0o755); err != nil { + t.Fatal(err) + } + l, err := acquireFlock(runLockPath(bundle), syscall.LOCK_SH) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { l.Close() }) +} + +func writeSparseBundleMarker(t *testing.T, bundle string) { + t.Helper() + image := filepath.Join(bundle, "rootfs.sparsebundle") + if err := os.MkdirAll(image, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(image, "band"), []byte("data"), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestDarwinCacheExistsBundleAndPlainRootfs(t *testing.T) { + root := t.TempDir() + digest := "sha256:" + strings.Repeat("1", 64) + if cacheExists(root, digest) { + t.Fatal("cacheExists returned true for absent cache") + } + + bundle, err := csBundleDirForDigest(root, digest) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(bundle, 0o755); err != nil { + t.Fatal(err) + } + if !cacheExists(root, digest) { + t.Fatal("cacheExists returned false for sparsebundle cache") + } + if err := os.RemoveAll(bundle); err != nil { + t.Fatal(err) + } + + rootfs, err := defaultRootfsForDigest(root, digest) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(rootfs, 0o755); err != nil { + t.Fatal(err) + } + if !cacheExists(root, digest) { + t.Fatal("cacheExists returned false for plain rootfs cache") + } + if cacheExists(root, "not-a-digest") { + t.Fatal("cacheExists returned true for invalid digest") + } +} + +func TestDarwinCacheHasKeptData(t *testing.T) { + root := t.TempDir() + digest := "sha256:" + strings.Repeat("6", 64) + + if kept, err := cacheHasKeptData(root, digest); err != nil || kept { + t.Fatalf("cacheHasKeptData absent = (%v, %v), want (false, nil)", kept, err) + } + + bundle, err := csBundleDirForDigest(root, digest) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(bundle, 0o755); err != nil { + t.Fatal(err) + } + // A bundle from a normal (non-keep) run has no sidecar: not kept. + if kept, err := cacheHasKeptData(root, digest); err != nil || kept { + t.Fatalf("cacheHasKeptData no-sidecar = (%v, %v), want (false, nil)", kept, err) + } + + if err := os.WriteFile(keptSidecarPath(bundle), nil, 0o644); err != nil { + t.Fatal(err) + } + if kept, err := cacheHasKeptData(root, digest); err != nil || !kept { + t.Fatalf("cacheHasKeptData with sidecar = (%v, %v), want (true, nil)", kept, err) + } +} + +// TestDarwinRmiRefusesKeptCacheWithoutForce pins the one case rmi still refuses: +// a bundle holding run --keep retained output is not discarded without --force, +// and --force then drops the whole bundle and removes the image. +func TestDarwinRmiRefusesKeptCacheWithoutForce(t *testing.T) { + withDarwinCacheSeams(t, func(string) bool { return false }, nil) + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + digest, err := s.addImage("local:a", img) + if err != nil { + t.Fatal(err) + } + bundle, err := csBundleDirForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(bundle, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keptSidecarPath(bundle), nil, 0o644); err != nil { + t.Fatal(err) + } + + if _, err := s.rmi("local:a", false); err == nil || !strings.Contains(err.Error(), "keep") { + t.Fatalf("rmi kept cache without --force err = %v, want --keep refusal", err) + } + if _, err := s.digestFor("local:a"); err != nil { + t.Fatalf("pin lost after refused rmi: %v", err) + } + if _, err := os.Stat(bundle); err != nil { + t.Fatalf("bundle removed after refused rmi: %v, want present", err) + } + + rep, err := s.rmi("local:a", true) + if err != nil { + t.Fatalf("rmi --force kept cache: %v", err) + } + if !rep.CacheDropped { + t.Error("rmi --force did not report dropping the cache") + } + if _, err := os.Stat(bundle); !os.IsNotExist(err) { + t.Fatalf("bundle after rmi --force: %v, want removed", err) + } + if _, err := s.digestFor("local:a"); err == nil { + t.Error("pin present after rmi --force, want gone") + } +} + +func TestDarwinRemoveRefCachesDropsBundleAndRootfs(t *testing.T) { + withDarwinCacheSeams(t, func(string) bool { return false }, nil) + s := &store{root: t.TempDir()} + digest := "sha256:" + strings.Repeat("2", 64) + bundle, err := csBundleDirForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + rootfs, err := defaultRootfsForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(bundle, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(rootfs, 0o755); err != nil { + t.Fatal(err) + } + + if err := removeRefCaches(s, digest); err != nil { + t.Fatalf("removeRefCaches: %v", err) + } + for _, p := range []string{bundle, rootfs} { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Fatalf("%s after removeRefCaches: %v, want IsNotExist", p, err) + } + } +} + +func TestDarwinRemoveRefCachesDetachesMountedBundle(t *testing.T) { + s := &store{root: t.TempDir()} + digest := "sha256:" + strings.Repeat("3", 64) + bundle, err := csBundleDirForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + mnt := filepath.Join(bundle, "mnt") + if err := os.MkdirAll(mnt, 0o755); err != nil { + t.Fatal(err) + } + var detached string + withDarwinCacheSeams(t, + func(path string) bool { return path == mnt }, + func(path string) error { + detached = path + return nil + }, + ) + + // No run holds run.lock, so the still-attached mount is stale: the sweep + // detaches it and the bundle is removed. + if err := removeRefCaches(s, digest); err != nil { + t.Fatalf("removeRefCaches: %v", err) + } + if detached != mnt { + t.Fatalf("detached = %q, want %q", detached, mnt) + } + if _, err := os.Stat(bundle); !os.IsNotExist(err) { + t.Fatalf("bundle after removeRefCaches: %v, want IsNotExist", err) + } +} + +func TestDarwinPruneCachesDropsOrphanAndLegacyCSBundles(t *testing.T) { + withDarwinCacheSeams(t, func(string) bool { return false }, nil) + s := openTestStore(t) + liveDigest := "sha256:" + strings.Repeat("4", 64) + orphanDigest := "sha256:" + strings.Repeat("5", 64) + if err := s.savePins(refPins{"live": liveDigest}); err != nil { + t.Fatal(err) + } + liveBundle, _ := csBundleDirForDigest(s.root, liveDigest) + orphanBundle, _ := csBundleDirForDigest(s.root, orphanDigest) + legacyBundle := filepath.Join(s.root, "cs", "legacy_ref") + for _, bundle := range []string{liveBundle, orphanBundle, legacyBundle} { + writeSparseBundleMarker(t, bundle) + } + + rep, err := s.pruneCaches(pruneOpts{cache: true}) + if err != nil { + t.Fatalf("pruneCaches: %v", err) + } + if !slices.Contains(rep.CacheDirs, orphanBundle) || !slices.Contains(rep.CacheDirs, legacyBundle) { + t.Fatalf("pruneCaches dirs = %v, want orphan and legacy bundles", rep.CacheDirs) + } + if slices.Contains(rep.CacheDirs, liveBundle) { + t.Fatalf("pruneCaches dropped live bundle %s: %v", liveBundle, rep.CacheDirs) + } + if _, err := os.Stat(orphanBundle); !os.IsNotExist(err) { + t.Fatalf("orphan bundle after prune: %v, want IsNotExist", err) + } + if _, err := os.Stat(legacyBundle); !os.IsNotExist(err) { + t.Fatalf("legacy bundle after prune: %v, want IsNotExist", err) + } + if _, err := os.Stat(liveBundle); err != nil { + t.Fatalf("live bundle after prune: %v, want present", err) + } +} + +// TestDarwinPruneCSBundleDryRunDoesNotSweepOrDelete pins that a dry-run makes +// the same decisions as a real prune (report the clones a real sweep would +// reap, skip a busy bundle) while never detaching or removing anything. The +// busy check is a real (non-blocking) probe of the bundle locks. +func TestDarwinPruneCSBundleDryRunDoesNotSweepOrDelete(t *testing.T) { + key := filepath.Join("sha256", strings.Repeat("6", 64)) + dryRun := func(t *testing.T, bundle string) pruneReport { + t.Helper() + rep, err := pruneCSBundle(pruneReport{}, bundle, key, nil, pruneOpts{cache: true, dryRun: true}) + if err != nil { + t.Fatalf("pruneCSBundle dry-run: %v", err) + } + if _, err := os.Stat(bundle); err != nil { + t.Fatalf("dry-run removed bundle: %v", err) + } + return rep + } + noDetach := func(t *testing.T, isMount func(string) bool) { + t.Helper() + withDarwinCacheSeams(t, isMount, func(string) error { + t.Fatal("detachForce called during dry-run") + return nil + }) + } + + t.Run("unmounted bundle reported", func(t *testing.T) { + bundle := filepath.Join(t.TempDir(), "bundle") + writeSparseBundleMarker(t, bundle) + noDetach(t, func(string) bool { return false }) + + rep := dryRun(t, bundle) + if len(rep.CacheDirs) != 1 || rep.CacheDirs[0] != bundle { + t.Fatalf("dry-run dirs = %v, want [%s]", rep.CacheDirs, bundle) + } + }) + + t.Run("sweepable clone reported", func(t *testing.T) { + bundle := filepath.Join(t.TempDir(), "bundle") + writeSparseBundleMarker(t, bundle) + mnt := filepath.Join(bundle, "mnt") + orphan := filepath.Join(mnt, "run-42-1") + if err := os.MkdirAll(orphan, 0o755); err != nil { + t.Fatal(err) + } + noDetach(t, func(path string) bool { return path == mnt }) + + rep := dryRun(t, bundle) + if len(rep.CacheDirs) != 2 || rep.CacheDirs[0] != orphan || rep.CacheDirs[1] != bundle { + t.Fatalf("dry-run dirs = %v, want [%s %s]", rep.CacheDirs, orphan, bundle) + } + if rep.Bytes == 0 { + t.Fatal("dry-run Bytes = 0, want the bundle's on-disk size counted") + } + if _, err := os.Stat(orphan); err != nil { + t.Fatalf("dry-run reaped %s, want read-only: %v", orphan, err) + } + }) + + t.Run("busy bundle skipped", func(t *testing.T) { + bundle := filepath.Join(t.TempDir(), "bundle") + writeSparseBundleMarker(t, bundle) + mnt := filepath.Join(bundle, "mnt") + noDetach(t, func(path string) bool { return path == mnt }) + holdRunLock(t, bundle) // a live run holds run.lock + + rep := dryRun(t, bundle) + if len(rep.CacheDirs) != 0 { + t.Fatalf("dry-run dirs = %v, want empty for a busy bundle", rep.CacheDirs) + } + if rep.Bytes != 0 { + t.Fatalf("dry-run Bytes = %d, want 0 for a busy bundle", rep.Bytes) + } + }) +} + +// TestDarwinSweepCSBundleIdleReapsAndHoldsLocks pins that an idle bundle +// (no run holds run.lock) is swept: a mounted volume's sweepable clones are +// reaped, the stale mount detached, and the returned unlock releases the +// bundle locks the sweep held for the caller's removal. +func TestDarwinSweepCSBundleIdleReapsAndHoldsLocks(t *testing.T) { + unmounted := filepath.Join(t.TempDir(), "bundle") + if err := os.MkdirAll(unmounted, 0o755); err != nil { + t.Fatal(err) + } + withDarwinCacheSeams(t, func(string) bool { return false }, func(string) error { + t.Fatal("detachForce called for non-mount") + return nil + }) + reaped, busy, unlock, err := sweepCSBundle(unmounted) + if err != nil { + t.Fatalf("sweepCSBundle no mount: %v", err) + } + if busy || len(reaped) != 0 { + t.Fatalf("sweepCSBundle no mount = (reaped %v, busy %v), want idle empty", reaped, busy) + } + // While the sweep holds the locks, a would-be run's exclusive probe fails. + if _, err := acquireFlock(runLockPath(unmounted), syscall.LOCK_EX|syscall.LOCK_NB); !errors.Is(err, errCacheBusy) { + t.Fatalf("run.lock during sweep err = %v, want held", err) + } + unlock() + if free, err := acquireFlock(runLockPath(unmounted), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatalf("run.lock after unlock err = %v, want free", err) + } else { + free.Close() + } + + bundle := filepath.Join(t.TempDir(), "bundle") + mnt := filepath.Join(bundle, "mnt") + clone := filepath.Join(mnt, "run-1-1") + if err := os.MkdirAll(clone, 0o755); err != nil { + t.Fatal(err) + } + var detached string + withDarwinCacheSeams(t, + func(path string) bool { return path == mnt }, + func(path string) error { detached = path; return nil }, + ) + reaped, busy, unlock, err = sweepCSBundle(bundle) + if err != nil { + t.Fatalf("sweepCSBundle mounted: %v", err) + } + defer unlock() + if busy { + t.Fatal("sweepCSBundle mounted-but-idle reported busy") + } + if len(reaped) != 1 || reaped[0] != clone { + t.Fatalf("mounted reaped = %v, want [%s]", reaped, clone) + } + if detached != mnt { + t.Fatalf("detached = %q, want %q", detached, mnt) + } + if _, err := os.Stat(clone); !os.IsNotExist(err) { + t.Fatalf("sweepable clone not reaped: %v", err) + } +} + +// TestDarwinSweepCSBundleBusySkips pins that a bundle whose run.lock a live +// run holds is reported busy and NOT force-detached. +func TestDarwinSweepCSBundleBusySkips(t *testing.T) { + bundle := filepath.Join(t.TempDir(), "bundle") + mnt := filepath.Join(bundle, "mnt") + if err := os.MkdirAll(mnt, 0o755); err != nil { + t.Fatal(err) + } + withDarwinCacheSeams(t, + func(path string) bool { return path == mnt }, + func(string) error { + t.Fatal("detachForce called although a live run holds the volume") + return nil + }, + ) + holdRunLock(t, bundle) + + reaped, busy, unlock, err := sweepCSBundle(bundle) + if err != nil { + t.Fatalf("sweepCSBundle: %v", err) + } + defer unlock() + if !busy { + t.Fatal("sweepCSBundle did not report busy for a live run") + } + if len(reaped) != 0 { + t.Fatalf("reaped = %v, want empty", reaped) + } +} + +// TestDarwinPruneCSBundleSkipsLivePinnedBeforeSweep pins the guard order: a +// still-pinned digest's bundle is skipped by a non---all prune before any +// sweep runs, so an active run's mount is never probed or detached. +func TestDarwinPruneCSBundleSkipsLivePinnedBeforeSweep(t *testing.T) { + bundle := filepath.Join(t.TempDir(), "bundle") + writeSparseBundleMarker(t, bundle) + key := filepath.Join("sha256", strings.Repeat("7", 64)) + withDarwinCacheSeams(t, + func(string) bool { + t.Fatal("isMountPoint probed for a live pinned bundle") + return false + }, + func(string) error { + t.Fatal("detachForce called for a live pinned bundle") + return nil + }, + ) + + rep, err := pruneCSBundle(pruneReport{}, bundle, key, map[string]bool{key: true}, pruneOpts{cache: true}) + if err != nil { + t.Fatalf("pruneCSBundle: %v", err) + } + if len(rep.CacheDirs) != 0 || rep.Bytes != 0 { + t.Fatalf("live pinned bundle was touched: %+v", rep) + } + if _, err := os.Stat(bundle); err != nil { + t.Fatalf("live pinned bundle missing after prune: %v", err) + } +} + +// TestDarwinPruneCSBundleLeavesBusyBundle pins that even when the sweep runs +// (e.g. --all), a bundle whose volume hosts a live run is left in place. +func TestDarwinPruneCSBundleLeavesBusyBundle(t *testing.T) { + bundle := filepath.Join(t.TempDir(), "bundle") + mnt := filepath.Join(bundle, "mnt") + writeSparseBundleMarker(t, bundle) + key := filepath.Join("sha256", strings.Repeat("8", 64)) + withDarwinCacheSeams(t, + func(path string) bool { return path == mnt }, + func(string) error { + t.Fatal("detachForce called although a live run holds the volume") + return nil + }, + ) + holdRunLock(t, bundle) + + rep, err := pruneCSBundle(pruneReport{}, bundle, key, map[string]bool{key: true}, pruneOpts{cache: true, all: true}) + if err != nil { + t.Fatalf("pruneCSBundle: %v", err) + } + if len(rep.CacheDirs) != 0 { + t.Fatalf("busy bundle reported as pruned: %v", rep.CacheDirs) + } + if _, err := os.Stat(bundle); err != nil { + t.Fatalf("busy bundle missing after prune: %v", err) + } +} + +// TestDarwinRemoveRefCachesRefusesLiveRun pins the rmi --force guard: a +// volume whose run.lock a live run holds must refuse cache removal instead of +// force-detaching the guest's rootfs. +func TestDarwinRemoveRefCachesRefusesLiveRun(t *testing.T) { + s := &store{root: t.TempDir()} + digest := "sha256:" + strings.Repeat("7", 64) + bundle, err := csBundleDirForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + mnt := filepath.Join(bundle, "mnt") + if err := os.MkdirAll(mnt, 0o755); err != nil { + t.Fatal(err) + } + detached := false + withDarwinCacheSeams(t, + func(path string) bool { return path == mnt }, + func(string) error { + detached = true + return nil + }, + ) + holdRunLock(t, bundle) + + err = removeRefCaches(s, digest) + if err == nil || !strings.Contains(err.Error(), "in use by a live run") { + t.Fatalf("removeRefCaches err = %v, want live-run refusal", err) + } + if detached { + t.Fatal("removeRefCaches force-detached a live run's volume") + } + if _, err := os.Stat(bundle); err != nil { + t.Fatalf("bundle after refusal: %v, want untouched", err) + } +} + +// TestDarwinSweepCSBundleRejectsSymlinkedMnt pins S8: a tampered store whose +// mnt is a symlink at an unrelated volume must be refused before any mount +// probe, clone reap, or detach, so the sweep cannot force-detach or wipe that +// volume. provisionCaseSensitive guards its attach path the same way. +func TestDarwinSweepCSBundleRejectsSymlinkedMnt(t *testing.T) { + bundle := filepath.Join(t.TempDir(), "bundle") + if err := os.MkdirAll(bundle, 0o755); err != nil { + t.Fatal(err) + } + victim := filepath.Join(t.TempDir(), "unrelated-volume") + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + mnt := filepath.Join(bundle, "mnt") + if err := os.Symlink(victim, mnt); err != nil { + t.Fatal(err) + } + withDarwinCacheSeams(t, + func(string) bool { + t.Fatal("isMountPoint probed a symlinked mnt") + return false + }, + func(string) error { + t.Fatal("detachForce followed a symlinked mnt") + return nil + }, + ) + + _, _, unlock, err := sweepCSBundle(bundle) + if unlock != nil { + unlock() + } + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("sweepCSBundle symlinked mnt err = %v, want symlink refusal", err) + } + if _, err := os.Lstat(mnt); err != nil { + t.Fatalf("symlink mnt disturbed: %v", err) + } +} + +// TestDarwinRemoveRefCachesBusyPlainRootfsLeavesBundle: when a digest +// has both cache forms and a live --plain-rootfs run holds the plain rootfs +// lock, removeRefCaches must refuse before deleting either form, so the +// sparsebundle is not left half-removed under a still-live pin. The bundle +// survives and the plain rootfs is untouched. +func TestDarwinRemoveRefCachesBusyPlainRootfsLeavesBundle(t *testing.T) { + withDarwinCacheSeams(t, + func(string) bool { return false }, + func(string) error { + t.Fatal("detachForce called although the plain rootfs run is live") + return nil + }, + ) + s := &store{root: t.TempDir()} + digest := "sha256:" + strings.Repeat("9", 64) + bundle, err := csBundleDirForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + rootfs, err := defaultRootfsForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + writeSparseBundleMarker(t, bundle) + if err := os.MkdirAll(rootfs, 0o755); err != nil { + t.Fatal(err) + } + // A live --plain-rootfs run holds the plain rootfs lock shared. + hold, err := acquireRootfsRunLock(rootfs) + if err != nil { + t.Fatal(err) + } + defer hold.Close() + + err = removeRefCaches(s, digest) + if err == nil || !strings.Contains(err.Error(), "in use by a live run") { + t.Fatalf("removeRefCaches err = %v, want live-run refusal", err) + } + if _, err := os.Stat(bundle); err != nil { + t.Fatalf("sparsebundle removed despite refusal (half-deleted cache): %v", err) + } + if _, err := os.Stat(rootfs); err != nil { + t.Fatalf("plain rootfs removed despite refusal: %v", err) + } +} diff --git a/cmd/elfuse-oci/cache_key.go b/cmd/elfuse-oci/cache_key.go index a59597c8..d9651432 100644 --- a/cmd/elfuse-oci/cache_key.go +++ b/cmd/elfuse-oci/cache_key.go @@ -6,7 +6,6 @@ package main import ( "fmt" "path/filepath" - "strings" "github.com/google/go-containerregistry/pkg/v1" ) @@ -18,14 +17,6 @@ const ( csCacheDirName = "cs" ) -// legacyCacheNameForRef reproduces the pre-digest cache naming scheme, which -// flattened the ref itself into a single (intentionally lossy) path -// component. New caches are keyed by digest (cacheKeyForDigest); this helper -// survives to document the old layout. -func legacyCacheNameForRef(ref string) string { - return strings.NewReplacer("/", "_", ":", "_", "@", "_").Replace(ref) -} - // cacheKeyForDigest returns the relative cache key used under rootfs/ and cs/. // The current store writes sha256 blobs only; keep the algorithm component in // the path so the layout remains explicit and non-lossy. @@ -47,7 +38,3 @@ func defaultRootfsForDigest(store, digest string) (string, error) { } return filepath.Join(store, rootfsCacheDirName, key), nil } - -func legacyRootfsForRef(store, ref string) string { - return filepath.Join(store, rootfsCacheDirName, legacyCacheNameForRef(ref)) -} diff --git a/cmd/elfuse-oci/cache_other.go b/cmd/elfuse-oci/cache_other.go new file mode 100644 index 00000000..4e095657 --- /dev/null +++ b/cmd/elfuse-oci/cache_other.go @@ -0,0 +1,54 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build !darwin + +package main + +import "os" + +// On non-Darwin the case-sensitive sparsebundle path is unavailable (no APFS, +// no hdiutil, no clonefile), so an unpacked cache is only ever the plain +// rootfs// directory. The lifecycle primitives (rmi, prune) touch +// caches through cacheExists / removeRefCaches / pruneCaches so the pure-Go +// blob GC and the rootfs cache sweep build and test on Linux CI; the darwin +// sparsebundle sweep lives in cache_darwin.go. + +// cacheHasKeptData reports whether digest's cache holds run --keep retained +// output. On non-Darwin the only cache is a plain rootfs directory with no +// per-run COW clones, so there is never retained output to protect: rmi always +// reclaims it. +func cacheHasKeptData(root, digest string) (bool, error) { + return false, nil +} + +// cacheExists reports whether digest has an unpacked cache under the store. On +// non-Darwin this is just the plain rootfs directory. +func cacheExists(root, digest string) bool { + rootfs, err := defaultRootfsForDigest(root, digest) + if err != nil { + return false + } + if _, err := os.Stat(rootfs); err == nil { + return true + } + return false +} + +// removeRefCaches deletes digest's unpacked cache(s). On non-Darwin, the plain +// rootfs directory only, refusing while a live run holds its per-digest lock. +func removeRefCaches(s *store, digest string) error { + return removeRootfsCacheForDigest(s, digest) +} + +// pruneCaches drops elfuse's unpacked caches. Without opts.all, only caches for +// refs no longer pinned (orphan caches) are dropped; with opts.all, every +// cache. On non-Darwin only plain rootfs// directories exist; the +// rootfs sweep is shared via pruneRootfsCaches. +func (s *store) pruneCaches(opts pruneOpts) (pruneReport, error) { + live, err := s.liveCacheKeys() + if err != nil { + return pruneReport{}, err + } + return pruneRootfsCaches(s, live, opts) +} diff --git a/cmd/elfuse-oci/clone_sweep.go b/cmd/elfuse-oci/clone_sweep.go new file mode 100644 index 00000000..b3718cc4 --- /dev/null +++ b/cmd/elfuse-oci/clone_sweep.go @@ -0,0 +1,150 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "strings" + "syscall" +) + +// Per-run COW clones of the warm base tree live inside an attached sparsebundle +// volume as run-- directories (see csrun.go). A crashed unpack +// can also leave a rootfs.tmp- staging directory behind (see +// unpackImage). Liveness is decided by the per-bundle advisory flocks +// (bundlelock.go), never by pids: a sweep only reaps once it holds run.lock +// exclusively, which proves no run is executing out of the volume, so every +// clone it finds is abandoned by construction. Pid parsing is gone; pid +// reuse can no longer make a still-wanted clone look dead. +// +// A run started with --keep records a keep sidecar for its clone so the sweep +// preserves it; without the sidecar every run-* clone is reapable. +// +// These helpers are pure path/lock logic with no darwin-specific calls, so +// they build and unit-test on Linux; sweepCSBundle (which needs isMountPoint + +// detachForce) is darwin-only and lives in cache_darwin.go. + +// keepDirName is a mount-root directory holding one marker file per kept clone, +// named by clone directory name. It lives in the mount root, BESIDE the clones, +// never inside any clone: a clone directory is a guest's /, so a marker there +// would collide with an image that ships its own /.elfuse-keep and could be +// forged by the guest at runtime, either of which would make an ordinary run's +// clone masquerade as kept and leak past every sweep. The mount root is outside +// every guest's --sysroot view, so markers here are writable only by +// elfuse-oci. A sweep skips a clone whose marker is present; only +// whole-bundle removal (prune of an unpinned/--all bundle, rmi --force) +// reclaims a kept clone, and that deletes this directory with it. +const keepDirName = ".elfuse-keep" + +// keepDirPath returns the mount-root keep-marker directory. +func keepDirPath(mountPath string) string { + return filepath.Join(mountPath, keepDirName) +} + +// cloneKeepMarkerPath returns the keep marker path for the clone named +// cloneName under mountPath. +func cloneKeepMarkerPath(mountPath, cloneName string) string { + return filepath.Join(keepDirPath(mountPath), cloneName) +} + +// writeKeepMarker records that the COW clone at cloneDir should survive sweeps, +// via a marker in the mount-root keep directory, so a later sweep preserves it +// after the creating run has exited and released run.lock. +func writeKeepMarker(cloneDir string) error { + mountPath := filepath.Dir(cloneDir) + if err := os.MkdirAll(keepDirPath(mountPath), 0o755); err != nil { + return err + } + return touchFile(cloneKeepMarkerPath(mountPath, filepath.Base(cloneDir))) +} + +// touchFile creates (or truncates) an empty marker file. The markers carry +// no content; only their existence matters. +func touchFile(path string) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + return f.Close() +} + +// isRunCloneDir reports whether a directory entry name is a per-run COW clone +// (run--). The pid is not parsed; only the naming matters. +func isRunCloneDir(name string) bool { + return strings.HasPrefix(name, "run-") +} + +// isUnpackTempDir reports whether name is a leftover unpack staging directory +// (rootfs.tmp-) from a crashed unpackImage into the volume. +func isUnpackTempDir(name string) bool { + return strings.HasPrefix(name, "rootfs.tmp-") +} + +// listSweepableClones returns the reapable directories inside mountPath: every +// run-- clone WITHOUT a keep marker, plus any rootfs.tmp-* unpack +// leftover. The caller must hold run.lock exclusively (no live run), so a +// clone lacking a keep marker is guaranteed abandoned. Removes nothing; it is +// the read-only detection half shared with prune --dry-run. +func listSweepableClones(mountPath string) []string { + entries, err := os.ReadDir(mountPath) + if err != nil { + return nil + } + var sweepable []string + for _, e := range entries { + name := e.Name() + switch { + case isRunCloneDir(name): + if !e.IsDir() { + continue + } + // Preserve the clone unless its keep marker is DEFINITIVELY + // absent: err == nil means present, and a transient non-ENOENT + // error (e.g. EIO on the mounted volume) must not cause a + // --keep clone to be reaped; fail safe toward preservation. + if _, err := os.Stat(cloneKeepMarkerPath(mountPath, name)); !os.IsNotExist(err) { + continue + } + sweepable = append(sweepable, filepath.Join(mountPath, name)) + case isUnpackTempDir(name): + sweepable = append(sweepable, filepath.Join(mountPath, name)) + } + } + return sweepable +} + +// reapSweepableClones removes the directories listSweepableClones names. +// Removal is best-effort: a busy entry (e.g. still unmounting) is skipped and +// not reported. Returns the directories that were removed. The caller must +// hold run.lock exclusively. +func reapSweepableClones(mountPath string) []string { + var reaped []string + for _, dir := range listSweepableClones(mountPath) { + if err := os.RemoveAll(dir); err != nil { + continue // busy or evaporating; leave it for next time + } + reaped = append(reaped, dir) + } + return reaped +} + +// csBundleBusy reports whether a live run holds the bundle via a non-blocking +// exclusive probe of run.lock. It is the read-only busy check used by prune +// --dry-run and diagnostics; it acquires and immediately releases, mutating +// nothing. It deliberately does NOT touch attach.lock: attach.lock is a +// lifecycle lock whose holders (provision, sweep, a run's last-one-out Close) +// each own the mount's detach fate, and a read-only prober transiently +// holding it would fool a concurrent Close into skipping its detach (leaving +// the volume attached with no owner). Any error other than a clean +// acquisition fails closed (busy) so a dry-run never advertises a reap it +// could not safely perform. +func csBundleBusy(bundle string) bool { + r, err := acquireFlock(runLockPath(bundle), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + return true + } + r.Close() + return false +} diff --git a/cmd/elfuse-oci/commands.go b/cmd/elfuse-oci/commands.go index 54539a4f..aeeddfff 100644 --- a/cmd/elfuse-oci/commands.go +++ b/cmd/elfuse-oci/commands.go @@ -7,12 +7,65 @@ import ( "errors" "fmt" "os" + + "github.com/google/go-containerregistry/pkg/v1" ) // The four subcommands share common-flag parsing (common.go) and the OCI // image-layout store (store.go). pull/unpack/inspect are pure store ops; run // additionally resolves the runspec and execs elfuse (run.go, csrun.go). +// afterImageResolve fires just after resolveImageForUse returns, while the +// caller holds the per-digest reference lock but no store lock. Production +// no-op; tests inject a concurrent repull here to prove the caller keeps using +// the image it resolved (keyed by the locked digest) and not a repinned one. +var afterImageResolve = func(digest string) {} + +// resolveImageForUse resolves ref to its pinned image and returns it with the +// manifest digest and a held per-digest reference lock (the digest's plain +// rootfs run lock, taken shared). Resolution and lock acquisition happen in +// one store-locked critical section, so the digest returned is exactly the +// digest locked: a concurrent repull (which needs the store lock to move the +// pin) cannot slip between the two, and the caller can key digest-scoped +// caches without a later re-resolution poisoning them. The reference lock +// then marks the image in use for the caller's lifetime: rmi probes it (and +// refuses) before dropping the last pin's descriptor and blobs, even before +// any cache dir exists, so a cold run that has resolved but not yet unpacked +// is never GC'd out from under. +// +// The SH acquisition cannot block: the only EX takers (rmi, prune) hold the +// store lock, which this holds, so no EX holder can exist meanwhile. +func resolveImageForUse(s *store, ref string) (v1.Image, string, *flockFile, error) { + var img v1.Image + var digest string + var lock *flockFile + err := s.withLock(func() error { + var err error + img, err = s.image(ref) + if err != nil { + return err + } + // Digest reads the manifest blob; do it under the lock so the blob + // cannot vanish mid-read. + d, err := img.Digest() + if err != nil { + return err + } + digest = d.String() + rootfs, err := defaultRootfsForDigest(s.root, digest) + if err != nil { + return err + } + lock, err = acquireRootfsRunLock(rootfs) + return err + }) + if err != nil { + return nil, "", nil, err + } + afterImageResolve(digest) + return img, digest, lock, nil +} + // cmdPull implements `elfuse-oci pull [--store] [--platform] `. func cmdPull(args []string) error { cf, ref, err := parsePullArgs(args) @@ -46,21 +99,28 @@ func cmdUnpack(args []string) error { if err != nil { return err } - digest, err := s.digestFor(ref) + // resolveImageForUse pairs the image with the digest lock, so the cache + // keyed below by this digest is filled from this image even if the ref is + // repulled mid-unpack, and a concurrent rmi/prune cannot reclaim the tree + // (or the blobs the unpack still reads) mid-merge. + img, digest, lock, err := resolveImageForUse(s, ref) if err != nil { return err } + defer lock.Close() if rootfs == "" { rootfs, err = defaultRootfsForDigest(cf.store, digest) if err != nil { return err } } - fmt.Printf("Unpacking %s -> %s\n", ref, rootfs) - if err := unpackImage(s, ref, rootfs); err != nil { + // Progress goes to stderr, like every other report in this CLI; stdout + // is reserved for command output. + fmt.Fprintf(os.Stderr, "Unpacking %s -> %s\n", ref, rootfs) + if err := unpackImage(img, rootfs); err != nil { return err } - fmt.Printf("Unpacked %s\n", ref) + fmt.Fprintf(os.Stderr, "Unpacked %s\n", ref) return nil } @@ -115,23 +175,24 @@ func cmdRun(args []string) error { if err != nil { return err } - img, err := s.image(ref) - if err != nil { + // Resolve under the store lock and come back holding the per-digest run + // lock: from here to guest exit, rmi cannot reclaim this image's + // descriptor, blobs, or caches out from under the setup. + img, digestStr, refLock, err := resolveImageForUse(s, ref) + if errors.Is(err, errNotPulled) { // Auto-pull only when the ref is simply absent, so `run` is // self-sufficient on first use. Any other failure (corrupt refs.json, // unreadable layout) must surface rather than mask itself behind a // fresh network pull. - if !errors.Is(err, errNotPulled) { - return err - } if err := pullImage(cf, s, ref); err != nil { return err } - img, err = s.image(ref) - if err != nil { - return err - } + img, digestStr, refLock, err = resolveImageForUse(s, ref) } + if err != nil { + return err + } + defer refLock.Close() cfg, err := img.ConfigFile() if err != nil { return err @@ -149,12 +210,6 @@ func cmdRun(args []string) error { ref, got, want, want, ref) } } - digest, err := img.Digest() - if err != nil { - return err - } - digestStr := digest.String() - // Choose the rootfs path. Default: a case-sensitive APFS sparsebundle so // the guest's case-sensitive filenames don't collide on the host's // case-insensitive volume, with a per-run COW clone for isolation and @@ -163,15 +218,31 @@ func cmdRun(args []string) error { useCS := rf.rootfs == "" && !rf.plainRootfs if useCS { - return runCaseSensitive(cf, s, ref, digestStr, cfg, rf, tail) + // refLock stays held: runCaseSensitive keeps this process alive across + // the guest (spawnElfuseWait), so the reference lock lives for the + // whole run and rmi sees the digest in use even before the bundle + // exists. + return runCaseSensitive(cf, s, ref, img, digestStr, cfg, rf, tail) } - // Plain-directory path. + // Plain-directory path. The reference lock resolveImageForUse holds IS the + // per-digest run lock the store-managed cache is swept under, so a store + // cache run reuses it: it was taken before the existence probe, so the + // cache cannot be reclaimed between the probe and the guest's first read, + // and execElfuse threads its descriptor through the exec so the lock lives + // exactly as long as the guest. An explicit --rootfs is user-managed and + // never store-swept, so the run itself needs no lock; but a cold unpack + // below still reads layer blobs lazily from the store, so the reference + // lock is held until the unpack is done rather than released here (an + // rmi/prune racing the unpack must see the digest busy, not GC the blobs + // mid-read). + var rootfsLock *flockFile if rf.rootfs == "" { rf.rootfs, err = defaultRootfsForDigest(cf.store, digestStr) if err != nil { return err } + rootfsLock = refLock } // Ensure the rootfs is unpacked before computing the spec, because // resolveUser reads /etc/passwd and /etc/group. Re-unpack only if @@ -181,10 +252,15 @@ func cmdRun(args []string) error { return err } fmt.Fprintf(os.Stderr, "Unpacking %s -> %s\n", ref, rf.rootfs) - if err := unpackImage(s, ref, rf.rootfs); err != nil { + if err := unpackImage(img, rf.rootfs); err != nil { return err } } + if rootfsLock == nil { + // Explicit --rootfs: the store blobs are no longer needed and the + // rootfs is not digest-keyed, so the reference lock ends here. + refLock.Close() + } spec, err := computeRunSpec(cfg, rf, rf.rootfs, tail) if err != nil { return err @@ -193,10 +269,10 @@ func cmdRun(args []string) error { // the guest's resolver/hostname work. On the plain path this mutates the // unpacked rootfs directory (acceptable: --plain-rootfs is the v1/debug // path; re-runs overwrite the same small files). - if err := injectRuntimeFiles(rf.rootfs); err != nil { + if err := prepareRootfsForRun(rf.rootfs, spec); err != nil { return err } - return execElfuseForRun(rf.rootfs, spec) + return execElfuseForRun(rf.rootfs, spec, rootfsLock) } func parseRunArgs(args []string) (commonFlags, runFlags, string, []string, error) { diff --git a/cmd/elfuse-oci/commands_integration_test.go b/cmd/elfuse-oci/commands_integration_test.go new file mode 100644 index 00000000..4611a503 --- /dev/null +++ b/cmd/elfuse-oci/commands_integration_test.go @@ -0,0 +1,580 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/v1" +) + +func withFakeCranePull(t *testing.T, fn func(string, ...crane.Option) (v1.Image, error)) { + t.Helper() + old := cranePull + cranePull = fn + t.Cleanup(func() { cranePull = old }) +} + +func withFakeExecElfuse(t *testing.T, fn func(string, *runSpec, *flockFile) error) { + t.Helper() + old := execElfuseForRun + execElfuseForRun = fn + t.Cleanup(func() { execElfuseForRun = old }) +} + +func TestCmdPullPinsImageOffline(t *testing.T) { + root := t.TempDir() + img := tinyImage(t) + wantDigest, err := img.Digest() + if err != nil { + t.Fatal(err) + } + var gotRef string + var gotOptions int + withFakeCranePull(t, func(ref string, opts ...crane.Option) (v1.Image, error) { + gotRef = ref + gotOptions = len(opts) + return img, nil + }) + + stdout, stderr, err := captureOutput(t, func() error { + return cmdPull([]string{"--store", root, "--platform", "linux/amd64", "local:tiny"}) + }) + if err != nil { + t.Fatalf("cmdPull: %v", err) + } + if stdout != "" { + t.Fatalf("cmdPull stdout = %q, want empty", stdout) + } + if !strings.Contains(stderr, "Pulled local:tiny -> "+wantDigest.String()) { + t.Fatalf("cmdPull stderr = %q, want pull summary", stderr) + } + if gotRef != "local:tiny" || gotOptions != 2 { + t.Fatalf("fake crane.Pull got ref=%q options=%d, want local:tiny with the platform and keychain options", gotRef, gotOptions) + } + + s, err := openStore(root) + if err != nil { + t.Fatal(err) + } + gotDigest, err := s.digestFor("local:tiny") + if err != nil { + t.Fatal(err) + } + if gotDigest != wantDigest.String() { + t.Fatalf("pin digest = %s, want %s", gotDigest, wantDigest) + } +} + +func TestCmdPullWrapsPullError(t *testing.T) { + root := t.TempDir() + withFakeCranePull(t, func(ref string, opts ...crane.Option) (v1.Image, error) { + return nil, errors.New("registry unavailable") + }) + + _, _, err := captureOutput(t, func() error { + return cmdPull([]string{"--store", root, "local:missing"}) + }) + if err == nil || !strings.Contains(err.Error(), "pull local:missing") || !strings.Contains(err.Error(), "registry unavailable") { + t.Fatalf("cmdPull error = %v, want wrapped pull error", err) + } +} + +func TestCmdListInspectRmiAndPruneWrappers(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + manifest, err := img.Digest() + if err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + + stdout, stderr, err := captureOutput(t, func() error { + return cmdList([]string{"--store", s.root}) + }) + if err != nil { + t.Fatalf("cmdList: %v", err) + } + if stderr != "" || !strings.Contains(stdout, "local:a") || !strings.Contains(stdout, "linux/arm64") { + t.Fatalf("cmdList stdout=%q stderr=%q, want list row", stdout, stderr) + } + listedDigest := shortDigest(manifest.String()) + if !strings.Contains(stdout, listedDigest) { + t.Fatalf("cmdList stdout=%q, want digest %s", stdout, listedDigest) + } + + stdout, stderr, err = captureOutput(t, func() error { + return cmdInspect([]string{"--store", s.root, "--json", "local:a"}) + }) + if err != nil { + t.Fatalf("cmdInspect --json: %v", err) + } + // The raw config blob (compact, as stored), not a re-marshal: vendor + // extension fields must survive inspect --json. + if stderr != "" || !strings.Contains(stdout, `"architecture":"arm64"`) || !strings.HasSuffix(stdout, "\n") { + t.Fatalf("cmdInspect stdout=%q stderr=%q, want raw config JSON with trailing newline", stdout, stderr) + } + + orphan := writeOrphanBlob(t, s.root, "command-prune-orphan") + stdout, stderr, err = captureOutput(t, func() error { + return cmdPrune([]string{"--store", s.root, "--dry-run"}) + }) + if err != nil { + t.Fatalf("cmdPrune --dry-run: %v", err) + } + if stdout != "" || !strings.Contains(stderr, "Would reclaim: 1 blob(s)") || !strings.Contains(stderr, orphan) { + t.Fatalf("cmdPrune dry-run stdout=%q stderr=%q, want dry-run summary", stdout, stderr) + } + if _, err := os.Stat(blobPath(s.root, orphan)); err != nil { + t.Fatalf("dry-run removed orphan blob: %v", err) + } + + stdout, stderr, err = captureOutput(t, func() error { + return cmdPrune([]string{"--store", s.root}) + }) + if err != nil { + t.Fatalf("cmdPrune: %v", err) + } + if stdout != "" || !strings.Contains(stderr, "Reclaimed: 1 blob(s)") { + t.Fatalf("cmdPrune stdout=%q stderr=%q, want reclaim summary", stdout, stderr) + } + if _, err := os.Stat(blobPath(s.root, orphan)); !os.IsNotExist(err) { + t.Fatalf("orphan blob after prune: %v, want IsNotExist", err) + } + + stdout, stderr, err = captureOutput(t, func() error { + return cmdRmi([]string{"--store", s.root, listedDigest}) + }) + if err != nil { + t.Fatalf("cmdRmi: %v", err) + } + if stdout != "" || !strings.Contains(stderr, "Removed local:a:") { + t.Fatalf("cmdRmi stdout=%q stderr=%q, want removal summary", stdout, stderr) + } + if _, err := s.digestFor("local:a"); err == nil { + t.Fatal("local:a pin still present after cmdRmi") + } +} + +func TestCmdUnpackWrapperExplicitAndDefaultRootfs(t *testing.T) { + s := openTestStore(t) + img := tinyImage(t) + digest, err := s.addImage("local:tiny", img) + if err != nil { + t.Fatal(err) + } + + explicit := filepath.Join(t.TempDir(), "explicit-rootfs") + stdout, stderr, err := captureOutput(t, func() error { + return cmdUnpack([]string{"--store", s.root, "--rootfs", explicit, "local:tiny"}) + }) + if err != nil { + t.Fatalf("cmdUnpack explicit: %v", err) + } + if stdout != "" || !strings.Contains(stderr, "Unpacking local:tiny -> "+explicit) || !strings.Contains(stderr, "Unpacked local:tiny") { + t.Fatalf("cmdUnpack explicit stdout=%q stderr=%q", stdout, stderr) + } + if b, err := os.ReadFile(filepath.Join(explicit, "hello")); err != nil || string(b) != "world" { + t.Fatalf("explicit rootfs hello = %q, err=%v; want world", b, err) + } + + defaultRootfs, err := defaultRootfsForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + stdout, stderr, err = captureOutput(t, func() error { + return cmdUnpack([]string{"--store", s.root, "local:tiny"}) + }) + if err != nil { + t.Fatalf("cmdUnpack default: %v", err) + } + if stdout != "" || !strings.Contains(stderr, defaultRootfs) { + t.Fatalf("cmdUnpack default stdout=%q stderr=%q, want default rootfs path", stdout, stderr) + } + if b, err := os.ReadFile(filepath.Join(defaultRootfs, "hello")); err != nil || string(b) != "world" { + t.Fatalf("default rootfs hello = %q, err=%v; want world", b, err) + } +} + +func TestCmdRunPlainRootfsUnpacksInjectsAndExecs(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/image-cmd"})); err != nil { + t.Fatal(err) + } + rootfs := filepath.Join(t.TempDir(), "rootfs") + var gotRootfs string + var gotSpec *runSpec + withFakeExecElfuse(t, func(rootfs string, spec *runSpec, lock *flockFile) error { + gotRootfs = rootfs + gotSpec = spec + if b, err := os.ReadFile(filepath.Join(rootfs, "hello")); err != nil || string(b) != "world" { + t.Fatalf("rootfs hello = %q, err=%v; want world before exec", b, err) + } + for _, name := range []string{"hostname", "hosts", "resolv.conf"} { + if _, err := os.Stat(filepath.Join(rootfs, "etc", name)); err != nil { + t.Fatalf("runtime file %s missing before exec: %v", name, err) + } + } + return nil + }) + + stderrExpected := "Unpacking local:a -> " + rootfs + stdout, stderr, err := captureOutput(t, func() error { + return cmdRun([]string{ + "--store", s.root, + "--plain-rootfs", + "--rootfs", rootfs, + "--env", "A=2", + "local:a", + "/cli-cmd", "arg", + }) + }) + if err != nil { + t.Fatalf("cmdRun --plain-rootfs: %v", err) + } + if stdout != "" || !strings.Contains(stderr, stderrExpected) { + t.Fatalf("cmdRun stdout=%q stderr=%q, want unpack message %q", stdout, stderr, stderrExpected) + } + if gotRootfs != rootfs { + t.Fatalf("exec rootfs = %q, want %q", gotRootfs, rootfs) + } + if gotSpec == nil { + t.Fatal("exec spec was nil") + } + if !reflect.DeepEqual(gotSpec.Args, []string{"/cli-cmd", "arg"}) { + t.Fatalf("spec args = %v, want CLI tail", gotSpec.Args) + } + if !reflect.DeepEqual(gotSpec.Env, []string{"A=2", "PATH=" + defaultGuestPath}) { + t.Fatalf("spec env = %v, want [A=2] plus default PATH", gotSpec.Env) + } +} + +func TestCmdRunPlainRootfsEntrypointOverride(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/image-cmd"})); err != nil { + t.Fatal(err) + } + rootfs := filepath.Join(t.TempDir(), "rootfs") + var gotSpec *runSpec + withFakeExecElfuse(t, func(_ string, spec *runSpec, _ *flockFile) error { + gotSpec = spec + return nil + }) + + _, _, err := captureOutput(t, func() error { + return cmdRun([]string{ + "--store", s.root, "--plain-rootfs", "--rootfs", rootfs, + "--entrypoint", "/override", "local:a", "x", "y", + }) + }) + if err != nil { + t.Fatalf("cmdRun --entrypoint: %v", err) + } + if gotSpec == nil { + t.Fatal("exec spec was nil") + } + // --entrypoint replaces the image Entrypoint AND drops the image Cmd; the + // CLI tail becomes the new Cmd. + if want := []string{"/override", "x", "y"}; !reflect.DeepEqual(gotSpec.Args, want) { + t.Fatalf("spec args = %v, want %v", gotSpec.Args, want) + } +} + +func TestCmdRunPlainRootfsExistingSkipsUnpack(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/image-cmd"})); err != nil { + t.Fatal(err) + } + rootfs := filepath.Join(t.TempDir(), "existing-rootfs") + if err := os.MkdirAll(rootfs, 0o755); err != nil { + t.Fatal(err) + } + + withFakeExecElfuse(t, func(rootfs string, spec *runSpec, lock *flockFile) error { + if _, err := os.Stat(filepath.Join(rootfs, "hello")); !os.IsNotExist(err) { + t.Fatalf("existing rootfs was unpacked over: stat hello = %v, want IsNotExist", err) + } + if !reflect.DeepEqual(spec.Args, []string{"/image-cmd"}) { + t.Fatalf("spec args = %v, want image cmd", spec.Args) + } + return nil + }) + + _, stderr, err := captureOutput(t, func() error { + return cmdRun([]string{"--store", s.root, "--plain-rootfs", "--rootfs", rootfs, "local:a"}) + }) + if err != nil { + t.Fatalf("cmdRun existing rootfs: %v", err) + } + if strings.Contains(stderr, "Unpacking") { + t.Fatalf("existing rootfs stderr = %q, want no unpack message", stderr) + } +} + +func TestCmdRunPlainRootfsAutoPullsMissingImage(t *testing.T) { + root := t.TempDir() + rootfs := filepath.Join(t.TempDir(), "rootfs") + pullCalls := 0 + withFakeCranePull(t, func(ref string, opts ...crane.Option) (v1.Image, error) { + pullCalls++ + if ref != "local:pulled" { + t.Fatalf("pull ref = %q, want local:pulled", ref) + } + return buildImage(t, []string{"/pulled-cmd"}), nil + }) + withFakeExecElfuse(t, func(rootfs string, spec *runSpec, lock *flockFile) error { + if !reflect.DeepEqual(spec.Args, []string{"/pulled-cmd"}) { + t.Fatalf("spec args = %v, want pulled image cmd", spec.Args) + } + return nil + }) + + stdout, stderr, err := captureOutput(t, func() error { + return cmdRun([]string{"--store", root, "--plain-rootfs", "--rootfs", rootfs, "local:pulled"}) + }) + if err != nil { + t.Fatalf("cmdRun auto-pull: %v", err) + } + if pullCalls != 1 { + t.Fatalf("pullCalls = %d, want 1", pullCalls) + } + // run's stdout belongs to the guest (callers capture it), so the pull + // summary must land on stderr with the unpack progress. + if strings.Contains(stdout, "Pulled local:pulled") { + t.Fatalf("cmdRun auto-pull stdout = %q, pull summary leaked into guest stdout", stdout) + } + if !strings.Contains(stderr, "Pulled local:pulled") || !strings.Contains(stderr, "Unpacking local:pulled") { + t.Fatalf("cmdRun auto-pull stderr = %q, want pull and unpack summaries", stderr) + } + s, err := openStore(root) + if err != nil { + t.Fatal(err) + } + if _, err := s.digestFor("local:pulled"); err != nil { + t.Fatalf("auto-pulled ref was not pinned: %v", err) + } +} + +func TestCommandWrappersReturnParseAndStoreErrors(t *testing.T) { + parseCases := []struct { + name string + fn func() error + }{ + {"pull", func() error { return cmdPull(nil) }}, + {"unpack", func() error { return cmdUnpack(nil) }}, + {"inspect", func() error { return cmdInspect(nil) }}, + {"run", func() error { return cmdRun(nil) }}, + {"list", func() error { return cmdList([]string{"extra"}) }}, + {"rmi", func() error { return cmdRmi(nil) }}, + {"prune", func() error { return cmdPrune([]string{"--all"}) }}, + } + for _, tc := range parseCases { + t.Run("parse "+tc.name, func(t *testing.T) { + if err := tc.fn(); err == nil { + t.Fatalf("%s parse error case succeeded, want error", tc.name) + } + }) + } + + storeFile := filepath.Join(t.TempDir(), "store-file") + if err := os.WriteFile(storeFile, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + storeCases := []struct { + name string + fn func() error + }{ + {"pull", func() error { return cmdPull([]string{"--store", storeFile, "local:a"}) }}, + {"unpack", func() error { return cmdUnpack([]string{"--store", storeFile, "local:a"}) }}, + {"inspect", func() error { return cmdInspect([]string{"--store", storeFile, "local:a"}) }}, + {"run", func() error { return cmdRun([]string{"--store", storeFile, "local:a"}) }}, + {"list", func() error { return cmdList([]string{"--store", storeFile}) }}, + {"rmi", func() error { return cmdRmi([]string{"--store", storeFile, "local:a"}) }}, + {"prune", func() error { return cmdPrune([]string{"--store", storeFile}) }}, + } + for _, tc := range storeCases { + t.Run("store "+tc.name, func(t *testing.T) { + if err := tc.fn(); err == nil { + t.Fatalf("%s store error case succeeded, want error", tc.name) + } + }) + } +} + +// TestCmdRunPlatformMismatchOnPinnedRef pins the --platform check: the store +// pins one digest per ref, so an explicit --platform that disagrees with the +// pinned image must fail instead of silently launching the wrong +// architecture. Without --platform the pinned image runs as-is. +func TestCmdRunPlatformMismatchOnPinnedRef(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/image-cmd"})); err != nil { + t.Fatal(err) + } + withFakeExecElfuse(t, func(string, *runSpec, *flockFile) error { return nil }) + rootfs := filepath.Join(t.TempDir(), "rootfs") + + _, _, err := captureOutput(t, func() error { + return cmdRun([]string{ + "--store", s.root, "--platform", "linux/amd64", + "--plain-rootfs", "--rootfs", rootfs, "local:a", + }) + }) + if err == nil || !strings.Contains(err.Error(), "pinned for linux/arm64") { + t.Fatalf("cmdRun --platform mismatch err = %v, want pinned-platform error", err) + } + + for _, args := range [][]string{ + {"--store", s.root, "--platform", "linux/arm64", "--plain-rootfs", "--rootfs", rootfs, "local:a"}, + {"--store", s.root, "--plain-rootfs", "--rootfs", rootfs, "local:a"}, + } { + if _, _, err := captureOutput(t, func() error { return cmdRun(args) }); err != nil { + t.Fatalf("cmdRun %v: %v", args, err) + } + } +} + +// TestCmdRunPlainRootfsLockDiscipline pins which runs hold the per-digest +// cache lock at exec time: a store-default rootfs arrives with the run lock +// held (a concurrent prune would see busy), while an explicit --rootfs is +// user-managed and locks nothing. +func TestCmdRunPlainRootfsLockDiscipline(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/image-cmd"}) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + + var gotLock *flockFile + var busyAtExec bool + withFakeExecElfuse(t, func(rootfs string, spec *runSpec, lock *flockFile) error { + gotLock = lock + busyAtExec = rootfsCacheBusy(rootfs) + return nil + }) + if _, _, err := captureOutput(t, func() error { + return cmdRun([]string{"--store", s.root, "--plain-rootfs", "local:a"}) + }); err != nil { + t.Fatalf("cmdRun store-default: %v", err) + } + if gotLock == nil { + t.Error("store-default rootfs exec got nil lock, want held run lock") + } + if !busyAtExec { + t.Error("store-default cache not busy at exec time, want lock held") + } + + gotLock = nil + rootfs := filepath.Join(t.TempDir(), "explicit") + if _, _, err := captureOutput(t, func() error { + return cmdRun([]string{"--store", s.root, "--plain-rootfs", "--rootfs", rootfs, "local:a"}) + }); err != nil { + t.Fatalf("cmdRun explicit rootfs: %v", err) + } + if gotLock != nil { + t.Error("explicit --rootfs exec got a lock, want nil") + } +} + +// markedImage builds a single-layer image whose /marker file holds content, so +// a test can tell which image populated a digest-keyed cache. +func markedImage(t *testing.T, content string) v1.Image { + t.Helper() + return testImageWithLayers(t, testTarLayer(t, + tarEntry{header: regHeader("marker", 0o644, 0), body: content})) +} + +// TestCmdUnpackUsesResolvedImageDespiteRepull pins that unpack fills the +// digest-keyed cache from the image it resolved, not a re-resolution of the +// mutable ref: a repull that moves the tag to a different image mid-setup must +// not poison digest A's cache with image B's content. The afterImageResolve +// seam fires in the exact window between resolution and unpack. +func TestCmdUnpackUsesResolvedImageDespiteRepull(t *testing.T) { + s := openTestStore(t) + imgA := markedImage(t, "image-A") + imgB := markedImage(t, "image-B") + digestA, err := s.addImage("local:tag", imgA) + if err != nil { + t.Fatal(err) + } + + old := afterImageResolve + t.Cleanup(func() { afterImageResolve = old }) + repinned := false + afterImageResolve = func(digest string) { + if repinned || digest != digestA { + return + } + repinned = true + // Move the tag to image B while the unpack still holds digest A's + // reference lock. addImage takes the store lock, which resolveImageForUse + // has already released by now. + if _, err := s.addImage("local:tag", imgB); err != nil { + t.Errorf("repull to image B: %v", err) + } + } + + if _, _, err := captureOutput(t, func() error { + return cmdUnpack([]string{"--store", s.root, "local:tag"}) + }); err != nil { + t.Fatalf("cmdUnpack: %v", err) + } + if !repinned { + t.Fatal("afterImageResolve never fired for digest A") + } + rootfsA, err := defaultRootfsForDigest(s.root, digestA) + if err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(filepath.Join(rootfsA, "marker")); err != nil || string(b) != "image-A" { + t.Fatalf("digest-A cache marker = %q, err=%v; want image-A (not poisoned by repull)", b, err) + } +} + +// TestRmiRefusesWhileReferenceLockHeld: a starting run holds the +// per-digest reference lock before any cache dir or bundle exists, so rmi of +// the last pin must refuse (even with --force) rather than GC blobs out from +// under it, and the pin plus its blobs survive the refusal. Once the lock is +// released the same rmi succeeds. +func TestRmiRefusesWhileReferenceLockHeld(t *testing.T) { + s := openTestStore(t) + img := tinyImage(t) + digest, err := s.addImage("local:a", img) + if err != nil { + t.Fatal(err) + } + rootfs, err := defaultRootfsForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + // A cold run's reference lock: taken before the rootfs cache dir exists. + hold, err := acquireRootfsRunLock(rootfs) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(rootfs); !os.IsNotExist(err) { + t.Fatalf("cache dir exists before run unpacked it: %v", err) + } + + if _, err := s.rmi("local:a", true); err == nil { + t.Fatal("rmi --force succeeded while reference lock held, want refusal") + } + if _, err := s.image("local:a"); err != nil { + t.Fatalf("pin/blobs gone after refused rmi: %v", err) + } + + if err := hold.Close(); err != nil { + t.Fatal(err) + } + if _, err := s.rmi("local:a", false); err != nil { + t.Fatalf("rmi after lock released: %v", err) + } +} diff --git a/cmd/elfuse-oci/common_test.go b/cmd/elfuse-oci/common_test.go index a65d120d..0946abe3 100644 --- a/cmd/elfuse-oci/common_test.go +++ b/cmd/elfuse-oci/common_test.go @@ -135,16 +135,47 @@ func TestParseRunArgs(t *testing.T) { func TestParseCommandFlagErrors(t *testing.T) { cases := []struct { name string - err error + fn func() error }{ - {"malformed platform", func() error { _, _, err := parsePullArgs([]string{"--platform", "bogus", "alpine:3"}); return err }()}, - {"unknown flag", func() error { _, _, err := parsePullArgs([]string{"--unknown", "alpine:3"}); return err }()}, - {"missing flag value", func() error { _, _, _, err := parseUnpackArgs([]string{"--rootfs"}); return err }()}, - {"run missing ref", func() error { _, _, _, _, err := parseRunArgs([]string{"--env", "A=1"}); return err }()}, + {"malformed platform", func() error { _, _, err := parsePullArgs([]string{"--platform", "bogus", "alpine:3"}); return err }}, + {"unknown flag", func() error { _, _, err := parsePullArgs([]string{"--unknown", "alpine:3"}); return err }}, + {"missing flag value", func() error { _, _, _, err := parseUnpackArgs([]string{"--rootfs"}); return err }}, + {"run missing ref", func() error { _, _, _, _, err := parseRunArgs([]string{"--env", "A=1"}); return err }}, + {"list extra arg", func() error { _, _, err := parseListArgs([]string{"alpine:3"}); return err }}, + {"rmi missing ref", func() error { _, _, _, err := parseRmiArgs([]string{"--force"}); return err }}, + {"prune all without cache", func() error { _, _, err := parsePruneArgs([]string{"--all"}); return err }}, } for _, tc := range cases { - if tc.err == nil { - t.Errorf("%s: got nil error", tc.name) - } + t.Run(tc.name, func(t *testing.T) { + if err := tc.fn(); err == nil { + t.Errorf("%s: got nil error, want parse failure", tc.name) + } + }) + } +} + +func TestParseLifecycleArgs(t *testing.T) { + cf, asJSON, err := parseListArgs([]string{"--store", "/s", "--json"}) + if err != nil { + t.Fatal(err) + } + if cf.store != "/s" || !asJSON { + t.Fatalf("list parse = store %q json %v, want /s true", cf.store, asJSON) + } + + cf, force, ref, err := parseRmiArgs([]string{"--force", "alpine:3"}) + if err != nil { + t.Fatal(err) + } + if force != true || ref != "alpine:3" || cf.platform != defaultPlatform { + t.Fatalf("rmi parse = force %v ref %q platform %+v", force, ref, cf.platform) + } + + cf, opts, err := parsePruneArgs([]string{"--cache", "--all", "--dry-run"}) + if err != nil { + t.Fatal(err) + } + if !opts.cache || !opts.all || !opts.dryRun || cf.platform != defaultPlatform { + t.Fatalf("prune parse = opts %+v platform %+v", opts, cf.platform) } } diff --git a/cmd/elfuse-oci/csrun.go b/cmd/elfuse-oci/csrun.go index 80a0710d..115a2ba4 100644 --- a/cmd/elfuse-oci/csrun.go +++ b/cmd/elfuse-oci/csrun.go @@ -21,10 +21,30 @@ var ( clonefileForRun = unix.Clonefile spawnElfuseWaitForRun = spawnElfuseWait cleanupCloneAndMountForRun = cleanupCloneAndMount + writeKeptSidecarForRun = writeKeptSidecar osExitForRun = os.Exit runNowUnixNano = func() int64 { return time.Now().UnixNano() } ) +// keptSidecarName marks a whole sparsebundle as holding run --keep retained +// output. It lives beside the bundle's sparsebundle image and flocks, outside +// the mounted volume, so a cold rmi can detect a deliberate keep without +// attaching a detached bundle to look for .elfuse-keep clones inside it. The +// only thing that removes a kept clone is whole-bundle removal (rmi --force, +// prune --cache --all), which deletes this sidecar along with it, so the marker +// never goes stale. +const keptSidecarName = "kept" + +func keptSidecarPath(bundle string) string { + return filepath.Join(bundle, keptSidecarName) +} + +// writeKeptSidecar records that m's bundle holds run --keep retained output. +// m.mountPath is /mnt, so the sidecar lands beside the bundle image. +func writeKeptSidecar(m *csMount) error { + return touchFile(keptSidecarPath(filepath.Dir(m.mountPath))) +} + // csBundleDirForDigest is /cs//: it holds the case-sensitive // sparsebundle image and the attach mount point for one pinned manifest digest. func csBundleDirForDigest(store, digest string) (string, error) { @@ -43,7 +63,7 @@ func csBundleDirForDigest(store, digest string) (string, error) { // The unpacked base tree persists in the sparsebundle image file across // attach/detach cycles, so warm re-runs skip the (slow) unpack and pay only the // attach. -func ensureCaseSensitiveRootfs(cf commonFlags, s *store, ref, digest, size string) (*csMount, string, error) { +func ensureCaseSensitiveRootfs(cf commonFlags, s *store, ref string, img v1.Image, digest, size string) (*csMount, string, error) { bundle, err := csBundleDirForDigest(cf.store, digest) if err != nil { return nil, "", err @@ -59,7 +79,11 @@ func ensureCaseSensitiveRootfs(cf commonFlags, s *store, ref, digest, size strin return nil, "", errors.Join(err, closeMount(m)) } fmt.Fprintf(os.Stderr, "Unpacking %s -> %s\n", ref, rootfs) - if err := unpackImage(s, ref, rootfs); err != nil { + // Unpack the caller's already-resolved image, not a re-resolution of + // ref: the bundle is keyed by digest, so a repull moving the tag + // mid-setup must not fill this digest's sparsebundle with another + // image. + if err := unpackImage(img, rootfs); err != nil { return nil, "", errors.Join(err, closeMount(m)) } } @@ -78,8 +102,8 @@ func ensureCaseSensitiveRootfs(cf commonFlags, s *store, ref, digest, size strin // The clone lives in the same APFS volume as the base tree (clonefile is // intra-volume only), so it is instant and free until the guest writes (COW). // It isolates each run's mutations from the warm base, so re-runs stay clean. -func runCaseSensitive(cf commonFlags, s *store, ref, digest string, cfg *v1.ConfigFile, rf runFlags, tail []string) error { - m, baseRootfs, err := ensureCaseSensitiveRootfsForRun(cf, s, ref, digest, rf.sparseSize) +func runCaseSensitive(cf commonFlags, s *store, ref string, img v1.Image, digest string, cfg *v1.ConfigFile, rf runFlags, tail []string) error { + m, baseRootfs, err := ensureCaseSensitiveRootfsForRun(cf, s, ref, img, digest, rf.sparseSize) if err != nil { return err } @@ -89,11 +113,26 @@ func runCaseSensitive(cf commonFlags, s *store, ref, digest string, cfg *v1.Conf // Per-run COW clone. --no-clone runs against the base tree directly (mutations // then persist into the warm tree; useful for debugging or when clonefile is - // unavailable). + // unavailable). Liveness no longer depends on a clone directory existing: + // this run holds the bundle's run.lock (via the csMount) for its whole + // lifetime, so prune --cache/rmi --force see the volume busy and leave it + // attached regardless of whether a clone was made, so --no-clone needs + // no placeholder directory. sysroot := baseRootfs - var cloneDir string + cloneDir := filepath.Join(m.mountPath, fmt.Sprintf("run-%d-%d", os.Getpid(), runNowUnixNano())) + if rf.keepRootfs { + // Record the bundle-level keep FIRST, before the per-clone marker, so + // the two keep records never disagree: if this succeeds but the + // per-clone marker below fails, a cold rmi still refuses to discard the + // bundle without --force. In the reverse order a failed sidecar write + // would leave a sweep-preserved clone that rmi silently discards. It + // also covers the --no-clone --keep case (mutations land in the base + // tree, no clone marker is written at all). + if err := writeKeptSidecarForRun(m); err != nil { + return errors.Join(err, closeMount(m)) + } + } if !rf.noClone { - cloneDir = filepath.Join(m.mountPath, fmt.Sprintf("run-%d-%d", os.Getpid(), runNowUnixNano())) if err := os.RemoveAll(cloneDir); err != nil { err = fmt.Errorf("remove stale COW clone %s: %w", cloneDir, err) return errors.Join(err, closeMount(m)) @@ -103,6 +142,14 @@ func runCaseSensitive(cf commonFlags, s *store, ref, digest string, cfg *v1.Conf return errors.Join(err, closeMount(m)) } sysroot = cloneDir + if rf.keepRootfs { + // Mark the clone so a later prune/rmi sweep preserves it even + // after this run exits and releases run.lock: without the marker + // the sweep, which only runs when no run is live, would reap it. + if err := writeKeepMarker(cloneDir); err != nil { + return errors.Join(err, cleanupCloneAndMountForRun(cloneDir, rf.keepRootfs, m)) + } + } } // Any failure past this point must tear down the clone and the mount. @@ -127,8 +174,11 @@ func runCaseSensitive(cf commonFlags, s *store, ref, digest string, cfg *v1.Conf // --keep leaves the clone and the mount in place for inspection. The // clone lives in the sparsebundle volume, so the mount must stay // attached for it to be reachable on the host; a later run reattaches - // (detaching this stale mount first) and the kept clone reappears. - if cloneDir != "" { + // (detaching this stale mount first) and the kept clone, protected + // by its keep marker from any intervening sweep, reappears. Under + // --no-clone there is no clone to keep (mutations landed in the base + // tree), only the still-attached mount. + if !rf.noClone { fmt.Fprintf(os.Stderr, "kept clone: %s\n", cloneDir) } fmt.Fprintf(os.Stderr, "mount stays attached: %s\n", m.mountPath) diff --git a/cmd/elfuse-oci/csrun_darwin_test.go b/cmd/elfuse-oci/csrun_darwin_test.go index 3516c767..d40579b2 100644 --- a/cmd/elfuse-oci/csrun_darwin_test.go +++ b/cmd/elfuse-oci/csrun_darwin_test.go @@ -6,6 +6,7 @@ package main import ( + "archive/tar" "fmt" "os" "path/filepath" @@ -25,6 +26,7 @@ func withCSRunSeams(t *testing.T) { oldClone := clonefileForRun oldSpawn := spawnElfuseWaitForRun oldCleanup := cleanupCloneAndMountForRun + oldSidecar := writeKeptSidecarForRun oldExit := osExitForRun oldNow := runNowUnixNano t.Cleanup(func() { @@ -32,6 +34,7 @@ func withCSRunSeams(t *testing.T) { clonefileForRun = oldClone spawnElfuseWaitForRun = oldSpawn cleanupCloneAndMountForRun = oldCleanup + writeKeptSidecarForRun = oldSidecar osExitForRun = oldExit runNowUnixNano = oldNow }) @@ -48,7 +51,7 @@ func TestRunCaseSensitiveCloneSpawnCleanupAndExit(t *testing.T) { runNowUnixNano = func() int64 { return 123 } expectedClone := filepath.Join(mount, fmt.Sprintf("run-%d-123", os.Getpid())) var clonedSrc, clonedDst string - ensureCaseSensitiveRootfsForRun = func(cf commonFlags, s *store, ref, digest, size string) (*csMount, string, error) { + ensureCaseSensitiveRootfsForRun = func(cf commonFlags, s *store, ref string, img v1.Image, digest, size string) (*csMount, string, error) { if cf.store != "store" || ref != "local:a" || digest != "sha256:"+strings.Repeat("7", 64) || size != "64m" { t.Fatalf("ensure args = store=%q ref=%q digest=%q size=%q", cf.store, ref, digest, size) } @@ -104,6 +107,7 @@ func TestRunCaseSensitiveCloneSpawnCleanupAndExit(t *testing.T) { commonFlags{store: "store"}, &store{}, "local:a", + nil, "sha256:"+strings.Repeat("7", 64), cfg, runFlags{sparseSize: "64m"}, @@ -119,7 +123,7 @@ func TestRunCaseSensitiveNoCloneKeepSkipsCleanup(t *testing.T) { if err := os.MkdirAll(base, 0o755); err != nil { t.Fatal(err) } - ensureCaseSensitiveRootfsForRun = func(commonFlags, *store, string, string, string) (*csMount, string, error) { + ensureCaseSensitiveRootfsForRun = func(commonFlags, *store, string, v1.Image, string, string) (*csMount, string, error) { return &csMount{mountPath: mount}, base, nil } clonefileForRun = func(string, string, int) error { @@ -136,6 +140,13 @@ func TestRunCaseSensitiveNoCloneKeepSkipsCleanup(t *testing.T) { t.Fatal("cleanup called with --keep") return nil } + // --no-clone --keep still records the keep beside the bundle so a cold rmi + // refuses to discard the mutated base tree without --force. + sidecarWritten := false + writeKeptSidecarForRun = func(*csMount) error { + sidecarWritten = true + return nil + } osExitForRun = func(code int) { panic(runExitCode(code)) } defer func() { @@ -144,8 +155,11 @@ func TestRunCaseSensitiveNoCloneKeepSkipsCleanup(t *testing.T) { if !ok || code != 0 { t.Fatalf("runCaseSensitive panic = %T %v, want exit code 0", r, r) } + if !sidecarWritten { + t.Error("--no-clone --keep did not write the kept sidecar") + } }() - err := runCaseSensitive(commonFlags{}, &store{}, "local:a", "sha256:"+strings.Repeat("8", 64), + err := runCaseSensitive(commonFlags{}, &store{}, "local:a", nil, "sha256:"+strings.Repeat("8", 64), &v1.ConfigFile{Config: v1.Config{Cmd: []string{"/cmd"}}}, runFlags{noClone: true, keepRootfs: true}, nil) @@ -162,7 +176,7 @@ func TestRunCaseSensitiveSpecErrorCleansCloneAndMount(t *testing.T) { m := &csMount{mountPath: mount} runNowUnixNano = func() int64 { return 456 } expectedClone := filepath.Join(mount, fmt.Sprintf("run-%d-456", os.Getpid())) - ensureCaseSensitiveRootfsForRun = func(commonFlags, *store, string, string, string) (*csMount, string, error) { + ensureCaseSensitiveRootfsForRun = func(commonFlags, *store, string, v1.Image, string, string) (*csMount, string, error) { return m, base, nil } clonefileForRun = func(src, dst string, flags int) error { @@ -185,7 +199,7 @@ func TestRunCaseSensitiveSpecErrorCleansCloneAndMount(t *testing.T) { } osExitForRun = func(code int) { t.Fatalf("exit called after spec error with code %d", code) } - err := runCaseSensitive(commonFlags{}, &store{}, "local:a", "sha256:"+strings.Repeat("9", 64), + err := runCaseSensitive(commonFlags{}, &store{}, "local:a", nil, "sha256:"+strings.Repeat("9", 64), &v1.ConfigFile{Config: v1.Config{}}, runFlags{}, nil) @@ -211,8 +225,12 @@ func TestEnsureCaseSensitiveRootfsProvisionsUnpacksAndSkipsExisting(t *testing.T if err != nil { t.Fatal(err) } + img, err := s.image("local:tiny") + if err != nil { + t.Fatal(err) + } - m, rootfs, err := ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:tiny", digest, "32m") + m, rootfs, err := ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:tiny", img, digest, "32m") if err != nil { t.Fatalf("ensureCaseSensitiveRootfs: %v", err) } @@ -242,8 +260,12 @@ func TestEnsureCaseSensitiveRootfsProvisionsUnpacksAndSkipsExisting(t *testing.T if err != nil { t.Fatal(err) } + img, err := s.image("local:tiny") + if err != nil { + t.Fatal(err) + } - m, gotRootfs, err := ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:tiny", digest, "32m") + m, gotRootfs, err := ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:tiny", img, digest, "32m") if err != nil { t.Fatalf("ensureCaseSensitiveRootfs existing: %v", err) } @@ -272,9 +294,21 @@ func TestEnsureCaseSensitiveRootfsClosesMountOnUnpackError(t *testing.T) { t.Setenv("HDIUTIL_DETACH_LOG", detachLog) s := openTestStore(t) - _, _, err := ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:missing", "sha256:"+strings.Repeat("a", 64), "32m") - if err == nil || !strings.Contains(err.Error(), "not pulled") { - t.Fatalf("ensureCaseSensitiveRootfs err = %v, want unpack not-pulled error", err) + // The image is resolved by the caller now, so an unpack failure has to come + // from the image content, not a missing ref (that is caught upstream in + // resolveImageForUse). A fifo entry is an unsupported special file, so + // unpackImage fails and ensureCaseSensitiveRootfs must still detach the + // mount it attached. + bad := testImageWithLayers(t, testTarLayer(t, + tarEntry{header: tar.Header{Name: "dev/fifo", Typeflag: tar.TypeFifo, Mode: 0o644}})) + digest, err := s.addImage("local:bad", bad) + if err != nil { + t.Fatal(err) + } + + _, _, err = ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:bad", bad, digest, "32m") + if err == nil || !strings.Contains(err.Error(), "fifo") { + t.Fatalf("ensureCaseSensitiveRootfs err = %v, want unpack fifo error", err) } b, readErr := os.ReadFile(detachLog) if readErr != nil { @@ -328,7 +362,7 @@ func TestCmdRunDefaultCaseSensitivePath(t *testing.T) { t.Fatal(err) } runNowUnixNano = func() int64 { return 789 } - ensureCaseSensitiveRootfsForRun = func(cf commonFlags, _ *store, ref, digest, size string) (*csMount, string, error) { + ensureCaseSensitiveRootfsForRun = func(cf commonFlags, _ *store, ref string, img v1.Image, digest, size string) (*csMount, string, error) { if cf.store != s.root || ref != "local:a" || size != "" { t.Fatalf("ensure from cmdRun got store=%q ref=%q size=%q", cf.store, ref, size) } @@ -362,3 +396,118 @@ func TestCmdRunDefaultCaseSensitivePath(t *testing.T) { err := cmdRun([]string{"--store", s.root, "local:a"}) t.Fatalf("cmdRun returned %v, want exit panic", err) } + +// TestRunCaseSensitiveNoCloneCreatesNoMarkerDir pins that a --no-clone run +// leaves no run-- placeholder in the volume: liveness now rides on +// the bundle's run.lock (held via the csMount), not on a marker directory, so +// none is created and none is cleaned up. +func TestRunCaseSensitiveNoCloneCreatesNoMarkerDir(t *testing.T) { + withCSRunSeams(t) + mount := t.TempDir() + base := filepath.Join(mount, "rootfs") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + m := &csMount{mountPath: mount} + runNowUnixNano = func() int64 { return 789 } + cloneName := filepath.Join(mount, fmt.Sprintf("run-%d-789", os.Getpid())) + ensureCaseSensitiveRootfsForRun = func(commonFlags, *store, string, v1.Image, string, string) (*csMount, string, error) { + return m, base, nil + } + clonefileForRun = func(string, string, int) error { + t.Fatal("clonefile called with --no-clone") + return nil + } + spawnElfuseWaitForRun = func(rootfs string, spec *runSpec) (int, error) { + if rootfs != base { + t.Fatalf("spawn rootfs = %q, want base rootfs", rootfs) + } + if _, err := os.Lstat(cloneName); !os.IsNotExist(err) { + t.Fatalf("--no-clone created a placeholder %q: %v, want none", cloneName, err) + } + return 0, nil + } + cleanupCloneAndMountForRun = func(clone string, keep bool, cm *csMount) error { + return removeClone(clone, keep) + } + osExitForRun = func(code int) { panic(runExitCode(code)) } + + defer func() { + r := recover() + code, ok := r.(runExitCode) + if !ok || code != 0 { + t.Fatalf("runCaseSensitive panic = %T %v, want exit code 0", r, r) + } + }() + err := runCaseSensitive(commonFlags{}, &store{}, "local:a", nil, "sha256:"+strings.Repeat("6", 64), + &v1.ConfigFile{Config: v1.Config{Cmd: []string{"/cmd"}}}, + runFlags{noClone: true}, + nil) + t.Fatalf("runCaseSensitive returned %v, want exit panic", err) +} + +// TestRunCaseSensitiveKeepWritesKeepMarker pins that a --keep run records a +// keep sidecar beside (not inside) its COW clone so a later sweep preserves the +// clone after this run exits and releases run.lock, and so image content or a +// guest cannot forge the keep by writing /.elfuse-keep in the clone. +func TestRunCaseSensitiveKeepWritesKeepMarker(t *testing.T) { + withCSRunSeams(t) + mount := t.TempDir() + base := filepath.Join(mount, "rootfs") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + m := &csMount{mountPath: mount} + runNowUnixNano = func() int64 { return 321 } + cloneDir := filepath.Join(mount, fmt.Sprintf("run-%d-321", os.Getpid())) + ensureCaseSensitiveRootfsForRun = func(commonFlags, *store, string, v1.Image, string, string) (*csMount, string, error) { + return m, base, nil + } + clonefileForRun = func(src, dst string, flags int) error { + return os.MkdirAll(dst, 0o755) + } + spawnElfuseWaitForRun = func(rootfs string, spec *runSpec) (int, error) { + if rootfs != cloneDir { + t.Fatalf("spawn rootfs = %q, want clone %q", rootfs, cloneDir) + } + if _, err := os.Stat(cloneKeepMarkerPath(mount, filepath.Base(cloneDir))); err != nil { + t.Fatalf("keep marker during run: %v, want present", err) + } + // The keep record must live OUTSIDE the clone (the guest's /), so + // image content or the guest cannot forge it. + if _, err := os.Stat(filepath.Join(cloneDir, ".elfuse-keep")); !os.IsNotExist(err) { + t.Fatalf("keep record found inside the clone: %v, want only the sidecar", err) + } + return 0, nil + } + cleanupCloneAndMountForRun = func(string, bool, *csMount) error { + t.Fatal("cleanup called with --keep") + return nil + } + sidecarWritten := false + writeKeptSidecarForRun = func(*csMount) error { + sidecarWritten = true + return nil + } + osExitForRun = func(code int) { panic(runExitCode(code)) } + + defer func() { + r := recover() + code, ok := r.(runExitCode) + if !ok || code != 0 { + t.Fatalf("runCaseSensitive panic = %T %v, want exit code 0", r, r) + } + // The kept clone with its marker survives: a later sweep skips it. + if listed := listSweepableClones(mount); len(listed) != 0 { + t.Fatalf("listSweepableClones = %v, want the kept clone skipped", listed) + } + if !sidecarWritten { + t.Error("--keep did not write the kept sidecar") + } + }() + err := runCaseSensitive(commonFlags{}, &store{}, "local:a", nil, "sha256:"+strings.Repeat("6", 64), + &v1.ConfigFile{Config: v1.Config{Cmd: []string{"/cmd"}}}, + runFlags{keepRootfs: true}, + nil) + t.Fatalf("runCaseSensitive returned %v, want exit panic", err) +} diff --git a/cmd/elfuse-oci/csrun_other.go b/cmd/elfuse-oci/csrun_other.go index b16d9b68..972310af 100644 --- a/cmd/elfuse-oci/csrun_other.go +++ b/cmd/elfuse-oci/csrun_other.go @@ -17,6 +17,6 @@ import ( // reports a clear error and directs the user at --plain-rootfs. This stub // exists so elfuse-oci compiles on Linux, where pull/inspect/unpack and // conformance/interop tests run. -func runCaseSensitive(cf commonFlags, s *store, ref, digest string, cfg *v1.ConfigFile, rf runFlags, tail []string) error { +func runCaseSensitive(cf commonFlags, s *store, ref string, img v1.Image, digest string, cfg *v1.ConfigFile, rf runFlags, tail []string) error { return fmt.Errorf("case-sensitive sparsebundle rootfs requires macOS; pass --plain-rootfs for a plain directory") } diff --git a/cmd/elfuse-oci/gc.go b/cmd/elfuse-oci/gc.go new file mode 100644 index 00000000..75f7087c --- /dev/null +++ b/cmd/elfuse-oci/gc.go @@ -0,0 +1,415 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/google/go-containerregistry/pkg/v1" +) + +// rmReport summarizes a reachability-GC pass: the blob digests removed (or that +// would be removed, under --dry-run) and the bytes reclaimed. CacheDropped +// records whether rmi also reclaimed the image's unpacked cache, so the command +// can report it rather than deleting a large warm tree silently. +type rmReport struct { + Ref string + Blobs []string + Bytes int64 + CacheDropped bool +} + +// gc runs a reachability pass over the OCI image-layout store and removes any +// sha256 blob that is not reachable from an index.json manifest descriptor. +// Reachability follows manifest/index descriptors recursively, then marks image +// manifests, configs, and layers live. When dryRun is set, gc reports what it +// would reclaim and deletes nothing. +// +// gc is the shared engine behind `rmi` (called after a manifest descriptor is +// removed from index.json, so the dropped image's config/layers surface as +// unreachable) and `prune` (a standalone sweep that reclaims retag/partial-pull +// orphans). It also reclaims stale temporary blob files left by interrupted +// writes under blobs/sha256/; those filenames are not valid sha256 digests and +// make layout.Path.GarbageCollect abort before it can sweep anything. +// +// It first reconciles index.json against the pin set (pruneUnpinnedDescriptors) +// so a re-pulled mutable tag's orphaned prior descriptor stops keeping its +// blobs live; the blob sweep then reclaims them. A dry run reconciles nothing +// and reports against the current index. +func (s *store) gc(dryRun bool) (rmReport, error) { + if !dryRun { + if err := s.pruneUnpinnedDescriptors(); err != nil { + return rmReport{}, fmt.Errorf("gc: reconcile descriptors: %w", err) + } + } + live, err := s.liveBlobDigests() + if err != nil { + return rmReport{}, fmt.Errorf("gc: compute reachability: %w", err) + } + var rep rmReport + blobs, err := s.localBlobFiles() + if err != nil { + return rep, err + } + for _, b := range blobs { + if !b.malformed && live[b.digest] { + continue + } + fi, err := os.Stat(b.path) + if os.IsNotExist(err) { + continue // raced or already removed + } + if err != nil { + return rep, err + } + rep.Blobs = append(rep.Blobs, b.digest) + rep.Bytes += fi.Size() + if !dryRun { + err := b.remove(s) + if err != nil && !os.IsNotExist(err) { + return rep, err + } + } + } + return rep, nil +} + +type localBlob struct { + path string + digest string + hash v1.Hash + malformed bool +} + +func (b localBlob) remove(s *store) error { + if b.malformed { + return os.Remove(b.path) + } + return s.path.RemoveBlob(b.hash) +} + +// liveBlobDigests roots reachability at the refs.json pin set, not at every +// index.json descriptor: an unpinned descriptor (a re-pulled tag's orphaned +// prior manifest, or a pull that appended a manifest but crashed before +// pinning) must not keep its blobs live. Each pinned digest's manifest, +// config, and layers are marked live. A real gc calls pruneUnpinnedDescriptors +// first so index.json never keeps referencing a manifest whose blobs this +// sweep reclaims; a dry run skips that but, rooting here at pins too, still +// reports exactly the blobs a real run would reclaim. +func (s *store) liveBlobDigests() (map[string]bool, error) { + pins, err := s.loadPins() + if err != nil { + return nil, err + } + live := map[string]bool{} + seen := map[string]bool{} + for _, digest := range pins { + if seen[digest] { + continue // shared manifest already marked + } + seen[digest] = true + h, err := v1.NewHash(digest) + if err != nil { + return nil, err + } + img, err := s.path.Image(h) + if err != nil { + return nil, fmt.Errorf("gc: read pinned manifest %s: %w", digest, err) + } + if err := markLiveImage(img, live); err != nil { + return nil, err + } + } + return live, nil +} + +func markLiveImage(image v1.Image, live map[string]bool) error { + h, err := image.Digest() + if err != nil { + return err + } + live[h.String()] = true + + h, err = image.ConfigName() + if err != nil { + return err + } + live[h.String()] = true + + layers, err := image.Layers() + if err != nil { + return err + } + for _, layer := range layers { + h, err := layer.Digest() + if err != nil { + return err + } + live[h.String()] = true + } + return nil +} + +func (s *store) localBlobFiles() ([]localBlob, error) { + base := filepath.Join(s.root, "blobs", "sha256") + entries, err := os.ReadDir(base) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + + blobs := make([]localBlob, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + // The entry vanished between ReadDir and Info (a concurrent + // rmi/prune already reclaimed it); skip it rather than aborting + // the whole GC pass, matching the IsNotExist tolerance elsewhere + // in this file. + if os.IsNotExist(err) { + continue + } + return nil, err + } + if !info.Mode().IsRegular() { + continue + } + + name := e.Name() + path := filepath.Join(base, name) + if validSHA256Hex(name) { + h := v1.Hash{Algorithm: "sha256", Hex: name} + blobs = append(blobs, localBlob{path: path, digest: h.String(), hash: h}) + continue + } + blobs = append(blobs, localBlob{path: path, digest: "sha256:" + name, malformed: true}) + } + return blobs, nil +} + +func validSHA256Hex(s string) bool { + return len(s) == 64 && isLowerHex(s) +} + +// isLowerHex reports whether s contains only lowercase hex digits. Callers +// bound the length themselves (the empty string passes). +func isLowerHex(s string) bool { + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} + +// rewriteIndexManifests rewrites index.json to the descriptors keep returns +// true for, writing atomically and durably (writeFileDurable), not through the +// layout package's in-place os.WriteFile: rmi commits an index.json change +// before dropping the pin from refs.json, and that ordering only survives a +// crash if each write is individually durable. A plain truncate-in-place write +// could leave a torn index.json (store unreadable) or a non-durable change a +// crash reverts after the fsynced pin drop, stranding a descriptor and every +// blob it keeps live. It reports whether anything changed so callers can skip +// the write (and its fsync) when the index already matches. +func (s *store) rewriteIndexManifests(keep func(v1.Descriptor) bool) (bool, error) { + idx, err := s.path.ImageIndex() + if err != nil { + return false, fmt.Errorf("rewrite index: read index: %w", err) + } + im, err := idx.IndexManifest() + if err != nil { + return false, fmt.Errorf("rewrite index: parse index: %w", err) + } + kept := make([]v1.Descriptor, 0, len(im.Manifests)) + for _, desc := range im.Manifests { + if keep(desc) { + kept = append(kept, desc) + } + } + if len(kept) == len(im.Manifests) { + return false, nil + } + next := *im + next.Manifests = kept + b, err := json.Marshal(&next) + if err != nil { + return false, fmt.Errorf("rewrite index: marshal index: %w", err) + } + if err := writeFileDurable(filepath.Join(s.root, "index.json"), b, 0o644); err != nil { + return false, err + } + return true, nil +} + +// removeManifestDescriptor removes the manifest descriptor with the given digest +// from index.json. It does not touch the manifest's config or layer blobs; a +// subsequent gc pass reclaims them once they are unreachable from every +// remaining descriptor, so a blob shared with another still-pinned image is +// kept. +func (s *store) removeManifestDescriptor(digest string) error { + h, err := v1.NewHash(digest) + if err != nil { + return fmt.Errorf("remove manifest descriptor: %w", err) + } + _, err = s.rewriteIndexManifests(func(desc v1.Descriptor) bool { + return desc.Digest != h + }) + return err +} + +// pruneUnpinnedDescriptors removes index.json manifest descriptors that no +// refs.json pin references. Reachability GC roots liveness at index.json +// descriptors, so a re-pull that moves a mutable tag to a new digest would +// otherwise leak the prior image forever: its descriptor stays in the index +// (keeping every blob live) even though no ref pins it, and rmi cannot target +// a digest it can no longer resolve through a pin. Reconciling the index to +// the pin set before the blob sweep makes that orphan reclaimable. The store +// only ever appends single-platform image manifests (addImage), and pins point +// at them directly, so a top-level descriptor absent from the pin set is +// genuinely unreferenced. +func (s *store) pruneUnpinnedDescriptors() error { + pins, err := s.loadPins() + if err != nil { + return err + } + pinned := make(map[string]bool, len(pins)) + for _, digest := range pins { + pinned[digest] = true + } + _, err = s.rewriteIndexManifests(func(desc v1.Descriptor) bool { + return pinned[desc.Digest.String()] + }) + return err +} + +// liveCacheKeys returns the set of digest cache keys for every currently pinned +// ref. pruneCaches uses it to keep digest-keyed rootfs/sparsebundle caches that +// are still reachable through refs.json. +func (s *store) liveCacheKeys() (map[string]bool, error) { + pins, err := s.loadPins() + if err != nil { + return nil, err + } + m := make(map[string]bool, len(pins)) + for _, digest := range pins { + key, err := cacheKeyForDigest(digest) + if err != nil { + return nil, err + } + m[key] = true + } + return m, nil +} + +// rmi removes one selected ref from the store. The target may be an exact ref +// or a unique sha256 digest prefix. If other refs still pin the same manifest +// digest, only the resolved pin is dropped: the shared descriptor, blobs, and +// digest-keyed caches stay live through the remaining refs. If this was the last +// pin for the digest, rmi removes the manifest descriptor, GCs the +// now-unreachable blobs, and reclaims the image's unpacked cache: the cache is +// derived state subordinate to the image, so it goes with it instead of being +// left as an orphan only prune --cache could reap. Two safety rules survive +// force: a cache whose volume a live run still uses is never dropped (even with +// force), and a cache holding run --keep retained output refuses without force +// so a deliberate keep is not discarded silently. +func (s *store) rmi(target string, force bool) (rmReport, error) { + // The store lock spans the whole resolve-modify-GC sequence so an rmi + // cannot interleave with a concurrent pull's check-append-pin (or another + // rmi) and lose one side's refs.json/index.json update. + unlock, err := s.lock() + if err != nil { + return rmReport{}, err + } + defer unlock() + pins, err := s.loadPins() + if err != nil { + return rmReport{}, err + } + ref, digest, err := resolvePinnedTarget(pins, target) + if err != nil { + return rmReport{}, err + } + + lastPin := true + for otherRef, otherDigest := range pins { + if otherRef != ref && otherDigest == digest { + lastPin = false + break + } + } + + var cacheDropped bool + if lastPin { + // A live or starting run holds the digest's reference lock (the plain + // rootfs run lock, taken by resolveImageForUse for every run path + // before any cache dir or bundle exists). Probe it directly rather + // than inferring liveness from cacheExists: a cold run that has + // resolved the image but not yet unpacked has no cache to detect, yet + // its descriptor and blobs must survive. rmi holds the store lock + // throughout, and a run claims the reference lock only while holding + // that same store lock (resolveImageForUse), so the set of holders is + // frozen here: a busy probe cannot be a run that is about to appear. + // Refuse regardless of force; force discards derived state, it does + // not evict a running guest. + rootfs, err := defaultRootfsForDigest(s.root, digest) + if err != nil { + return rmReport{}, err + } + if rootfsCacheBusy(rootfs) { + return rmReport{}, fmt.Errorf("rmi: %q is in use by a live run; stop it before removing the image", ref) + } + // A run --keep clone is deliberately retained output living in the + // cache; refuse to discard it without force. Everything else in the + // cache is derived state reclaimed with the image below. + kept, err := cacheHasKeptData(s.root, digest) + if err != nil { + return rmReport{}, fmt.Errorf("rmi: inspect cache for %q: %w", ref, err) + } + if kept && !force { + return rmReport{}, fmt.Errorf("rmi: %q has retained run --keep output; pass --force to discard it, or inspect it with a fresh run then 'elfuse-oci prune --cache'", ref) + } + // Reclaim the unpacked cache as part of removing the image. removeRefCaches + // fails closed if a live run still holds the volume (even with force), so + // this never yanks a rootfs out from under a running guest. + if cacheExists(s.root, digest) { + if err := removeRefCaches(s, digest); err != nil { + return rmReport{}, fmt.Errorf("rmi: drop cache for %q: %w", ref, err) + } + cacheDropped = true + } + } + + // On a last-pin removal, update index.json BEFORE committing the pin + // removal to refs.json. In the opposite order, a failure between the two + // writes strands the manifest: the ref is gone from refs.json (so rmi can + // no longer resolve it) while the descriptor keeps every blob live, and + // prune never removes descriptors; the image becomes unreclaimable. In + // this order the same crash window leaves a stale pin over a removed + // descriptor, which a retried rmi resolves and finishes + // (RemoveDescriptors is a filter, so re-removing is a no-op). + if lastPin { + if err := s.removeManifestDescriptor(digest); err != nil { + return rmReport{}, fmt.Errorf("rmi: remove manifest descriptor for %q: %w", ref, err) + } + } + delete(pins, ref) + if err := s.savePins(pins); err != nil { + return rmReport{}, err + } + if !lastPin { + return rmReport{Ref: ref}, nil + } + rep, err := s.gc(false) + rep.Ref = ref + rep.CacheDropped = cacheDropped + return rep, err +} diff --git a/cmd/elfuse-oci/inspect.go b/cmd/elfuse-oci/inspect.go index bec2f475..fb18f2f6 100644 --- a/cmd/elfuse-oci/inspect.go +++ b/cmd/elfuse-oci/inspect.go @@ -12,6 +12,16 @@ import ( // inspect prints a stored image's manifest + config. With --json the raw config // JSON is emitted (one object); otherwise a human-readable summary. func inspect(w io.Writer, s *store, ref string, asJSON bool) error { + // Hold the store lock across resolution and every metadata read, as list + // does: ConfigFile/Layers are lazy blob reads, so a concurrent rmi on the + // last ref could delete the config/layer blobs between resolving the pin + // and reading them, failing inspect partway through. + unlock, err := s.lock() + if err != nil { + return err + } + defer unlock() + img, err := s.image(ref) if err != nil { return err @@ -47,9 +57,10 @@ func inspect(w io.Writer, s *store, ref string, asJSON bool) error { bw := bufio.NewWriter(w) w = bw + platform := Platform{OS: cfg.OS, Arch: cfg.Architecture, Variant: cfg.Variant}.String() fmt.Fprintf(w, "%-12s %s\n", "Ref:", ref) fmt.Fprintf(w, "%-12s %s\n", "Digest:", d) - fmt.Fprintf(w, "%-12s %s/%s\n", "Platform:", cfg.OS, cfg.Architecture) + fmt.Fprintf(w, "%-12s %s\n", "Platform:", platform) if !cfg.Created.IsZero() { fmt.Fprintf(w, "%-12s %s\n", "Created:", cfg.Created.UTC().Format("2006-01-02T15:04:05Z")) } diff --git a/cmd/elfuse-oci/inspect_test.go b/cmd/elfuse-oci/inspect_test.go index 0a2b240b..ea5a7cc5 100644 --- a/cmd/elfuse-oci/inspect_test.go +++ b/cmd/elfuse-oci/inspect_test.go @@ -113,6 +113,80 @@ func TestInspectHumanIncludesCreatedEnvAndLayers(t *testing.T) { } } +// TestInspectHoldsStoreLock: inspect resolves and reads image metadata +// under the store lock, so a concurrent rmi cannot delete blobs mid-inspect. +// The proof is that inspect blocks while another holder keeps the lock and +// completes once it is released. +func TestInspectHoldsStoreLock(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:tiny", tinyImage(t)); err != nil { + t.Fatal(err) + } + unlock, err := s.lock() + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { + var buf bytes.Buffer + done <- inspect(&buf, s, "local:tiny", false) + }() + + select { + case <-done: + unlock() + t.Fatal("inspect completed while the store lock was held; it does not take the lock") + case <-time.After(150 * time.Millisecond): + } + + unlock() + select { + case err := <-done: + if err != nil { + t.Fatalf("inspect after unlock: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("inspect did not complete after the store lock was released") + } +} + +// TestInspectAndListAgreeOnVariant: an image with a platform variant +// (linux/arm/v7) must show the full os/arch/variant in inspect, matching list, +// not the truncated os/arch. +func TestInspectAndListAgreeOnVariant(t *testing.T) { + s := openTestStore(t) + img := tinyImage(t) + cfg, err := img.ConfigFile() + if err != nil { + t.Fatal(err) + } + cfg.Architecture = "arm" + cfg.Variant = "v7" + img, err = mutate.ConfigFile(img, cfg) + if err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:v7", img); err != nil { + t.Fatal(err) + } + + var ins bytes.Buffer + if err := inspect(&ins, s, "local:v7", false); err != nil { + t.Fatal(err) + } + if !strings.Contains(ins.String(), "linux/arm/v7") { + t.Fatalf("inspect platform missing variant:\n%s", ins.String()) + } + var lst bytes.Buffer + if err := list(&lst, s, false); err != nil { + t.Fatal(err) + } + if !strings.Contains(lst.String(), "linux/arm/v7") { + t.Fatalf("list platform missing variant:\n%s", lst.String()) + } +} + func TestInspectMissingRefAndConfigErrors(t *testing.T) { s := openTestStore(t) if err := inspect(&bytes.Buffer{}, s, "local:missing", false); err == nil || !strings.Contains(err.Error(), "not pulled") { diff --git a/cmd/elfuse-oci/lifecycle_darwin_test.go b/cmd/elfuse-oci/lifecycle_darwin_test.go new file mode 100644 index 00000000..70110c56 --- /dev/null +++ b/cmd/elfuse-oci/lifecycle_darwin_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The darwin-only sparsebundle lifecycle (prune --cache detaching a stale mount +// and reaping abandoned COW clones, rmi --force dropping a mounted bundle) +// cannot run in hosted CI: it needs hdiutil + APFS + a real attach. The +// cross-platform pieces it rests on (listSweepableClones, csBundleBusy, the +// bundle flocks, pruneCaches, pruneRootfsCaches) are covered in +// lifecycle_test.go and bundlelock_test.go. This file adds the darwin-only +// round-trip behind ELFUSE_OCI_DARWIN_CS=1 so a Mac operator can opt in; +// without the flag it skips, so `go test ./cmd/elfuse-oci/` stays green +// everywhere by default. + +// TestDarwinCSSweep exercises the crash-recovery path prune --cache runs per +// sparsebundle bundle: while a live run holds the bundle's run.lock the volume +// is reported busy and stays attached; once that run is gone the next sweep +// reaps the abandoned (unmarked) clone, preserves a --keep-marked clone, and +// detaches, after which removeRefCaches drops the whole bundle. Gated because +// it provisions a real APFS sparsebundle via hdiutil. +func TestDarwinCSSweep(t *testing.T) { + if os.Getenv("ELFUSE_OCI_DARWIN_CS") == "" { + t.Skip("set ELFUSE_OCI_DARWIN_CS=1 to exercise the darwin sparsebundle sweep (needs hdiutil + APFS)") + } + + s := openTestStore(t) + digest := "sha256:" + strings.Repeat("1", 64) + bundle, err := csBundleDirForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + mnt := filepath.Join(bundle, "mnt") + // provision returns a mount holding run.lock shared; this stands in for + // the live run. Drop owned so Close does not detach; we release the + // liveness lock explicitly to simulate the run exiting. + m, err := provisionCaseSensitive(bundle, mnt, "32m") + if err != nil { + t.Fatalf("provision: %v", err) + } + m.owned = false + t.Cleanup(func() { + if isMountPoint(mnt) { + _ = detachForce(mnt) + } + }) + + // Plant an abandoned clone (reapable) and a --keep-marked clone (preserved). + reapClone := filepath.Join(mnt, "run-1-1") + keepClone := filepath.Join(mnt, "run-2-2") + for _, p := range []string{reapClone, keepClone} { + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + } + if err := writeKeepMarker(keepClone); err != nil { + t.Fatal(err) + } + if !isMountPoint(mnt) { + t.Fatalf("mnt %s not attached after provision", mnt) + } + + // First sweep: the live run still holds run.lock, so the bundle is busy; + // nothing is reaped and the volume stays attached. + reaped, busy, unlock, err := sweepCSBundle(bundle) + if err != nil { + t.Fatalf("sweepCSBundle: %v", err) + } + unlock() + if !busy { + t.Fatal("sweepCSBundle busy = false while a live run holds run.lock") + } + if len(reaped) != 0 { + t.Fatalf("sweepCSBundle reaped = %v while busy, want none", reaped) + } + if !isMountPoint(mnt) { + t.Fatal("volume detached although a live run remains") + } + + // The live run exits: release its run.lock. + if err := m.runLock.Close(); err != nil { + t.Fatal(err) + } + + // Second sweep, idle now: the unmarked clone is reaped, the kept clone + // preserved, and the stale mount detached. + reaped, busy, unlock, err = sweepCSBundle(bundle) + if err != nil { + t.Fatalf("sweepCSBundle after run exit: %v", err) + } + unlock() + if busy { + t.Fatalf("second sweep reported busy, want idle") + } + if len(reaped) != 1 || reaped[0] != reapClone { + t.Fatalf("second sweep reaped = %v, want [%s]", reaped, reapClone) + } + if isMountPoint(mnt) { + t.Errorf("mnt still attached after idle sweepCSBundle, want detached") + } + + // removeRefCaches should now delete the whole bundle directory, kept clone + // and all. + if err := removeRefCaches(s, digest); err != nil { + t.Fatalf("removeRefCaches: %v", err) + } + if _, err := os.Stat(bundle); !os.IsNotExist(err) { + t.Errorf("bundle dir after removeRefCaches: %v, want IsNotExist", err) + } +} diff --git a/cmd/elfuse-oci/lifecycle_test.go b/cmd/elfuse-oci/lifecycle_test.go new file mode 100644 index 00000000..3b7d2c27 --- /dev/null +++ b/cmd/elfuse-oci/lifecycle_test.go @@ -0,0 +1,1218 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" + + "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" +) + +func firstLayerDigest(t *testing.T, img v1.Image) v1.Hash { + t.Helper() + ls, err := img.Layers() + if err != nil || len(ls) == 0 { + t.Fatalf("layers: %v (n=%d)", err, len(ls)) + } + d, err := ls[0].Digest() + if err != nil { + t.Fatal(err) + } + return d +} + +func indexManifestCount(t *testing.T, root string) int { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, "index.json")) + if err != nil { + t.Fatal(err) + } + var idx struct { + Manifests []json.RawMessage `json:"manifests"` + } + if err := json.Unmarshal(b, &idx); err != nil { + t.Fatalf("index.json unmarshal: %v", err) + } + return len(idx.Manifests) +} + +func rootfsForDigest(t *testing.T, s *store, digest string) string { + t.Helper() + rootfs, err := defaultRootfsForDigest(s.root, digest) + if err != nil { + t.Fatal(err) + } + return rootfs +} + +func rootfsForImage(t *testing.T, s *store, img v1.Image) string { + t.Helper() + d, err := img.Digest() + if err != nil { + t.Fatal(err) + } + return rootfsForDigest(t, s, d.String()) +} + +// TestPruneReclaimsRepulledTagPriorImage: re-pulling a mutable tag to +// a new digest must not leak the prior image. Reachability GC roots at the pin +// set, and gc reconciles index.json to it, so after the tag moves A->B a prune +// removes A's orphaned descriptor and its unique blobs (manifest, config) while +// keeping the layer blob B still shares, and B stays intact. +func TestPruneReclaimsRepulledTagPriorImage(t *testing.T) { + s := openTestStore(t) + imgA := buildImage(t, []string{"/a"}) + imgB := buildImage(t, []string{"/b"}) + + manifestA, err := imgA.Digest() + if err != nil { + t.Fatal(err) + } + configA, err := imgA.ConfigName() + if err != nil { + t.Fatal(err) + } + sharedLayer := firstLayerDigest(t, imgA) + if got := firstLayerDigest(t, imgB); got != sharedLayer { + t.Fatalf("test images do not share a layer: A=%s B=%s", sharedLayer, got) + } + + if _, err := s.addImage("local:tag", imgA); err != nil { + t.Fatal(err) + } + digestB, err := s.addImage("local:tag", imgB) // repin tag A->B; A now orphaned + if err != nil { + t.Fatal(err) + } + if n := indexManifestCount(t, s.root); n != 2 { + t.Fatalf("index has %d descriptors after repull, want 2 (A leaked, B live)", n) + } + + if err := cmdPrune([]string{"--store", s.root}); err != nil { + t.Fatalf("prune: %v", err) + } + + if n := indexManifestCount(t, s.root); n != 1 { + t.Fatalf("index has %d descriptors after prune, want 1 (A reclaimed)", n) + } + for _, gone := range []v1.Hash{manifestA, configA} { + if _, err := os.Stat(blobPath(s.root, gone.String())); !os.IsNotExist(err) { + t.Errorf("orphaned blob %s still present after prune: %v", gone, err) + } + } + if _, err := os.Stat(blobPath(s.root, sharedLayer.String())); err != nil { + t.Errorf("shared layer %s reclaimed although B still references it: %v", sharedLayer, err) + } + got, err := s.digestFor("local:tag") + if err != nil || got != digestB { + t.Fatalf("tag resolves to %q err=%v; want B %s", got, err, digestB) + } + if _, err := s.image("local:tag"); err != nil { + t.Fatalf("image B unreadable after prune: %v", err) + } +} + +// --- list ------------------------------------------------------------------- + +func TestListEmpty(t *testing.T) { + s := openTestStore(t) + var buf bytes.Buffer + if err := list(&buf, s, false); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Errorf("human list of empty store produced output %q, want none", buf.String()) + } + var jbuf bytes.Buffer + if err := list(&jbuf, s, true); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(jbuf.String()) != "[]" { + t.Errorf("json list of empty store = %q, want []", jbuf.String()) + } +} + +func TestListShape(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/hello"})); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := list(&buf, s, false); err != nil { + t.Fatal(err) + } + out := buf.String() + for _, want := range []string{"local:a", "linux/arm64"} { + if !strings.Contains(out, want) { + t.Errorf("human list missing %q in:\n%s", want, out) + } + } + + var jbuf bytes.Buffer + if err := list(&jbuf, s, true); err != nil { + t.Fatal(err) + } + var entries []listEntry + if err := json.Unmarshal(jbuf.Bytes(), &entries); err != nil { + t.Fatalf("json list unmarshal: %v (raw %q)", err, jbuf.String()) + } + if len(entries) != 1 || entries[0].Ref != "local:a" { + t.Fatalf("json list entries = %+v, want one local:a", entries) + } + e := entries[0] + if !strings.HasPrefix(e.Digest, "sha256:") { + t.Errorf("json digest = %q, want sha256: prefix", e.Digest) + } + if e.Platform != "linux/arm64" { + t.Errorf("json platform = %q, want linux/arm64", e.Platform) + } + if e.Layers != 1 { + t.Errorf("json layers = %d, want 1", e.Layers) + } + if e.Size <= 0 { + t.Errorf("json size = %d, want > 0 (compressed layer size)", e.Size) + } +} + +func TestListCorruptConfigErrors(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + config, err := img.ConfigName() + if err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + if err := os.Remove(blobPath(s.root, config.String())); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + err = list(&buf, s, false) + if err == nil || !strings.Contains(err.Error(), "list: local:a: config") { + t.Fatalf("list err = %v, want local:a config error", err) + } +} + +func TestListMultipleRefsSorted(t *testing.T) { + s := openTestStore(t) + for _, ref := range []string{"local:b", "local:a"} { + if _, err := s.addImage(ref, buildImage(t, []string{ref})); err != nil { + t.Fatal(err) + } + } + var buf bytes.Buffer + if err := list(&buf, s, false); err != nil { + t.Fatal(err) + } + out := buf.String() + if i, j := strings.Index(out, "local:a"), strings.Index(out, "local:b"); i < 0 || j < 0 || i > j { + t.Errorf("list not sorted by ref:\n%s", out) + } +} + +// --- rmi -------------------------------------------------------------------- + +func TestRmiDropsPinAndBlobs(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + manifest, _ := img.Digest() + config, _ := img.ConfigName() + layer := firstLayerDigest(t, img) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + + if _, err := s.rmi("local:a", false); err != nil { + t.Fatalf("rmi: %v", err) + } + if _, err := s.digestFor("local:a"); err == nil { + t.Error("digestFor after rmi succeeded, want not-pulled error") + } + for _, d := range []string{manifest.String(), config.String(), layer.String()} { + if _, err := os.Stat(blobPath(s.root, d)); !os.IsNotExist(err) { + t.Errorf("blob %s after rmi: %v, want IsNotExist", d, err) + } + } + if n := indexManifestCount(t, s.root); n != 0 { + t.Errorf("index.json after rmi has %d manifest descriptors, want 0", n) + } +} + +func TestRmiByRefDropsStaleTempBlob(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + layer := firstLayerDigest(t, img) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + stale := writeStaleTempBlob(t, s.root, layer.String()) + + rep, err := s.rmi("local:a", false) + if err != nil { + t.Fatalf("rmi by ref with stale temp blob: %v", err) + } + if !slices.Contains(rep.Blobs, stale) { + t.Fatalf("rmi report blobs = %v, want stale temp blob %s", rep.Blobs, stale) + } + if _, err := os.Stat(blobPath(s.root, stale)); !os.IsNotExist(err) { + t.Fatalf("stale temp blob after rmi by ref: %v, want IsNotExist", err) + } + if _, err := s.digestFor("local:a"); err == nil { + t.Fatal("local:a pin still present after rmi by ref") + } +} + +func TestRmiKeepsSharedBlobs(t *testing.T) { + s := openTestStore(t) + imgA := buildImage(t, []string{"/a"}) + imgB := buildImage(t, []string{"/b"}) + sharedLayer := firstLayerDigest(t, imgA) // same content -> same digest as imgB's layer + if _, err := s.addImage("local:a", imgA); err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:b", imgB); err != nil { + t.Fatal(err) + } + + if _, err := s.rmi("local:a", false); err != nil { + t.Fatalf("rmi local:a: %v", err) + } + if _, err := os.Stat(blobPath(s.root, sharedLayer.String())); err != nil { + t.Errorf("shared layer blob after rmi local:a: %v, want present (still reachable via local:b)", err) + } + if _, err := s.digestFor("local:b"); err != nil { + t.Fatalf("local:b pin lost after rmi local:a: %v", err) + } + if img, err := s.image("local:b"); err != nil { + t.Errorf("s.image(local:b) after rmi local:a: %v", err) + } else if ls, _ := img.Layers(); len(ls) != 1 { + t.Errorf("local:b layers after rmi local:a = %d, want 1", len(ls)) + } +} + +func TestRmiKeepsSameDigestPinnedByOtherRef(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + manifest, _ := img.Digest() + config, _ := img.ConfigName() + layer := firstLayerDigest(t, img) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:b", img); err != nil { + t.Fatal(err) + } + if n := indexManifestCount(t, s.root); n != 1 { + t.Fatalf("index manifest count before rmi = %d, want 1", n) + } + + rep, err := s.rmi("local:a", false) + if err != nil { + t.Fatalf("rmi local:a: %v", err) + } + if len(rep.Blobs) != 0 || rep.Bytes != 0 { + t.Fatalf("rmi local:a report = %+v, want no blobs removed", rep) + } + if _, err := s.digestFor("local:a"); err == nil { + t.Error("local:a pin still present after rmi, want gone") + } + if got, err := s.digestFor("local:b"); err != nil { + t.Fatalf("local:b pin lost after rmi local:a: %v", err) + } else if got != manifest.String() { + t.Fatalf("local:b digest = %s, want %s", got, manifest) + } + if n := indexManifestCount(t, s.root); n != 1 { + t.Fatalf("index manifest count after rmi local:a = %d, want 1", n) + } + for _, d := range []string{manifest.String(), config.String(), layer.String()} { + if _, err := os.Stat(blobPath(s.root, d)); err != nil { + t.Errorf("blob %s after rmi local:a: %v, want present", d, err) + } + } + if _, err := s.image("local:b"); err != nil { + t.Fatalf("s.image(local:b) after rmi local:a: %v", err) + } +} + +func TestRmiAbsentRefErrors(t *testing.T) { + s := openTestStore(t) + _, err := s.rmi("local:never", false) + if err == nil || !strings.Contains(err.Error(), "not pulled") { + t.Errorf("rmi absent ref err = %v, want an error mentioning \"not pulled\"", err) + } +} + +func TestRmiByDigestDropsStaleTempBlob(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + manifest, _ := img.Digest() + layer := firstLayerDigest(t, img) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + stale := writeStaleTempBlob(t, s.root, layer.String()) + + rep, err := s.rmi(shortDigest(manifest.String()), false) + if err != nil { + t.Fatalf("rmi by digest with stale temp blob: %v", err) + } + if rep.Ref != "local:a" { + t.Fatalf("rmi report ref = %q, want local:a", rep.Ref) + } + if !slices.Contains(rep.Blobs, stale) { + t.Fatalf("rmi report blobs = %v, want stale temp blob %s", rep.Blobs, stale) + } + if _, err := os.Stat(blobPath(s.root, stale)); !os.IsNotExist(err) { + t.Fatalf("stale temp blob after rmi by digest: %v, want IsNotExist", err) + } +} + +func TestRmiAcceptsDigestFromList(t *testing.T) { + for _, tc := range []struct { + name string + target func(string) string + }{ + {"short hex", shortDigest}, + {"full digest", func(d string) string { return d }}, + {"qualified prefix", func(d string) string { return "sha256:" + shortDigest(d) }}, + } { + t.Run(tc.name, func(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + manifest, _ := img.Digest() + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + + rep, err := s.rmi(tc.target(manifest.String()), false) + if err != nil { + t.Fatalf("rmi by digest: %v", err) + } + if rep.Ref != "local:a" { + t.Fatalf("rmi report ref = %q, want local:a", rep.Ref) + } + if _, err := s.digestFor("local:a"); err == nil { + t.Fatal("local:a pin still present after digest rmi") + } + if _, err := os.Stat(blobPath(s.root, manifest.String())); !os.IsNotExist(err) { + t.Fatalf("manifest blob after digest rmi: %v, want IsNotExist", err) + } + }) + } +} + +func TestCmdRmiAcceptsDigestPrintedByList(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/hello"})); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := list(&buf, s, false); err != nil { + t.Fatal(err) + } + fields := strings.Fields(buf.String()) + if len(fields) < 7 { + t.Fatalf("list output has too few fields:\n%s", buf.String()) + } + listedDigest := fields[6] + + if err := cmdRmi([]string{"--store", s.root, listedDigest}); err != nil { + t.Fatalf("cmdRmi by listed digest %q: %v", listedDigest, err) + } + if _, err := s.digestFor("local:a"); err == nil { + t.Fatal("local:a pin still present after cmdRmi by listed digest") + } +} + +func TestRmiDigestPrefixAmbiguous(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + manifest, _ := img.Digest() + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:b", img); err != nil { + t.Fatal(err) + } + + _, err := s.rmi(shortDigest(manifest.String()), false) + if err == nil || !strings.Contains(err.Error(), "ambiguous") || !strings.Contains(err.Error(), "local:a, local:b") { + t.Fatalf("rmi ambiguous digest err = %v, want both refs listed", err) + } + if _, err := s.digestFor("local:a"); err != nil { + t.Fatalf("local:a pin lost after ambiguous rmi: %v", err) + } + if _, err := s.digestFor("local:b"); err != nil { + t.Fatalf("local:b pin lost after ambiguous rmi: %v", err) + } +} + +func TestRmiKeepsCacheForSameDigestPinnedByOtherRef(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:b", img); err != nil { + t.Fatal(err) + } + rootfs := rootfsForImage(t, s, img) + if err := os.MkdirAll(rootfs, 0o755); err != nil { + t.Fatal(err) + } + + if _, err := s.rmi("local:a", false); err != nil { + t.Fatalf("rmi local:a with shared cache: %v", err) + } + if _, err := os.Stat(rootfs); err != nil { + t.Fatalf("shared digest rootfs after rmi local:a: %v, want present", err) + } + if _, err := s.digestFor("local:b"); err != nil { + t.Fatalf("local:b pin lost after rmi local:a: %v", err) + } + + // Removing the last ref reclaims the now-unshared cache with the image. + rep, err := s.rmi("local:b", false) + if err != nil { + t.Fatalf("rmi final shared-cache ref: %v", err) + } + if !rep.CacheDropped { + t.Error("rmi last shared-cache ref did not report dropping the cache") + } + if _, err := os.Stat(rootfs); !os.IsNotExist(err) { + t.Fatalf("shared digest rootfs after final rmi: %v, want removed", err) + } +} + +func TestRmiForceDropsCache(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + rootfs := rootfsForImage(t, s, img) + if err := os.MkdirAll(rootfs, 0o755); err != nil { + t.Fatal(err) + } + + if _, err := s.rmi("local:a", true); err != nil { + t.Fatalf("rmi --force: %v", err) + } + if _, err := os.Stat(rootfs); !os.IsNotExist(err) { + t.Errorf("rootfs cache after rmi --force: %v, want IsNotExist", err) + } + if _, err := s.digestFor("local:a"); err == nil { + t.Error("pin still present after rmi --force, want gone") + } +} + +// TestRmiDropsColdCacheWithoutForce pins the fixed behavior: a plain rmi +// reclaims a cold unpacked cache as part of removing the image, so the natural +// run -> rmi lifecycle needs no --force and leaves no orphan cache. +func TestRmiDropsColdCacheWithoutForce(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + rootfs := rootfsForImage(t, s, img) + if err := os.MkdirAll(rootfs, 0o755); err != nil { + t.Fatal(err) + } + + rep, err := s.rmi("local:a", false) + if err != nil { + t.Fatalf("rmi cold cache without --force: %v", err) + } + if !rep.CacheDropped { + t.Error("rmi did not report dropping the cold cache") + } + if _, err := os.Stat(rootfs); !os.IsNotExist(err) { + t.Errorf("cold cache after rmi: %v, want removed", err) + } + if _, err := s.digestFor("local:a"); err == nil { + t.Error("pin present after rmi, want gone") + } +} + +func TestDigestCachePathChangesWhenRefDigestChanges(t *testing.T) { + s := openTestStore(t) + ref := "local:tag" + + imgA := buildImage(t, []string{"/a"}) + if _, err := s.addImage(ref, imgA); err != nil { + t.Fatal(err) + } + rootfsA := rootfsForImage(t, s, imgA) + + imgB := buildImage(t, []string{"/b"}) + if _, err := s.addImage(ref, imgB); err != nil { + t.Fatal(err) + } + rootfsB := rootfsForImage(t, s, imgB) + + if rootfsA == rootfsB { + t.Fatalf("digest-keyed rootfs path did not change across repull: %s", rootfsA) + } + if got := filepath.Dir(rootfsB); filepath.Base(got) != "sha256" { + t.Fatalf("rootfs path %s not under rootfs/sha256/", rootfsB) + } +} + +func TestDigestCachePathsAvoidRefEncodingCollisions(t *testing.T) { + s := openTestStore(t) + refA := "local/a:b" + refB := "local:a/b" + if legacyCacheNameForRef(refA) != legacyCacheNameForRef(refB) { + t.Fatalf("test refs no longer collide under legacy encoding: %q vs %q", refA, refB) + } + + imgA := buildImage(t, []string{"/a"}) + imgB := buildImage(t, []string{"/b"}) + if _, err := s.addImage(refA, imgA); err != nil { + t.Fatal(err) + } + if _, err := s.addImage(refB, imgB); err != nil { + t.Fatal(err) + } + + rootfsA := rootfsForImage(t, s, imgA) + rootfsB := rootfsForImage(t, s, imgB) + if rootfsA == rootfsB { + t.Fatalf("digest-keyed refs collided at %s", rootfsA) + } +} + +// --- prune ------------------------------------------------------------------ + +// writeOrphanBlob writes an unreferenced file under blobs/sha256/ named with a +// valid sha256 digest so the local GC treats it as an unreachable blob. +func writeOrphanBlob(t *testing.T, root, content string) string { + t.Helper() + sum := sha256.Sum256([]byte(content)) + hex := hex.EncodeToString(sum[:]) + p := blobPath(root, hex) + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return "sha256:" + hex +} + +func writeStaleTempBlob(t *testing.T, root, baseDigest string) string { + t.Helper() + name := strings.TrimPrefix(baseDigest, "sha256:") + "1072211852" + p := blobPath(root, name) + if err := os.WriteFile(p, []byte("stale temp blob"), 0o644); err != nil { + t.Fatal(err) + } + return "sha256:" + name +} + +func TestPruneSweepsOrphanBlob(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + manifest, _ := img.Digest() + config, _ := img.ConfigName() + layer := firstLayerDigest(t, img) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + orphan := writeOrphanBlob(t, s.root, "orphan-bytes") + + rep, err := s.gc(false) + if err != nil { + t.Fatalf("gc: %v", err) + } + if len(rep.Blobs) != 1 || rep.Blobs[0] != orphan { + t.Errorf("gc removed = %v, want [%s]", rep.Blobs, orphan) + } + if _, err := os.Stat(blobPath(s.root, orphan)); !os.IsNotExist(err) { + t.Errorf("orphan blob after prune: %v, want gone", err) + } + for _, d := range []string{manifest.String(), config.String(), layer.String()} { + if _, err := os.Stat(blobPath(s.root, d)); err != nil { + t.Errorf("referenced blob %s lost after prune: %v", d, err) + } + } +} + +func TestPruneSweepsOrphanAndStaleTempBlobs(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + layer := firstLayerDigest(t, img) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + orphan := writeOrphanBlob(t, s.root, "orphan-bytes") + stale := writeStaleTempBlob(t, s.root, layer.String()) + + rep, err := s.gc(false) + if err != nil { + t.Fatalf("gc with stale temp blob: %v", err) + } + for _, want := range []string{orphan, stale} { + if !slices.Contains(rep.Blobs, want) { + t.Fatalf("gc removed = %v, want %s", rep.Blobs, want) + } + if _, err := os.Stat(blobPath(s.root, want)); !os.IsNotExist(err) { + t.Fatalf("blob %s after prune: %v, want gone", want, err) + } + } + if _, err := os.Stat(blobPath(s.root, layer.String())); err != nil { + t.Fatalf("live layer blob after prune: %v, want present", err) + } +} + +func TestPruneDryRunDeletesNothing(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/hello"})); err != nil { + t.Fatal(err) + } + orphan := writeOrphanBlob(t, s.root, "orphan-bytes") + + rep, err := s.gc(true) + if err != nil { + t.Fatalf("gc --dry-run: %v", err) + } + if len(rep.Blobs) != 1 || rep.Blobs[0] != orphan { + t.Errorf("dry-run gc reported = %v, want [%s]", rep.Blobs, orphan) + } + if _, err := os.Stat(blobPath(s.root, orphan)); err != nil { + t.Errorf("orphan blob deleted under --dry-run: %v, want present", err) + } +} + +func TestPruneDryRunKeepsStaleTempBlob(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + layer := firstLayerDigest(t, img) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + stale := writeStaleTempBlob(t, s.root, layer.String()) + + rep, err := s.gc(true) + if err != nil { + t.Fatalf("gc --dry-run with stale temp blob: %v", err) + } + if !slices.Contains(rep.Blobs, stale) { + t.Fatalf("dry-run gc reported = %v, want stale temp blob %s", rep.Blobs, stale) + } + if _, err := os.Stat(blobPath(s.root, stale)); err != nil { + t.Fatalf("stale temp blob deleted under --dry-run: %v, want present", err) + } +} + +func TestPruneCacheDropsUnpulledRootfs(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/hello"})); err != nil { + t.Fatal(err) + } + // Legacy cache for an unpulled ref "local:b", an orphan cache under the + // digest-keyed layout. + orphanCache := legacyRootfsForRef(s.root, "local:b") + if err := os.MkdirAll(orphanCache, 0o755); err != nil { + t.Fatal(err) + } + + rep, err := s.pruneCaches(pruneOpts{cache: true}) + if err != nil { + t.Fatalf("pruneCaches: %v", err) + } + if len(rep.CacheDirs) != 1 || rep.CacheDirs[0] != orphanCache { + t.Errorf("pruneCaches dropped = %v, want [%s]", rep.CacheDirs, orphanCache) + } + if _, err := os.Stat(orphanCache); !os.IsNotExist(err) { + t.Errorf("orphan rootfs cache after prune --cache: %v, want gone", err) + } + if _, err := s.digestFor("local:a"); err != nil { + t.Errorf("local:a pin lost after prune --cache: %v", err) + } +} + +func TestPruneCacheAllDropsPulledRootfs(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + liveCache := rootfsForImage(t, s, img) + if err := os.MkdirAll(liveCache, 0o755); err != nil { + t.Fatal(err) + } + + rep, err := s.pruneCaches(pruneOpts{cache: true, all: true}) + if err != nil { + t.Fatalf("pruneCaches --all: %v", err) + } + if len(rep.CacheDirs) != 1 || rep.CacheDirs[0] != liveCache { + t.Errorf("pruneCaches --all dropped = %v, want [%s]", rep.CacheDirs, liveCache) + } + if _, err := os.Stat(liveCache); !os.IsNotExist(err) { + t.Errorf("live rootfs cache after prune --cache --all: %v, want gone", err) + } + // --all drops the cache only; the store (pin + blobs) is untouched. + if _, err := s.digestFor("local:a"); err != nil { + t.Errorf("local:a pin lost after prune --cache --all: %v", err) + } +} + +func TestRmiThenPruneIdempotent(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/hello"})); err != nil { + t.Fatal(err) + } + if _, err := s.rmi("local:a", false); err != nil { + t.Fatalf("rmi: %v", err) + } + rep, err := s.gc(false) + if err != nil { + t.Fatalf("gc after rmi: %v", err) + } + if len(rep.Blobs) != 0 { + t.Errorf("gc after rmi removed %v, want nothing (already reclaimed)", rep.Blobs) + } +} + +// --- sweepable clone detection ---------------------------------------------- + +// TestListSweepableClonesSkipsKeepMarkerAndNonClones pins the reap set the +// bundle sweep uses once it holds run.lock exclusively: every run-- +// clone WITHOUT a keep marker plus any rootfs.tmp-* unpack leftover, and +// nothing else. Pids are irrelevant now; the exclusive lock already proves +// no run is live. +func TestListSweepableClonesSkipsKeepMarkerAndNonClones(t *testing.T) { + dir := t.TempDir() + reapClone := filepath.Join(dir, "run-1234-1") + keepClone := filepath.Join(dir, "run-5678-2") + tmpUnpack := filepath.Join(dir, "rootfs.tmp-abcd") + rootfs := filepath.Join(dir, "rootfs") + for _, p := range []string{reapClone, keepClone, tmpUnpack, rootfs} { + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + } + if err := writeKeepMarker(keepClone); err != nil { + t.Fatal(err) + } + + got := listSweepableClones(dir) + want := map[string]bool{reapClone: true, tmpUnpack: true} + if len(got) != len(want) { + t.Fatalf("listSweepableClones = %v, want %v", got, want) + } + for _, g := range got { + if !want[g] { + t.Fatalf("listSweepableClones included %q, want only %v", g, want) + } + } + + // The read-only lister removes nothing. + for _, p := range []string{reapClone, keepClone, tmpUnpack, rootfs} { + if _, err := os.Stat(p); err != nil { + t.Errorf("listSweepableClones removed %s, want read-only: %v", p, err) + } + } + + // reapSweepableClones removes exactly the sweepable set, keeping the + // marked clone, the base rootfs, and the keep marker's clone dir. + reaped := reapSweepableClones(dir) + if len(reaped) != 2 { + t.Fatalf("reapSweepableClones = %v, want 2 entries", reaped) + } + if _, err := os.Stat(reapClone); !os.IsNotExist(err) { + t.Errorf("unmarked clone not reaped: %v", err) + } + if _, err := os.Stat(tmpUnpack); !os.IsNotExist(err) { + t.Errorf("unpack leftover not reaped: %v", err) + } + if _, err := os.Stat(keepClone); err != nil { + t.Errorf("kept clone was reaped, want preserved: %v", err) + } + if _, err := os.Stat(rootfs); err != nil { + t.Errorf("base rootfs was reaped, want left alone: %v", err) + } +} + +// TestListSweepableClonesPreservesOnMarkerStatError pins the fail-safe: if the +// keep-marker stat returns a transient non-ENOENT error (here EACCES from an +// unreadable keep directory), the clone is preserved rather than reaped; +// reaping a --keep clone on a flaky read would be data loss. +func TestListSweepableClonesPreservesOnMarkerStatError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("permission bits do not bind as root") + } + dir := t.TempDir() + clone := filepath.Join(dir, "run-1-1") + if err := os.MkdirAll(clone, 0o755); err != nil { + t.Fatal(err) + } + // 0o000 on the keep dir makes os.Stat(.elfuse-keep/run-1-1) fail with + // EACCES (cannot traverse .elfuse-keep), not ENOENT, while dir itself stays + // readable so the clone is still discovered. + if err := os.MkdirAll(keepDirPath(dir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(keepDirPath(dir), 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(keepDirPath(dir), 0o755) }) + + if got := listSweepableClones(dir); len(got) != 0 { + t.Fatalf("listSweepableClones = %v, want empty (fail safe on marker stat error)", got) + } +} + +// TestListSweepableClonesReapsImageShippedKeepFile: a clone whose +// own contents include /.elfuse-keep (an image that ships that path, or a guest +// that wrote it) is NOT preserved. The keep record lives in the mount-root keep +// directory, outside every guest's view, so only elfuse-oci's --keep can +// set it; forged in-clone files are ignored and the clone is reaped. +func TestListSweepableClonesReapsImageShippedKeepFile(t *testing.T) { + dir := t.TempDir() + clone := filepath.Join(dir, "run-7-7") + if err := os.MkdirAll(clone, 0o755); err != nil { + t.Fatal(err) + } + // The image (or guest) planted /.elfuse-keep inside the clone. + if err := os.WriteFile(filepath.Join(clone, ".elfuse-keep"), nil, 0o644); err != nil { + t.Fatal(err) + } + + got := listSweepableClones(dir) + if len(got) != 1 || got[0] != clone { + t.Fatalf("listSweepableClones = %v, want the clone reapable despite in-clone /.elfuse-keep", got) + } + + // A genuine --keep record in the mount-root keep dir does preserve it. + if err := writeKeepMarker(clone); err != nil { + t.Fatal(err) + } + if got := listSweepableClones(dir); len(got) != 0 { + t.Fatalf("listSweepableClones = %v, want kept clone skipped after writeKeepMarker", got) + } +} + +// TestCSBundleBusy pins the read-only busy probe used by prune --dry-run: a +// bundle whose run.lock is held (a live run) reports busy; an idle one does +// not. +func TestCSBundleBusy(t *testing.T) { + bundle := t.TempDir() + if csBundleBusy(bundle) { + t.Fatal("csBundleBusy(idle) = true, want false") + } + hold, err := acquireFlock(runLockPath(bundle), syscall.LOCK_SH) + if err != nil { + t.Fatal(err) + } + if !csBundleBusy(bundle) { + t.Fatal("csBundleBusy(live run) = false, want true") + } + if err := hold.Close(); err != nil { + t.Fatal(err) + } + if csBundleBusy(bundle) { + t.Fatal("csBundleBusy after release = true, want false") + } +} + +func TestListMissingImage(t *testing.T) { + s := openTestStore(t) + missingDigest := "sha256:" + strings.Repeat("9", 64) + if err := s.savePins(refPins{"missing": missingDigest}); err != nil { + t.Fatal(err) + } + if err := list(&bytes.Buffer{}, s, false); err == nil || !strings.Contains(err.Error(), "list: missing: image") { + t.Fatalf("list missing image err = %v, want image error", err) + } +} + +func TestShortDigest(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"short hex passthrough", "abcdef", "abcdef"}, + {"exactly twelve", "123456789012", "123456789012"}, + {"truncated to twelve", "123456789012345", "123456789012"}, + {"sha256 prefix stripped and truncated", "sha256:abcdef1234567890abcdef00", "abcdef123456"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shortDigest(tc.in); got != tc.want { + t.Fatalf("shortDigest(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestListShowsPlatformVariant pins that a variant-qualified platform is not +// truncated to os/arch in list output. +func TestListShowsPlatformVariant(t *testing.T) { + s := openTestStore(t) + img, err := mutate.ConfigFile(buildImage(t, []string{"/hello"}), &v1.ConfigFile{ + Architecture: "arm64", + OS: "linux", + Variant: "v8", + }) + if err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:variant", img); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := list(&buf, s, false); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "linux/arm64/v8") { + t.Fatalf("list output %q missing linux/arm64/v8", buf.String()) + } +} + +// --- plain-rootfs run lock --------------------------------------------------- + +func TestRootfsCacheBusy(t *testing.T) { + dir := filepath.Join(t.TempDir(), "cache") + if rootfsCacheBusy(dir) { + t.Fatal("rootfsCacheBusy(no lock file) = true, want false") + } + hold, err := acquireRootfsRunLock(dir) + if err != nil { + t.Fatal(err) + } + if !rootfsCacheBusy(dir) { + t.Fatal("rootfsCacheBusy(live run) = false, want true") + } + if err := hold.Close(); err != nil { + t.Fatal(err) + } + if rootfsCacheBusy(dir) { + t.Fatal("rootfsCacheBusy after release = true, want false") + } +} + +// TestPruneCacheHonorsRootfsRunLock pins the busy semantics of the plain +// cache sweep: a digest cache whose run lock is held by a live run survives +// prune --cache --all and is never advertised by a dry run; an idle cache +// (including one with a stale lock file left by a dead run) is reclaimed, +// lock file included. +func TestPruneCacheHonorsRootfsRunLock(t *testing.T) { + cases := []struct { + name string + holdLock bool + staleLock bool + dryRun bool + wantGone bool + wantInRep bool + }{ + {name: "live run skipped", holdLock: true}, + {name: "live run hidden from dry-run", holdLock: true, dryRun: true}, + {name: "idle reclaimed", wantGone: true, wantInRep: true}, + {name: "stale lock file reclaimed", staleLock: true, wantGone: true, wantInRep: true}, + {name: "idle dry-run reported only", dryRun: true, wantInRep: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + cache := rootfsForImage(t, s, img) + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + if tc.holdLock { + hold, err := acquireRootfsRunLock(cache) + if err != nil { + t.Fatal(err) + } + defer hold.Close() + } + if tc.staleLock { + if err := os.WriteFile(rootfsRunLockPath(cache), nil, 0o644); err != nil { + t.Fatal(err) + } + } + + rep, err := s.pruneCaches(pruneOpts{cache: true, all: true, dryRun: tc.dryRun}) + if err != nil { + t.Fatalf("pruneCaches: %v", err) + } + + inRep := slices.Contains(rep.CacheDirs, cache) + if inRep != tc.wantInRep { + t.Errorf("reported = %v, want %v (dirs %v)", inRep, tc.wantInRep, rep.CacheDirs) + } + _, statErr := os.Stat(cache) + gone := os.IsNotExist(statErr) + wantGone := tc.wantGone && !tc.dryRun + if gone != wantGone { + t.Errorf("cache gone = %v, want %v (stat err %v)", gone, wantGone, statErr) + } + if wantGone { + if _, err := os.Lstat(rootfsRunLockPath(cache)); !os.IsNotExist(err) { + t.Errorf("lock file after reclaim: %v, want gone", err) + } + } + }) + } +} + +// TestPruneCacheHonorsLockForStagingDir pins that the sweep guards a +// staging dir (.tmp-, unpackImage's pre-rename workspace) by +// the DIGEST's run lock, which the unpacker holds for the whole unpack: a +// mid-flight unpack survives prune --cache --all and never appears in a dry +// run, while a crashed unpack's leftover (digest lock free) is reclaimed. +func TestPruneCacheHonorsLockForStagingDir(t *testing.T) { + cases := []struct { + name string + holdLock bool + dryRun bool + wantGone bool + wantInRep bool + }{ + {name: "live unpack skipped", holdLock: true}, + {name: "live unpack hidden from dry-run", holdLock: true, dryRun: true}, + {name: "crashed leftover reclaimed", wantGone: true, wantInRep: true}, + {name: "crashed leftover dry-run reported only", dryRun: true, wantInRep: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + cache := rootfsForImage(t, s, img) + staging := cache + rootfsStagingSuffix + "123456" + if err := os.MkdirAll(staging, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(staging, "f"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if tc.holdLock { + // The digest's lock, exactly what a live unpacker holds; a + // lock named after the staging dir itself never exists. + hold, err := acquireRootfsRunLock(cache) + if err != nil { + t.Fatal(err) + } + defer hold.Close() + } + + rep, err := s.pruneCaches(pruneOpts{cache: true, all: true, dryRun: tc.dryRun}) + if err != nil { + t.Fatalf("pruneCaches: %v", err) + } + + inRep := slices.Contains(rep.CacheDirs, staging) + if inRep != tc.wantInRep { + t.Errorf("reported = %v, want %v (dirs %v)", inRep, tc.wantInRep, rep.CacheDirs) + } + _, statErr := os.Stat(staging) + gone := os.IsNotExist(statErr) + if gone != tc.wantGone { + t.Errorf("staging gone = %v, want %v (stat err %v)", gone, tc.wantGone, statErr) + } + }) + } +} + +// TestRemoveRootfsCacheMissingDirRespectsLock pins the lock-first rule for +// a cache dir that is already gone: a staging dir vanishes exactly when its +// unpack renames it into place, and that holder still owns the digest lock, +// so removal must refuse rather than unlink the lock out from under it. +// With no holder the stale lock file is swept, and a path with no store +// structure at all is a plain no-op. +func TestRemoveRootfsCacheMissingDirRespectsLock(t *testing.T) { + cache := filepath.Join(t.TempDir(), "sha256", strings.Repeat("ab", 32)) + hold, err := acquireRootfsRunLock(cache) + if err != nil { + t.Fatal(err) + } + staging := cache + rootfsStagingSuffix + "123456" + + if err := removeRootfsCache(staging); !errors.Is(err, errCacheBusy) { + t.Fatalf("removeRootfsCache(vanished staging, lock held) = %v, want errCacheBusy", err) + } + if _, err := os.Lstat(rootfsRunLockPath(cache)); err != nil { + t.Errorf("digest lock file unlinked by refused removal: %v", err) + } + + if err := hold.Close(); err != nil { + t.Fatal(err) + } + if err := removeRootfsCache(staging); err != nil { + t.Fatalf("removeRootfsCache(vanished staging, idle) = %v", err) + } + if _, err := os.Lstat(rootfsRunLockPath(cache)); !os.IsNotExist(err) { + t.Errorf("stale digest lock survived idle removal: %v", err) + } + + if err := removeRootfsCache(filepath.Join(t.TempDir(), "nope", "sha256", "feed")); err != nil { + t.Fatalf("removeRootfsCache(no store structure) = %v, want nil", err) + } +} + +// TestRmiRefusesLiveRootfsRun pins rmi's fail-closed rule for the plain +// cache: even --force refuses while a live run holds the per-digest lock, +// and the pin survives the refusal; once the run exits the same rmi +// succeeds and reclaims cache and lock file. +func TestRmiRefusesLiveRootfsRun(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + if _, err := s.addImage("local:a", img); err != nil { + t.Fatal(err) + } + cache := rootfsForImage(t, s, img) + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + hold, err := acquireRootfsRunLock(cache) + if err != nil { + t.Fatal(err) + } + + if _, err := s.rmi("local:a", true); err == nil || + !strings.Contains(err.Error(), "in use by a live run") { + t.Fatalf("rmi under live run err = %v, want live-run refusal", err) + } + if _, err := s.digestFor("local:a"); err != nil { + t.Errorf("pin lost after refused rmi: %v", err) + } + if _, err := os.Stat(cache); err != nil { + t.Errorf("cache damaged after refused rmi: %v", err) + } + + if err := hold.Close(); err != nil { + t.Fatal(err) + } + if _, err := s.rmi("local:a", true); err != nil { + t.Fatalf("rmi after run exit: %v", err) + } + if _, err := os.Stat(cache); !os.IsNotExist(err) { + t.Errorf("cache after rmi: %v, want gone", err) + } + if _, err := os.Lstat(rootfsRunLockPath(cache)); !os.IsNotExist(err) { + t.Errorf("lock file after rmi: %v, want gone", err) + } +} diff --git a/cmd/elfuse-oci/list.go b/cmd/elfuse-oci/list.go new file mode 100644 index 00000000..08184a1d --- /dev/null +++ b/cmd/elfuse-oci/list.go @@ -0,0 +1,143 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/google/go-containerregistry/pkg/v1" +) + +// listEntry is one row of `elfuse-oci list --json`. +type listEntry struct { + Ref string `json:"ref"` + Digest string `json:"digest"` + Platform string `json:"platform"` + Created string `json:"created,omitempty"` + Size int64 `json:"size"` + Layers int `json:"layers"` +} + +// list prints every ref pinned in the store with its manifest digest, platform, +// creation time, total compressed layer size, and layer count. With asJSON it +// emits a JSON array of listEntry; otherwise a human-readable table sorted by +// ref. The size is the sum of the layers' compressed descriptor sizes +// (layer.Size()), matching `docker images` and the blob bytes rmi or a plain +// prune would reclaim, not the uncompressed size, which would require +// streaming every layer and defeat a fast listing. (prune --cache reports a +// different pool entirely: the on-disk allocation of the unpacked caches.) +func list(w io.Writer, s *store, asJSON bool) error { + // Snapshot pins and manifests under the store lock: a concurrent rmi + // deletes both under the same lock, and reading unlocked could observe a + // pin whose descriptor or blobs are already gone and fail mid-listing. + unlock, err := s.lock() + if err != nil { + return err + } + defer unlock() + + pins, err := s.loadPins() + if err != nil { + return err + } + refs := make([]string, 0, len(pins)) + for ref := range pins { + refs = append(refs, ref) + } + sort.Strings(refs) + + entries := make([]listEntry, 0, len(refs)) + for _, ref := range refs { + e := listEntry{Ref: ref, Digest: pins[ref]} + h, err := v1.NewHash(pins[ref]) + if err != nil { + return fmt.Errorf("list: %s: digest %q: %w", ref, pins[ref], err) + } + img, err := s.path.Image(h) + if err != nil { + return fmt.Errorf("list: %s: image: %w", ref, err) + } + cfg, err := img.ConfigFile() + if err != nil { + return fmt.Errorf("list: %s: config: %w", ref, err) + } + e.Platform = Platform{OS: cfg.OS, Arch: cfg.Architecture, Variant: cfg.Variant}.String() + if !cfg.Created.IsZero() { + e.Created = cfg.Created.UTC().Format("2006-01-02T15:04:05Z") + } + layers, err := img.Layers() + if err != nil { + return fmt.Errorf("list: %s: layers: %w", ref, err) + } + e.Layers = len(layers) + for i, l := range layers { + sz, err := l.Size() + if err != nil { + return fmt.Errorf("list: %s: layer %d size: %w", ref, i, err) + } + e.Size += sz + } + entries = append(entries, e) + } + + if asJSON { + b, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + fmt.Fprintf(w, "%s\n", b) + return nil + } + if len(entries) == 0 { + return nil + } + fmt.Fprintf(w, "%-40s %-12s %-14s %12s %s\n", "REF", "DIGEST", "PLATFORM", "SIZE", "LAYERS") + for _, e := range entries { + fmt.Fprintf(w, "%-40s %s %-14s %12d %d\n", e.Ref, shortDigest(e.Digest), e.Platform, e.Size, e.Layers) + } + return nil +} + +// shortDigest returns the first 12 hex characters of a "sha256:..." digest. +func shortDigest(d string) string { + if i := strings.Index(d, ":"); i >= 0 { + d = d[i+1:] + } + if len(d) > 12 { + return d[:12] + } + return d +} + +// cmdList implements `elfuse-oci list [--store] [--json]` (alias: images). +func cmdList(args []string) error { + cf, asJSON, err := parseListArgs(args) + if err != nil { + return err + } + s, err := cf.openResolvedStore() + if err != nil { + return err + } + return list(os.Stdout, s, asJSON) +} + +func parseListArgs(args []string) (commonFlags, bool, error) { + var cf commonFlags + var asJSON bool + fs := newCommandFlagSet("list", &cf) + fs.BoolVar(&asJSON, "json", false, "emit a JSON array of {ref,digest,platform,created,size,layers}") + if err := fs.Parse(args); err != nil { + return cf, false, err + } + if err := noArgs("list", fs.Args()); err != nil { + return cf, false, err + } + return cf, asJSON, nil +} diff --git a/cmd/elfuse-oci/main.go b/cmd/elfuse-oci/main.go index 15407a27..4bf4bf84 100644 --- a/cmd/elfuse-oci/main.go +++ b/cmd/elfuse-oci/main.go @@ -18,10 +18,14 @@ // elfuse-oci run [--store DIR] [--entrypoint E] [--env K=V]... // [--user UID[:GID]] [--workdir DIR] [--platform ...] // [args...] +// elfuse-oci list [--store DIR] [--json] (alias: images) +// elfuse-oci rmi [--store DIR] [--force] +// elfuse-oci prune [--store DIR] [--cache] [--all] [--dry-run] // // is an OCI image reference (docker.io/library/alpine:3, ghcr.io/..., -// localhost:5000/foo:tag, or name@sha256:...). The default store is -// $ELFUSE_OCI_STORE or ~/.local/share/elfuse/oci. +// localhost:5000/foo:tag, or name@sha256:...). `rmi` also accepts a unique +// sha256 digest prefix from `list`. The default store is $ELFUSE_OCI_STORE or +// ~/.local/share/elfuse/oci. package main import ( @@ -46,6 +50,9 @@ commands: unpack Unpack a stored image's layers into a rootfs directory inspect Print a stored image's manifest + config run Pull + unpack + exec the image's entrypoint under elfuse + list List refs pinned in the store with digest/platform/size (alias: images) + rmi Remove a ref or unique digest and garbage-collect unreachable blobs + prune Garbage-collect unreachable blobs; --cache also drops rootfs/sparsebundle caches help Show this help version Print the elfuse-oci version @@ -72,6 +79,19 @@ run flags: clone (mutations persist; macOS only) --keep Keep the per-run COW clone and mount for inspection (macOS only) + +list flags: + --json Emit a JSON array of {ref,digest,platform,created,size,layers} + +rmi flags: + --force Also discard run --keep retained output held in the cache; + without it rmi refuses. A cold cache is always reclaimed + with its image; a cache a live run uses is never removed + +prune flags: + --cache Also drop elfuse unpacked caches (rootfs/ and, on macOS, cs/) + --all With --cache, drop caches even for still-pulled refs + --dry-run Report what would be reclaimed without deleting `) } @@ -108,6 +128,12 @@ func dispatch(cmd string, rest []string) error { return cmdInspect(rest) case "run": return cmdRun(rest) + case "list", "images": + return cmdList(rest) + case "rmi": + return cmdRmi(rest) + case "prune": + return cmdPrune(rest) default: usage() return fmt.Errorf("unknown command: %s", cmd) diff --git a/cmd/elfuse-oci/main_command_test.go b/cmd/elfuse-oci/main_command_test.go index 9a8636da..7a761c07 100644 --- a/cmd/elfuse-oci/main_command_test.go +++ b/cmd/elfuse-oci/main_command_test.go @@ -5,8 +5,12 @@ package main import ( "os/exec" + "path/filepath" "strings" "testing" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/v1" ) func TestRunDispatchHelpVersionAndErrors(t *testing.T) { @@ -82,3 +86,68 @@ func TestMainSubprocessExitBehavior(t *testing.T) { t.Fatalf("main no-arg stderr = %q, want formatted error", stderr) } } + +func TestRunDispatchesAllSubcommands(t *testing.T) { + root := t.TempDir() + img := tinyImage(t) + withFakeCranePull(t, func(ref string, opts ...crane.Option) (v1.Image, error) { + if ref != "local:tiny" { + t.Fatalf("pull ref = %q, want local:tiny", ref) + } + return img, nil + }) + + stdout, stderr, err := captureOutput(t, func() error { + return run([]string{"pull", "--store", root, "local:tiny"}) + }) + if err != nil || !strings.Contains(stderr, "Pulled local:tiny") { + t.Fatalf("run pull stderr=%q err=%v, want pull summary", stderr, err) + } + + for _, cmd := range []string{"list", "images"} { + stdout, _, err = captureOutput(t, func() error { + return run([]string{cmd, "--store", root}) + }) + if err != nil || !strings.Contains(stdout, "local:tiny") { + t.Fatalf("run %s stdout=%q err=%v, want list row", cmd, stdout, err) + } + } + + stdout, _, err = captureOutput(t, func() error { + return run([]string{"inspect", "--store", root, "local:tiny"}) + }) + if err != nil || !strings.Contains(stdout, "Ref: local:tiny") { + t.Fatalf("run inspect stdout=%q err=%v, want inspect output", stdout, err) + } + + unpackRoot := filepath.Join(t.TempDir(), "unpack-rootfs") + _, stderr, err = captureOutput(t, func() error { + return run([]string{"unpack", "--store", root, "--rootfs", unpackRoot, "local:tiny"}) + }) + if err != nil || !strings.Contains(stderr, "Unpacked local:tiny") { + t.Fatalf("run unpack stderr=%q err=%v, want unpack summary", stderr, err) + } + + withFakeExecElfuse(t, func(rootfs string, spec *runSpec, _ *flockFile) error { return nil }) + runRoot := filepath.Join(t.TempDir(), "run-rootfs") + _, _, err = captureOutput(t, func() error { + return run([]string{"run", "--store", root, "--plain-rootfs", "--rootfs", runRoot, "local:tiny"}) + }) + if err != nil { + t.Fatalf("run run --plain-rootfs: %v", err) + } + + _, stderr, err = captureOutput(t, func() error { + return run([]string{"prune", "--store", root, "--dry-run"}) + }) + if err != nil || !strings.Contains(stderr, "Would reclaim") { + t.Fatalf("run prune stderr=%q err=%v, want dry-run summary", stderr, err) + } + + _, stderr, err = captureOutput(t, func() error { + return run([]string{"rmi", "--store", root, "local:tiny"}) + }) + if err != nil || !strings.Contains(stderr, "Removed local:tiny") { + t.Fatalf("run rmi stderr=%q err=%v, want rmi summary", stderr, err) + } +} diff --git a/cmd/elfuse-oci/prune.go b/cmd/elfuse-oci/prune.go new file mode 100644 index 00000000..fe369608 --- /dev/null +++ b/cmd/elfuse-oci/prune.go @@ -0,0 +1,223 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "syscall" +) + +// pruneOpts controls a prune pass. +type pruneOpts struct { + cache bool // also drop elfuse rootfs/sparsebundle caches + all bool // with cache: drop caches even for still-pulled refs + dryRun bool // report only; delete nothing +} + +// pruneReport summarizes a prune pass: blobs reclaimed, cache dirs dropped, +// and the approximate bytes freed. The total mixes two accountings: blob +// bytes are logical file sizes, cache-dir bytes are on-disk allocation +// (st_blocks, the honest figure for sparse files and APFS clones), so it is +// an estimate, not one uniform metric. +type pruneReport struct { + Blobs []string + CacheDirs []string + Bytes int64 +} + +// cmdPrune implements `elfuse-oci prune [--store] [--cache] [--all] [--dry-run]`. +// +// Without --cache, prune runs a reachability GC over blobs/sha256/ and reclaims +// any blob not reachable from an index.json manifest descriptor (retag or +// partial-pull orphans). With --cache it additionally drops elfuse's unpacked +// caches: the plain rootfs// directories and, on darwin, the +// case-sensitive sparsebundle bundles (cs//). By default only +// digest-keyed caches no longer reachable from refs.json are dropped, plus any +// legacy ref-named caches; --all drops every cache, including for still-pulled +// refs. --dry-run reports what would be removed without deleting. --all +// requires --cache (it has no meaning for the blob GC, which is already +// unconditional). +func cmdPrune(args []string) error { + cf, opts, err := parsePruneArgs(args) + if err != nil { + return err + } + s, err := cf.openResolvedStore() + if err != nil { + return err + } + + // The whole sweep runs under the store lock: the GC's reachability scan + // must not race a concurrent pull, whose fresh blobs land before the + // index descriptor that makes them reachable and would otherwise be + // reclaimed in the window between the two writes. rmi already runs its + // own gc under this lock. Reporting below happens after the lock drops. + var rep pruneReport + err = s.withLock(func() error { + gcr, err := s.gc(opts.dryRun) + if err != nil { + return err + } + rep.Blobs = gcr.Blobs + rep.Bytes += gcr.Bytes + + if opts.cache { + cr, err := s.pruneCaches(opts) + if err != nil { + return err + } + rep.CacheDirs = cr.CacheDirs + rep.Bytes += cr.Bytes + } + return nil + }) + if err != nil { + return err + } + + verb := "Reclaimed" + if opts.dryRun { + verb = "Would reclaim" + } + fmt.Fprintf(os.Stderr, "%s: %d blob(s), %d cache dir(s), ~%d bytes\n", + verb, len(rep.Blobs), len(rep.CacheDirs), rep.Bytes) + for _, b := range rep.Blobs { + fmt.Fprintf(os.Stderr, " blob %s\n", b) + } + for _, d := range rep.CacheDirs { + fmt.Fprintf(os.Stderr, " cache %s\n", d) + } + return nil +} + +func parsePruneArgs(args []string) (commonFlags, pruneOpts, error) { + var cf commonFlags + var opts pruneOpts + fs := newCommandFlagSet("prune", &cf) + fs.BoolVar(&opts.cache, "cache", false, "also drop unpacked caches (rootfs/ and, on macOS, cs/ sparsebundles)") + fs.BoolVar(&opts.all, "all", false, "with --cache, drop caches even for still-pulled refs") + fs.BoolVar(&opts.dryRun, "dry-run", false, "report what would be reclaimed without deleting") + if err := fs.Parse(args); err != nil { + return cf, opts, err + } + if err := noArgs("prune", fs.Args()); err != nil { + return cf, opts, err + } + if opts.all && !opts.cache { + return cf, opts, fmt.Errorf("prune: --all requires --cache") + } + return cf, opts, nil +} + +// pruneRootfsCaches drops plain rootfs// cache directories. Without +// opts.all, only dirs whose digest key is not live are dropped; with opts.all, +// every digest cache is dropped. Pre-digest rootfs/ caches are +// always treated as orphaned legacy caches because they are no longer used by +// run/unpack. Shared across platforms; the darwin-only sparsebundle sweep lives +// in cache_darwin.go. +func pruneRootfsCaches(s *store, live map[string]bool, opts pruneOpts) (pruneReport, error) { + var rep pruneReport + base := filepath.Join(s.root, rootfsCacheDirName) + entries, err := os.ReadDir(base) + if err != nil { + if os.IsNotExist(err) { + return rep, nil + } + return rep, err + } + for _, e := range entries { + if !e.IsDir() { + continue + } + top := filepath.Join(base, e.Name()) + if e.Name() == "sha256" { + children, err := os.ReadDir(top) + if err != nil { + return rep, err + } + for _, child := range children { + if !child.IsDir() { + continue + } + key := filepath.Join("sha256", child.Name()) + if !opts.all && live[key] { + continue + } + dir := filepath.Join(top, child.Name()) + // A cache whose per-digest run lock is held belongs to a live + // --plain-rootfs guest: skip it, and never advertise it in a + // dry run, the way the bundle sweep skips a busy sparsebundle. + // This also covers the non---all case where a re-pull moved + // the pin off a digest a guest is still running from. + if err := sweepPlainDir(&rep, dir, opts.dryRun); err != nil { + return rep, err + } + } + continue + } + + // Legacy ref-named rootfs caches are no longer live under the + // digest-keyed scheme; prune --cache reclaims them as orphan caches. + // No run ever locks a legacy path, but the same locked removal keeps + // one code path. + if err := sweepPlainDir(&rep, top, opts.dryRun); err != nil { + return rep, err + } + } + return rep, nil +} + +// sweepPlainDir reclaims (or, in a dry run, reports) one plain rootfs cache +// dir, skipping it when a live --plain-rootfs guest holds its run lock, the +// way the bundle sweep skips a busy sparsebundle. +func sweepPlainDir(rep *pruneReport, dir string, dryRun bool) error { + if dryRun { + if rootfsCacheBusy(dir) { + return nil + } + rep.Bytes += dirSize(dir) + rep.CacheDirs = append(rep.CacheDirs, dir) + return nil + } + size := dirSize(dir) + if err := removeRootfsCache(dir); err != nil { + if errors.Is(err, errCacheBusy) { + return nil + } + return err + } + rep.Bytes += size + rep.CacheDirs = append(rep.CacheDirs, dir) + return nil +} + +// dirSize reports the on-disk allocation (stat block count, not logical file +// size) of a tree, so a sparse APFS sparsebundle image is measured by the bytes +// it actually occupies rather than its 16g virtual ceiling. Best-effort: walk +// errors are ignored so a busy/evaporating entry does not abort the sweep. +func dirSize(path string) int64 { + var total int64 + _ = filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if info, err := d.Info(); err == nil { + total += diskUsage(info) + } + return nil + }) + return total +} + +// diskUsage returns the bytes actually allocated to a file (Blocks * 512) when +// the platform exposes stat blocks, falling back to logical size otherwise. +func diskUsage(fi os.FileInfo) int64 { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + return int64(st.Blocks) * 512 + } + return fi.Size() +} diff --git a/cmd/elfuse-oci/rmi.go b/cmd/elfuse-oci/rmi.go new file mode 100644 index 00000000..171ef6fe --- /dev/null +++ b/cmd/elfuse-oci/rmi.go @@ -0,0 +1,59 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" +) + +// cmdRmi implements `elfuse-oci rmi [--store] [--force] `. +// +// rmi drops the selected ref's pin. The target may be an exact ref or a unique +// sha256 digest prefix from `list`. If no remaining ref pins the same manifest +// digest, it removes that descriptor from index.json, garbage-collects the +// now-unreachable blobs, and reclaims the image's unpacked cache (rootfs/ or cs/ +// sparsebundle); the cache is derived state that goes with the image. A blob, +// descriptor, or cache still reachable through another pinned ref is kept. rmi +// refuses when a live run still uses the cache (never overridable) or when the +// cache holds run --keep retained output (then --force discards it). An absent +// ref/digest is an error. +func cmdRmi(args []string) error { + cf, force, ref, err := parseRmiArgs(args) + if err != nil { + return err + } + s, err := cf.openResolvedStore() + if err != nil { + return err + } + rep, err := s.rmi(ref, force) + if err != nil { + return err + } + removed := ref + if rep.Ref != "" { + removed = rep.Ref + } + fmt.Fprintf(os.Stderr, "Removed %s: %d blob(s), %d bytes\n", removed, len(rep.Blobs), rep.Bytes) + for _, b := range rep.Blobs { + fmt.Fprintf(os.Stderr, " blob %s\n", b) + } + if rep.CacheDropped { + fmt.Fprintf(os.Stderr, " dropped unpacked cache\n") + } + return nil +} + +func parseRmiArgs(args []string) (commonFlags, bool, string, error) { + var cf commonFlags + var force bool + fs := newCommandFlagSet("rmi", &cf) + fs.BoolVar(&force, "force", false, "discard run --keep retained output when removing an image that has it") + if err := fs.Parse(args); err != nil { + return cf, false, "", err + } + ref, err := oneArg("rmi", fs.Args(), "") + return cf, force, ref, err +} diff --git a/cmd/elfuse-oci/rootfslock.go b/cmd/elfuse-oci/rootfslock.go new file mode 100644 index 00000000..ae34ab21 --- /dev/null +++ b/cmd/elfuse-oci/rootfslock.go @@ -0,0 +1,169 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +// Per-digest plain-rootfs cache locks. +// +// The plain digest-keyed rootfs cache (/rootfs///) is the +// same kind of shared mutable state as a sparsebundle bundle: a +// --plain-rootfs run executes a guest out of it while prune --cache and rmi +// want to RemoveAll it. Liveness follows the bundlelock.go discipline (a +// held flock proves a live holder regardless of pids) with one lock +// instead of two: there is no mount lifecycle to serialize (no attach.lock +// analog), and concurrent cold unpacks already reconcile through +// unpackImage's stage-and-rename. +// +// The lock is a SIBLING file (.lock next to the cache dir), not a file +// inside it: the cache dir is the guest's /, so a lock inside would appear +// at the guest's / in every run, and the dir's very existence is the +// "fully unpacked" signal that unpackImage publishes by atomic rename. +// Prune's sweep enumerations skip non-directories, so the lock file is +// invisible to them. +// +// - A run (and a store-cache unpack) holds the lock SHARED from before the +// cache-existence probe until the guest exits. The plain run path execs +// elfuse in place, so PreserveAcrossExec threads the descriptor through +// the exec: the kernel releases the flock exactly when the elfuse +// process exits, SIGKILL included, the same no-leaked-liveness property +// run.lock gives the sparsebundle path. +// - prune --cache takes it EXCLUSIVE non-blocking and skips a busy cache; +// rmi refuses a busy cache outright. The remover deletes the lock file +// while still holding the lock; acquireFlock's stat-after-lock guard +// already handles a racing acquirer. +// +// Ordering stays acyclic: runs take the rootfs lock while holding no store +// lock, and prune/rmi (which do hold the store lock) only ever probe the +// rootfs lock non-blocking. + +func rootfsRunLockPath(dir string) string { return dir + ".lock" } + +// rootfsCacheLockPath returns the lock file guarding a cache dir in the +// prune sweep. A published cache is guarded by its sibling .lock. +// A staging dir .tmp- (unpackImage's pre-rename workspace, a +// temp sibling in the same parent) is guarded by the SAME .lock: the +// unpacker holds that lock from before the cache-existence probe until the +// guest exits, so a probe on a lock named after the staging dir itself +// proves nothing and would let the sweep reclaim a tree a live unpack is +// still writing. A staging dir whose digest lock is free is a crashed +// unpack's leftover and remains reclaimable. +func rootfsCacheLockPath(dir string) string { + if hex, _, isStaging := strings.Cut(filepath.Base(dir), rootfsStagingSuffix); isStaging { + return rootfsRunLockPath(filepath.Join(filepath.Dir(dir), hex)) + } + return rootfsRunLockPath(dir) +} + +// acquireRootfsRunLock takes dir's run lock shared, blocking: if a prune is +// mid-removal the run waits it out and then re-unpacks the reclaimed cache. +// The parent directory is created first so a cold store can take the lock +// before its first unpack. +func acquireRootfsRunLock(dir string) (*flockFile, error) { + if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil { + return nil, err + } + return acquireFlock(rootfsRunLockPath(dir), syscall.LOCK_SH) +} + +// rootfsCacheBusy reports whether a live run holds dir's run lock. Used by +// prune's dry-run so it never advertises a reap the real pass would refuse. +// A missing lock file means no holder (holders create it), so the probe +// creates no state; any other failure fails closed to busy. +func rootfsCacheBusy(dir string) bool { + f, err := os.OpenFile(rootfsCacheLockPath(dir), os.O_RDWR, 0) + if err != nil { + return !os.IsNotExist(err) + } + defer f.Close() + if err := flockRetryIntr(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return true + } + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + return false +} + +// lockRootfsCacheForRemoval takes dir's run lock exclusively, non-blocking. +// busy=true means a live run holds it and the cache must be left alone. On +// success the caller removes the cache dir and the lock file, then unlocks, +// the same unlock-after-removal rule sweepCSBundle follows, so a blocked +// acquirer never sees a half-removed tree. +func lockRootfsCacheForRemoval(dir string) (unlock func(), busy bool, err error) { + l, err := acquireFlock(rootfsCacheLockPath(dir), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + if errors.Is(err, errCacheBusy) { + return func() {}, true, nil + } + return func() {}, false, err + } + return func() { l.Close() }, false, nil +} + +// removeRootfsCache removes dir and its guarding lock file under an +// exclusive run lock, returning errCacheBusy (wrapped) when a live holder +// pins the cache: a running guest for a published dir, a mid-flight unpack +// for a staging dir (both hold the digest's lock, see rootfsCacheLockPath). +// The lock comes first even when dir is already gone: a vanished staging +// dir usually means its unpack just renamed it into place, and the holder +// still owns the digest lock, which an unlocked cleanup here would unlink +// out from under it. A missing lock parent means nothing was ever unpacked +// or locked, so an rmi of a run-less image stays a no-op that conjures no +// store structure. +func removeRootfsCache(dir string) error { + unlock, busy, err := lockRootfsCacheForRemoval(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer unlock() + if busy { + return fmt.Errorf("%s: %w", dir, errCacheBusy) + } + if err := os.RemoveAll(dir); err != nil { + return err + } + if err := os.Remove(rootfsCacheLockPath(dir)); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// removeRootfsCacheForDigest is the non-darwin removeRefCaches' plain-cache +// half: delete digest's unpacked plain rootfs, refusing while a live run uses +// it. The darwin removeRefCaches inlines the same removal because it deletes +// under a lock already held from its two-cache preflight. +func removeRootfsCacheForDigest(s *store, digest string) error { + rootfs, err := defaultRootfsForDigest(s.root, digest) + if err != nil { + return err + } + if err := removeRootfsCache(rootfs); err != nil { + if errors.Is(err, errCacheBusy) { + return fmt.Errorf( + "cache for %s is in use by a live run; stop it before removing the image", + digest) + } + return err + } + return nil +} + +// PreserveAcrossExec clears FD_CLOEXEC on the lock's descriptor so the flock +// rides through syscall.Exec into the replacement process and is released by +// the kernel exactly when that process exits, SIGKILL included. +func (l *flockFile) PreserveAcrossExec() error { + _, err := unix.FcntlInt(l.f.Fd(), unix.F_SETFD, 0) + return err +} diff --git a/cmd/elfuse-oci/run.go b/cmd/elfuse-oci/run.go index 50aaeb3b..9adf0201 100644 --- a/cmd/elfuse-oci/run.go +++ b/cmd/elfuse-oci/run.go @@ -9,23 +9,29 @@ import ( "os/exec" "os/signal" "path/filepath" + "runtime" "syscall" ) var execElfuseForRun = execElfuse -// elfuseBin locates the elfuse binary to exec for `run`. Precedence: +// resolveElfuseBin locates the elfuse binary to exec for `run` and verifies +// it exists. Precedence: // - $ELFUSE_BIN (an override hook for tests and wrapper scripts); // - the sibling of this executable (build/elfuse-oci -> build/elfuse). -func elfuseBin() (string, error) { - if p := os.Getenv("ELFUSE_BIN"); p != "" { - return p, nil +func resolveElfuseBin() (string, error) { + bin := os.Getenv("ELFUSE_BIN") + if bin == "" { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locate elfuse: %w", err) + } + bin = filepath.Join(filepath.Dir(exe), "elfuse") } - exe, err := os.Executable() - if err != nil { - return "", fmt.Errorf("locate elfuse: %w", err) + if _, err := os.Stat(bin); err != nil { + return "", fmt.Errorf("elfuse binary not found at %s (set $ELFUSE_BIN): %w", bin, err) } - return filepath.Join(filepath.Dir(exe), "elfuse"), nil + return bin, nil } // elfuseArgv builds the argv for `elfuse --sysroot --user U:G @@ -59,18 +65,28 @@ func elfuseArgv(rootfs string, spec *runSpec) []string { // becomes elfuse in place, so the invoking shell reaps the same pid and // terminal signals such as Ctrl-C go straight to elfuse rather than through a // Go middleman. -func execElfuse(rootfs string, spec *runSpec) error { - bin, err := elfuseBin() +// +// A non-nil lock is the store cache's per-digest run lock. Its descriptor is +// made exec-survivable so the kernel holds the flock for exactly the elfuse +// process's lifetime (SIGKILL included) and releases it at guest exit; the +// one inherited fd is the price of not leaving a Go wrapper alive to babysit +// the lock. +func execElfuse(rootfs string, spec *runSpec, lock *flockFile) error { + bin, err := resolveElfuseBin() if err != nil { return err } - if _, err := os.Stat(bin); err != nil { - return fmt.Errorf("elfuse binary not found at %s (set $ELFUSE_BIN): %w", bin, err) - } - if err := syscall.Exec(bin, elfuseArgv(rootfs, spec), os.Environ()); err != nil { - return fmt.Errorf("exec %s: %w", bin, err) + if lock != nil { + if err := lock.PreserveAcrossExec(); err != nil { + return fmt.Errorf("preserve rootfs lock across exec: %w", err) + } } - return nil // unreachable + err = syscall.Exec(bin, elfuseArgv(rootfs, spec), os.Environ()) + // Unreachable on success. Keep the lock's *os.File live until the exec + // verdict so its finalizer cannot close the fd (dropping the flock) in + // the window before the process image is replaced. + runtime.KeepAlive(lock) + return fmt.Errorf("exec %s: %w", bin, err) } // spawnElfuseWait runs elfuse as a child and waits for it, returning the exit @@ -83,13 +99,10 @@ func execElfuse(rootfs string, spec *runSpec) error { // child so a signal targeted at elfuse-oci alone still propagates, and we // survive to reap and report the child's status rather than dying first. func spawnElfuseWait(rootfs string, spec *runSpec) (int, error) { - bin, err := elfuseBin() + bin, err := resolveElfuseBin() if err != nil { return 0, err } - if _, err := os.Stat(bin); err != nil { - return 0, fmt.Errorf("elfuse binary not found at %s (set $ELFUSE_BIN): %w", bin, err) - } // exec.Command uses `bin` as argv[0], so drop the leading "elfuse" // program-name that elfuseArgv includes for syscall.Exec's sake; otherwise // elfuse would see "elfuse" as its first positional and try to boot a diff --git a/cmd/elfuse-oci/run_test.go b/cmd/elfuse-oci/run_test.go index b8b9a2a7..de9710d9 100644 --- a/cmd/elfuse-oci/run_test.go +++ b/cmd/elfuse-oci/run_test.go @@ -13,7 +13,7 @@ import ( ) // writeElfuseStub writes a #!/bin/sh stub script that stands in for the -// elfuse binary, and points elfuseBin() at it via $ELFUSE_BIN. spawnElfuseWait +// elfuse binary, and points resolveElfuseBin() at it via $ELFUSE_BIN. spawnElfuseWait // exec.Command's whatever $ELFUSE_BIN names, so no real elfuse (and no HVF) is // needed. t.Setenv restores the env on cleanup. func writeElfuseStub(t *testing.T, body string) string { @@ -92,35 +92,31 @@ func TestElfuseArgvShape(t *testing.T) { } } -func TestElfuseBinEnvAndFallback(t *testing.T) { +func TestResolveElfuseBinEnvAndMissing(t *testing.T) { want := filepath.Join(t.TempDir(), "elfuse-custom") + if err := os.WriteFile(want, []byte("#!"), 0o755); err != nil { + t.Fatal(err) + } t.Setenv("ELFUSE_BIN", want) - got, err := elfuseBin() + got, err := resolveElfuseBin() if err != nil { t.Fatal(err) } if got != want { - t.Fatalf("elfuseBin with env = %q, want %q", got, want) + t.Fatalf("resolveElfuseBin with env = %q, want %q", got, want) } - t.Setenv("ELFUSE_BIN", "") - exe, err := os.Executable() - if err != nil { - t.Fatal(err) - } - want = filepath.Join(filepath.Dir(exe), "elfuse") - got, err = elfuseBin() - if err != nil { - t.Fatal(err) - } - if got != want { - t.Fatalf("elfuseBin fallback = %q, want %q", got, want) + // A missing binary must fail up front with the $ELFUSE_BIN hint instead + // of surfacing later as an opaque exec error. + t.Setenv("ELFUSE_BIN", filepath.Join(t.TempDir(), "absent")) + if _, err := resolveElfuseBin(); err == nil || !strings.Contains(err.Error(), "ELFUSE_BIN") { + t.Fatalf("resolveElfuseBin missing binary err = %v, want not-found with hint", err) } } func TestExecElfuseMissingBinary(t *testing.T) { t.Setenv("ELFUSE_BIN", filepath.Join(t.TempDir(), "missing-elfuse")) - err := execElfuse(t.TempDir(), &runSpec{Args: []string{"/bin/true"}, Workdir: "/", UID: 0, GID: 0}) + err := execElfuse(t.TempDir(), &runSpec{Args: []string{"/bin/true"}, Workdir: "/", UID: 0, GID: 0}, nil) if err == nil || !strings.Contains(err.Error(), "elfuse binary not found") { t.Fatalf("execElfuse missing binary err = %v, want not found", err) } diff --git a/cmd/elfuse-oci/sparsebundle.go b/cmd/elfuse-oci/sparsebundle.go index 5069de46..1e67ac61 100644 --- a/cmd/elfuse-oci/sparsebundle.go +++ b/cmd/elfuse-oci/sparsebundle.go @@ -232,16 +232,6 @@ func writeSpotlightMarker(mountPath string) error { return touchFile(filepath.Join(mountPath, ".metadata_never_index")) } -// touchFile creates (or truncates) an empty marker file. The markers carry -// no content; only their existence matters. -func touchFile(path string) error { - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) - if err != nil { - return err - } - return f.Close() -} - // isMountPoint reports whether path is currently a mount point by comparing its // device id against its parent's. func isMountPoint(path string) bool { diff --git a/cmd/elfuse-oci/sparsebundle_test.go b/cmd/elfuse-oci/sparsebundle_test.go index 01b027c8..8d106232 100644 --- a/cmd/elfuse-oci/sparsebundle_test.go +++ b/cmd/elfuse-oci/sparsebundle_test.go @@ -11,27 +11,10 @@ import ( "os" "path/filepath" "strings" + "syscall" "testing" ) -// withDarwinCacheSeams swaps the darwin mount-probe and force-detach seams -// for a test and restores them on cleanup. -func withDarwinCacheSeams(t *testing.T, isMount func(string) bool, detach func(string) error) { - t.Helper() - oldIsMount := isMountPointFn - oldDetach := detachForce - if isMount != nil { - isMountPointFn = isMount - } - if detach != nil { - detachForce = detach - } - t.Cleanup(func() { - isMountPointFn = oldIsMount - detachForce = oldDetach - }) -} - // hdiutil attach -plist output is an array of system entity dictionaries. We // only care about the mount-point. This fixture is a trimmed-down capture of the // real shape (system-entities -> dict -> mount-point key/string). @@ -396,6 +379,138 @@ func TestProvisionCaseSensitiveWithFakeHdiutilFailures(t *testing.T) { } } +// withMountSeam overrides the mount-point probe directly (not via any shared +// seam helper) so these lock-behavior tests are self-contained. +func withMountSeam(t *testing.T, fn func(string) bool) { + t.Helper() + old := isMountPointFn + isMountPointFn = fn + t.Cleanup(func() { isMountPointFn = old }) +} + +// TestProvisionSharesLiveMount pins the F1 fix: when live runs of the digest +// hold run.lock, a new provision must share the already-attached volume, not +// force-detach it out from under the running guests. +func TestProvisionSharesLiveMount(t *testing.T) { + installFakeHdiutil(t) + bundle := t.TempDir() + requested := filepath.Join(t.TempDir(), "mnt") + withMountSeam(t, func(p string) bool { return p == requested }) + oldDetach := detachForce + detachForce = func(p string) error { + t.Errorf("detachForce(%s) called although a live run holds the volume", p) + return nil + } + t.Cleanup(func() { detachForce = oldDetach }) + + holder, err := acquireFlock(runLockPath(bundle), syscall.LOCK_SH) + if err != nil { + t.Fatal(err) + } + defer holder.Close() + + m, err := provisionCaseSensitive(bundle, requested, "32m") + if err != nil { + t.Fatalf("provisionCaseSensitive with live run: %v", err) + } + if m.mountPath != requested || !m.owned { + t.Fatalf("mount = %+v, want shared attach at requested mount", m) + } + + // The new run holds run.lock shared: an exclusive probe must report busy. + if _, err := acquireFlock(runLockPath(bundle), syscall.LOCK_EX|syscall.LOCK_NB); !errors.Is(err, errCacheBusy) { + t.Fatalf("run.lock probe err = %v, want errCacheBusy while run is live", err) + } + + // Close with the other holder still live: last-one-out must NOT detach + // (the detachForce seam above fails the test if it does). + if err := m.Close(); err != nil { + t.Fatalf("Close with surviving run: %v", err) + } +} + +// TestProvisionRejectsSymlinkedMountPath pins the G4 fix: a symlinked mount +// path must be refused before any mount-status probe or force-detach, so a +// tampered cache cannot trick provision into detaching an unrelated volume. +func TestProvisionRejectsSymlinkedMountPath(t *testing.T) { + installFakeHdiutil(t) + t.Setenv("HDIUTIL_MOUNT", t.TempDir()) + bundle := t.TempDir() + requested := filepath.Join(t.TempDir(), "mnt") + if err := os.Symlink(t.TempDir(), requested); err != nil { + t.Fatal(err) + } + // Even if the path reads as a mount point, the symlink guard must win and + // no detach may run. + withMountSeam(t, func(string) bool { return true }) + oldDetach := detachForce + detachForce = func(p string) error { + t.Errorf("detachForce(%s) called on a symlinked mount path", p) + return nil + } + t.Cleanup(func() { detachForce = oldDetach }) + + _, err := provisionCaseSensitive(bundle, requested, "32m") + if err == nil || !strings.Contains(err.Error(), "is a symlink") { + t.Fatalf("provisionCaseSensitive(symlink) err = %v, want 'is a symlink'", err) + } +} + +// TestProvisionDetachesStaleMountAndHoldsRunLock pins the crash-recovery +// path: with no live run holding run.lock, a leftover attached mount is +// provably stale: provision detaches it, re-attaches cleanly, and the +// returned mount holds run.lock shared until Close, whose last-one-out probe +// then detaches. +func TestProvisionDetachesStaleMountAndHoldsRunLock(t *testing.T) { + installFakeHdiutil(t) + bundle := t.TempDir() + requested := filepath.Join(t.TempDir(), "requested-mnt") + actualMount := filepath.Join(t.TempDir(), "actual-mount") + if err := os.MkdirAll(actualMount, 0o755); err != nil { + t.Fatal(err) + } + detachLog := filepath.Join(t.TempDir(), "detach.log") + t.Setenv("HDIUTIL_MOUNT", actualMount) + t.Setenv("HDIUTIL_DETACH_LOG", detachLog) + // The stale leftover: the requested mount point reads as attached. + withMountSeam(t, func(p string) bool { return p == requested }) + + m, err := provisionCaseSensitive(bundle, requested, "32m") + if err != nil { + t.Fatalf("provisionCaseSensitive: %v", err) + } + b, err := os.ReadFile(detachLog) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), requested) { + t.Fatalf("detach log = %q, want stale mount %s detached", b, requested) + } + if m.mountPath != actualMount { + t.Fatalf("mountPath = %q, want re-attached %q", m.mountPath, actualMount) + } + + // Liveness is held from provision until Close. + if _, err := acquireFlock(runLockPath(bundle), syscall.LOCK_EX|syscall.LOCK_NB); !errors.Is(err, errCacheBusy) { + t.Fatalf("run.lock probe err = %v, want errCacheBusy while mount is open", err) + } + if err := m.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + b, err = os.ReadFile(detachLog) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), actualMount) { + t.Fatalf("detach log = %q, want last-one-out detach of %s", b, actualMount) + } + free, err := acquireFlock(runLockPath(bundle), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + t.Fatalf("run.lock probe after Close err = %v, want free", err) + } + free.Close() +} + // TestParseMountpointDecodesXMLEntities pins entity decoding: hdiutil's plist // escapes XML-special characters in the mount path (a store path may carry // "&" or "'"), and the raw escaped form would make every later use of the diff --git a/cmd/elfuse-oci/store.go b/cmd/elfuse-oci/store.go index 657f7561..71edcd86 100644 --- a/cmd/elfuse-oci/store.go +++ b/cmd/elfuse-oci/store.go @@ -8,6 +8,8 @@ import ( "fmt" "os" "path/filepath" + "sort" + "strings" "syscall" "github.com/google/go-containerregistry/pkg/v1" @@ -85,7 +87,47 @@ func writeIfAbsent(path string, data []byte) error { } else if !os.IsNotExist(err) { return err } - return os.WriteFile(path, data, 0o644) + // Durable even on cold-store bootstrap: a crash mid-write must not leave a + // truncated oci-layout or index.json that later opens fail to parse. + return writeFileDurable(path, data, 0o644) +} + +// writeFileDurable writes data to path atomically and durably: a uniquely +// named temp sibling is written, fsynced, and renamed into place, then the +// parent directory is fsynced so the rename itself survives a crash. A crash +// leaves either the prior file or the complete new one, never a truncated +// mix. This is the store's shared durability primitive for refs.json and +// index.json; rmi's crash-ordering rule (index.json committed before +// refs.json) holds only if each write is individually durable, which the +// layout package's plain os.WriteFile is not. +func writeFileDurable(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + // A unique temp name: a fixed name would let two writers clobber each + // other's half-written temp even before the rename race. + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) // no-op once the rename succeeds + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(perm); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmp.Name(), path); err != nil { + return err + } + return fsyncDir(dir) } // refPins maps an image reference to its manifest digest ("sha256:..."). @@ -113,48 +155,85 @@ func (s *store) savePins(p refPins) error { if err != nil { return err } - // Unique temp name: a fixed name would let two writers clobber each - // other's half-written temp even before the rename race. - tmp, err := os.CreateTemp(s.root, ".refs.json.*") + // Durability, not just atomicity: rmi's crash-ordering argument (commit the + // index.json descriptor removal before dropping the pin) only holds if the + // new refs.json cannot revert to an old pin after a crash. + return writeFileDurable(filepath.Join(s.root, "refs.json"), b, 0o644) +} + +// fsyncDir flushes a directory's entries (renames, unlinks) to stable +// storage. +func fsyncDir(dir string) error { + f, err := os.Open(dir) if err != nil { return err } - defer os.Remove(tmp.Name()) // no-op once the rename succeeds - if _, err := tmp.Write(b); err != nil { - tmp.Close() - return err - } - if err := tmp.Chmod(0o644); err != nil { - tmp.Close() + defer f.Close() + return f.Sync() +} + +// fsyncFile flushes an existing file's data to stable storage. Used to make a +// blob durable before the pin that references it is committed. +func fsyncFile(path string) error { + f, err := os.Open(path) + if err != nil { return err } - // Durability, not just atomicity: rmi's crash-ordering argument (drop the - // pin before dropping the descriptor) only holds if the new refs.json - // cannot revert to an old pin after a crash. Sync the temp file before the - // rename and the directory after it, so the rename is durable once this - // returns. - if err := tmp.Sync(); err != nil { - tmp.Close() + defer f.Close() + return f.Sync() +} + +// syncAppendedImage makes img's on-disk state durable after AppendImage but +// before the pin is committed: the config and layer blobs, then index.json, +// then the store directory. Without this the pin (refs.json) is fsynced while +// the blobs and index it references may still sit in the page cache, so a +// crash could leave a durable pin over content that never reached disk, which +// every later resolve of the ref then fails on. The layout package writes +// blobs and index.json with plain os.WriteFile, so the durability is ours to +// add here. +func (s *store) syncAppendedImage(img v1.Image) error { + digests, err := imageBlobDigests(img) + if err != nil { return err } - if err := tmp.Close(); err != nil { - return err + for _, h := range digests { + p := filepath.Join(s.root, "blobs", h.Algorithm, h.Hex) + if err := fsyncFile(p); err != nil { + return err + } } - if err := os.Rename(tmp.Name(), filepath.Join(s.root, "refs.json")); err != nil { + if err := fsyncFile(filepath.Join(s.root, "index.json")); err != nil { return err } return fsyncDir(s.root) } -// fsyncDir flushes a directory's entries (renames, unlinks) to stable -// storage. -func fsyncDir(dir string) error { - f, err := os.Open(dir) +// imageBlobDigests returns the hashes of every blob img introduces: its +// manifest, config, and layers. +func imageBlobDigests(img v1.Image) ([]v1.Hash, error) { + var hs []v1.Hash + mh, err := img.Digest() if err != nil { - return err + return nil, err } - defer f.Close() - return f.Sync() + hs = append(hs, mh) + ch, err := img.ConfigName() + if err != nil { + return nil, err + } + hs = append(hs, ch) + layers, err := img.Layers() + if err != nil { + return nil, err + } + for _, l := range layers { + lh, err := l.Digest() + if err != nil { + return nil, err + } + hs = append(hs, lh) + } + return hs, nil } // lock takes an exclusive advisory flock on /.lock and returns the @@ -219,6 +298,56 @@ func (s *store) digestFor(ref string) (string, error) { return d, nil } +// resolvePinnedTarget resolves an exact pulled ref, or a unique sha256 digest +// prefix such as the 12-character digest printed by `list`. +func resolvePinnedTarget(pins refPins, target string) (string, string, error) { + if d, ok := pins[target]; ok { + return target, d, nil + } + + prefix, ok := digestPrefix(target) + if !ok { + return "", "", fmt.Errorf("store: %q %w (run `elfuse-oci pull %s` first)", target, errNotPulled, target) + } + + var matches []string + matchDigest := "" + for ref, digest := range pins { + h, err := v1.NewHash(digest) + if err != nil { + return "", "", fmt.Errorf("store: pinned digest for %q: %w", ref, err) + } + if h.Algorithm == "sha256" && strings.HasPrefix(h.Hex, prefix) { + matches = append(matches, ref) + matchDigest = digest + } + } + sort.Strings(matches) + + switch len(matches) { + case 0: + return "", "", fmt.Errorf("store: digest %q %w", target, errNotPulled) + case 1: + return matches[0], matchDigest, nil + default: + return "", "", fmt.Errorf("store: digest %q is ambiguous; matches refs: %s", target, strings.Join(matches, ", ")) + } +} + +func digestPrefix(target string) (string, bool) { + if i := strings.IndexByte(target, ':'); i >= 0 { + if target[:i] != "sha256" { + return "", false + } + target = target[i+1:] + } + target = strings.ToLower(target) + if len(target) < 12 || len(target) > 64 || !isLowerHex(target) { + return "", false + } + return target, true +} + // addImage appends img to the layout index if its manifest is not already // present (dedup by digest), and pins ref to that digest. Returns the digest. // The store lock covers the whole check-append-pin sequence: index.json is @@ -244,6 +373,12 @@ func (s *store) addImage(ref string, img v1.Image) (string, error) { if err := s.path.AppendImage(img); err != nil { return fmt.Errorf("store: append image: %w", err) } + // Make the blobs and index.json durable before the pin that will + // point at them, so a crash never strands a fsynced pin over + // content still in the page cache. + if err := s.syncAppendedImage(img); err != nil { + return fmt.Errorf("store: sync appended image: %w", err) + } } return s.pinLocked(ref, d.String()) }) diff --git a/cmd/elfuse-oci/store_test.go b/cmd/elfuse-oci/store_test.go index 4f507ff2..b43deaf4 100644 --- a/cmd/elfuse-oci/store_test.go +++ b/cmd/elfuse-oci/store_test.go @@ -4,10 +4,12 @@ package main import ( + "bytes" "errors" "fmt" "os" "path/filepath" + "slices" "strings" "sync" "syscall" @@ -135,6 +137,80 @@ func TestOpenStoreBootstrapWaitsForStoreLock(t *testing.T) { } } +// TestRmiKeepsPinWhenDescriptorRemovalFails pins the rmi write ordering: +// index.json must be updated before the pin is dropped from refs.json. In the +// reverse order a failure between the writes strands the manifest: the ref +// no longer resolves while the descriptor keeps all blobs live, and prune +// never removes descriptors. With the correct order the pin survives the +// failure and a retried rmi completes. +func TestRmiKeepsPinWhenDescriptorRemovalFails(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("running as root: a read-only store dir cannot induce the write failure") + } + s := openTestStore(t) + if _, err := s.addImage("local:stuck", buildImage(t, []string{"/a"})); err != nil { + t.Fatal(err) + } + // index.json is now written atomically (temp + rename), so a read-only + // index.json no longer blocks the write: a rename replaces the file + // regardless of its mode. Make the store directory read-only instead, so + // the descriptor removal's temp create fails before refs.json is touched. + if err := os.Chmod(s.root, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(s.root, 0o755) }) + + if _, err := s.rmi("local:stuck", false); err == nil { + t.Fatal("rmi succeeded although the store directory is read-only") + } + pins, err := s.loadPins() + if err != nil { + t.Fatal(err) + } + if _, ok := pins["local:stuck"]; !ok { + t.Fatal("pin dropped although descriptor removal failed; image is stranded") + } + + if err := os.Chmod(s.root, 0o755); err != nil { + t.Fatal(err) + } + if _, err := s.rmi("local:stuck", false); err != nil { + t.Fatalf("retried rmi after transient failure: %v", err) + } + pins, err = s.loadPins() + if err != nil { + t.Fatal(err) + } + if _, ok := pins["local:stuck"]; ok { + t.Fatal("pin still present after successful retried rmi") + } +} + +// TestGCReclaimsOrphanKeepsLive exercises store.gc directly (the lifecycle tests +// otherwise only reach it through rmi/prune): an unreferenced blob is reclaimed +// while a still-pinned image's own manifest/config/layers survive. +func TestGCReclaimsOrphanKeepsLive(t *testing.T) { + s := openTestStore(t) + if _, err := s.addImage("local:a", buildImage(t, []string{"/a"})); err != nil { + t.Fatal(err) + } + orphan := writeOrphanBlob(t, s.root, "gc-direct-orphan") + + rep, err := s.gc(false) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(rep.Blobs, orphan) { + t.Fatalf("gc did not report orphan %s; blobs=%v", orphan, rep.Blobs) + } + if _, err := os.Stat(blobPath(s.root, orphan)); !os.IsNotExist(err) { + t.Fatalf("orphan blob still present after gc: %v", err) + } + if _, err := s.image("local:a"); err != nil { + t.Fatalf("live image unreadable after gc (live blobs reclaimed): %v", err) + } +} + func TestDefaultStoreFromEnvAndResolveStore(t *testing.T) { want := filepath.Join(t.TempDir(), "store") t.Setenv("ELFUSE_OCI_STORE", want) @@ -246,6 +322,43 @@ func TestLoadPinsCorruptNullAndPinError(t *testing.T) { } } +func TestInvalidPinnedDigestErrors(t *testing.T) { + s := openTestStore(t) + if err := os.WriteFile(filepath.Join(s.root, "refs.json"), []byte(`{"bad":"not-a-digest"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.image("bad"); err == nil { + t.Fatal("image with invalid pinned digest succeeded, want error") + } + var buf bytes.Buffer + if err := list(&buf, s, false); err == nil || !strings.Contains(err.Error(), `digest "not-a-digest"`) { + t.Fatalf("list err = %v, want invalid digest error", err) + } + if _, err := s.liveCacheKeys(); err == nil { + t.Fatal("liveCacheKeys with invalid pinned digest succeeded, want error") + } +} + +func TestRemoveManifestDescriptorAndGCErrors(t *testing.T) { + s := openTestStore(t) + if err := s.removeManifestDescriptor("not-a-digest"); err == nil { + t.Fatal("removeManifestDescriptor invalid digest succeeded, want error") + } + + // Pin a ref so gc's descriptor reconciliation and reachability both have to + // read the (now corrupt) index.json rather than short-circuiting on an + // empty pin set. + if _, err := s.addImage("local:a", buildImage(t, []string{"/a"})); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(s.root, "index.json"), []byte("{"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.gc(false); err == nil || !strings.Contains(err.Error(), "index") { + t.Fatalf("gc corrupt index err = %v, want an index parse error", err) + } +} + func TestCacheKeyForDigestRejectsInvalidAndUnsupported(t *testing.T) { if _, err := cacheKeyForDigest("not-a-digest"); err == nil { t.Fatal("cacheKeyForDigest accepted malformed digest") @@ -258,3 +371,193 @@ func TestCacheKeyForDigestRejectsInvalidAndUnsupported(t *testing.T) { t.Fatalf("cacheKeyForDigest(%q) succeeded, want rejection", unsupported) } } + +func TestPruneRootfsCachesKeepsLiveDropsOrphanAndDryRun(t *testing.T) { + s := &store{root: t.TempDir()} + liveHex := strings.Repeat("a", 64) + orphanHex := strings.Repeat("b", 64) + liveDir := filepath.Join(s.root, "rootfs", "sha256", liveHex) + orphanDir := filepath.Join(s.root, "rootfs", "sha256", orphanHex) + for _, dir := range []string{liveDir, orphanDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "file"), []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(s.root, "rootfs", "sha256", "not-a-dir"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + live := map[string]bool{filepath.Join("sha256", liveHex): true} + rep, err := pruneRootfsCaches(s, live, pruneOpts{cache: true, dryRun: true}) + if err != nil { + t.Fatalf("dry-run pruneRootfsCaches: %v", err) + } + if len(rep.CacheDirs) != 1 || rep.CacheDirs[0] != orphanDir { + t.Fatalf("dry-run cache dirs = %v, want [%s]", rep.CacheDirs, orphanDir) + } + if _, err := os.Stat(orphanDir); err != nil { + t.Fatalf("dry-run removed orphan cache: %v", err) + } + + rep, err = pruneRootfsCaches(s, live, pruneOpts{cache: true}) + if err != nil { + t.Fatalf("pruneRootfsCaches: %v", err) + } + if len(rep.CacheDirs) != 1 || rep.CacheDirs[0] != orphanDir { + t.Fatalf("cache dirs = %v, want [%s]", rep.CacheDirs, orphanDir) + } + if _, err := os.Stat(orphanDir); !os.IsNotExist(err) { + t.Fatalf("orphan cache after prune: %v, want IsNotExist", err) + } + if _, err := os.Stat(liveDir); err != nil { + t.Fatalf("live cache removed: %v", err) + } +} + +func TestPruneRootfsCachesMissingRootAndDiskUsageFallback(t *testing.T) { + s := &store{root: t.TempDir()} + rep, err := pruneRootfsCaches(s, nil, pruneOpts{cache: true}) + if err != nil { + t.Fatalf("missing rootfs prune: %v", err) + } + if len(rep.CacheDirs) != 0 || rep.Bytes != 0 { + t.Fatalf("missing rootfs report = %+v, want empty", rep) + } + + if got := diskUsage(fakeFileInfo{size: 123}); got != 123 { + t.Fatalf("diskUsage fallback = %d, want logical size 123", got) + } +} + +func TestPruneCachesErrorsOnInvalidLivePin(t *testing.T) { + s := openTestStore(t) + if err := os.WriteFile(filepath.Join(s.root, "refs.json"), []byte(`{"bad":"not-a-digest"}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.pruneCaches(pruneOpts{cache: true}); err == nil { + t.Fatal("pruneCaches with invalid live pin succeeded, want error") + } +} + +func TestDigestPrefix(t *testing.T) { + cases := []struct { + in string + want string + wantOK bool + }{ + {"abcdef123456", "abcdef123456", true}, + {"sha256:ABCDEF123456", "abcdef123456", true}, + {"abcdef12345", "", false}, + {strings.Repeat("a", 65), "", false}, + {"sha512:" + strings.Repeat("a", 64), "", false}, + {"not-hex-12345", "", false}, + } + for _, tc := range cases { + got, ok := digestPrefix(tc.in) + if ok != tc.wantOK || got != tc.want { + t.Errorf("digestPrefix(%q) = (%q, %v), want (%q, %v)", tc.in, got, ok, tc.want, tc.wantOK) + } + } +} + +func TestResolvePinnedTarget(t *testing.T) { + digestA := "sha256:" + strings.Repeat("a", 64) + digestB := "sha256:" + strings.Repeat("b", 64) + digestAmbiguous := "sha256:" + strings.Repeat("a", 12) + strings.Repeat("c", 52) + base := refPins{ + "local:a": digestA, + "local:b": digestB, + } + ambiguous := refPins{ + "local:a": digestA, + "local:b": digestB, + "local:ambiguous": digestAmbiguous, + } + + cases := []struct { + name string + pins refPins + target string + wantRef string + wantDigest string + wantErr string // when set, expect an error containing this and ignore wantRef/wantDigest + }{ + {name: "exact ref", pins: base, target: "local:a", wantRef: "local:a", wantDigest: digestA}, + {name: "unique prefix", pins: base, target: strings.Repeat("b", 12), wantRef: "local:b", wantDigest: digestB}, + {name: "uppercase prefix", pins: base, target: "sha256:" + strings.Repeat("B", 12), wantRef: "local:b", wantDigest: digestB}, + {name: "missing digest", pins: base, target: strings.Repeat("d", 12), wantErr: "not pulled"}, + {name: "invalid target", pins: base, target: "not-a-ref", wantErr: "not pulled"}, + {name: "ambiguous prefix", pins: ambiguous, target: strings.Repeat("a", 12), wantErr: "ambiguous"}, + {name: "invalid pinned digest", pins: refPins{"bad": "not-a-digest"}, target: strings.Repeat("e", 12), wantErr: "pinned digest"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ref, digest, err := resolvePinnedTarget(tc.pins, tc.target) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want substring %q", err, tc.wantErr) + } + return + } + if err != nil || ref != tc.wantRef || digest != tc.wantDigest { + t.Fatalf("resolve = ref=%q digest=%q err=%v, want %s %s", ref, digest, err, tc.wantRef, tc.wantDigest) + } + }) + } + + // The ambiguous case must list the colliding refs in sorted order. + if _, _, err := resolvePinnedTarget(ambiguous, strings.Repeat("a", 12)); err == nil || + !strings.Contains(err.Error(), "local:a, local:ambiguous") { + t.Fatalf("ambiguous err = %v, want sorted ambiguous refs", err) + } +} + +func TestRmiByDigestPrefixReportsResolvedRef(t *testing.T) { + s := openTestStore(t) + img := buildImage(t, []string{"/hello"}) + digest, err := s.addImage("local:a", img) + if err != nil { + t.Fatal(err) + } + prefix := strings.TrimPrefix(digest, "sha256:")[:12] + rep, err := s.rmi(prefix, false) + if err != nil { + t.Fatalf("rmi by prefix: %v", err) + } + if rep.Ref != "local:a" { + t.Fatalf("rmi report ref = %q, want local:a", rep.Ref) + } + if _, err := s.digestFor("local:a"); err == nil { + t.Fatal("local:a pin still present after rmi by digest prefix") + } + + s = openTestStore(t) + digest, err = s.addImage("local:a", img) + if err != nil { + t.Fatal(err) + } + prefix = strings.TrimPrefix(digest, "sha256:")[:12] + _, stderr, err := captureOutput(t, func() error { + return cmdRmi([]string{"--store", s.root, prefix}) + }) + if err != nil { + t.Fatalf("cmdRmi by prefix: %v", err) + } + if !strings.Contains(stderr, "Removed local:a:") || strings.Contains(stderr, "Removed "+prefix+":") { + t.Fatalf("cmdRmi stderr = %q, want resolved ref in summary", stderr) + } +} + +type fakeFileInfo struct { + size int64 +} + +func (f fakeFileInfo) Name() string { return "fake" } +func (f fakeFileInfo) Size() int64 { return f.size } +func (f fakeFileInfo) Mode() os.FileMode { return 0o644 } +func (f fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeFileInfo) IsDir() bool { return false } +func (f fakeFileInfo) Sys() any { return nil } diff --git a/cmd/elfuse-oci/test_helpers_test.go b/cmd/elfuse-oci/test_helpers_test.go index e07c21d0..3d805f82 100644 --- a/cmd/elfuse-oci/test_helpers_test.go +++ b/cmd/elfuse-oci/test_helpers_test.go @@ -11,7 +11,6 @@ import ( "os" "os/exec" "path/filepath" - "slices" "strings" "testing" @@ -39,7 +38,7 @@ func TestMain(m *testing.M) { UID: 1, GID: 2, } - if err := execElfuse(os.Getenv("ELFUSE_EXEC_ROOTFS"), spec); err != nil { + if err := execElfuse(os.Getenv("ELFUSE_EXEC_ROOTFS"), spec, nil); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(98) } @@ -48,10 +47,6 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -func sliceContains(xs []string, want string) bool { - return slices.Contains(xs, want) -} - func captureOutput(t *testing.T, fn func() error) (string, string, error) { t.Helper() @@ -170,3 +165,18 @@ func runMainSubprocess(t *testing.T, args ...string) (string, string, error) { waitErr := cmd.Run() return outB.String(), errB.String(), waitErr } + +// legacyCacheNameForRef reproduces the pre-digest cache naming scheme, which +// flattened the ref itself into a single (intentionally lossy) path +// component. New caches are keyed by digest (cacheKeyForDigest). prune +// --cache recognizes legacy caches purely by their top-level directory name +// (anything under rootfs/ or cs/ that is not "sha256"), never through this +// helper; it lives with the tests that fabricate legacy caches, documenting +// the old layout. +func legacyCacheNameForRef(ref string) string { + return strings.NewReplacer("/", "_", ":", "_", "@", "_").Replace(ref) +} + +func legacyRootfsForRef(store, ref string) string { + return filepath.Join(store, rootfsCacheDirName, legacyCacheNameForRef(ref)) +} diff --git a/cmd/elfuse-oci/unpack.go b/cmd/elfuse-oci/unpack.go index 3161ba06..f6bff989 100644 --- a/cmd/elfuse-oci/unpack.go +++ b/cmd/elfuse-oci/unpack.go @@ -16,9 +16,19 @@ import ( "github.com/google/go-containerregistry/pkg/v1" ) -// unpackImage extracts every layer of the stored image (base first) into a -// plain directory rootfs. Each layer is a tar stream (crane decompresses gzip -// and zstd transparently via layer.Uncompressed). +// rootfsStagingSuffix marks unpackImage's pre-rename staging siblings, +// . rootfsCacheLockPath parses it to map a staging +// dir back to its digest's lock, so the two must never drift apart. +const rootfsStagingSuffix = ".tmp-" + +// unpackImage extracts every layer of img (base first) into a plain directory +// rootfs. Each layer is a tar stream (crane decompresses gzip and zstd +// transparently via layer.Uncompressed). +// +// img is the caller's already-resolved image, not a ref re-resolved here: the +// caller keys caches by the digest it resolved, and a re-resolution could +// observe a different pin (a concurrent repull) and fill a digest-keyed cache +// with another image's content. // // Layer application implements the OCI whiteout conventions: // - `.wh.` in a directory removes `` (from this and lower layers). @@ -33,11 +43,7 @@ import ( // // Ownership is not applied here: elfuse runs as the host user and overrides // identity at runtime via --user, so the rootfs carries only mode bits. -func unpackImage(s *store, ref, dest string) error { - img, err := s.image(ref) - if err != nil { - return err - } +func unpackImage(img v1.Image, dest string) error { if _, statErr := os.Lstat(dest); statErr == nil { // An explicit pre-existing --rootfs directory: merge in place and // never remove it, failed or not; it is not ours to delete. @@ -54,7 +60,7 @@ func unpackImage(s *store, ref, dest string) error { if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return err } - tmp, err := os.MkdirTemp(filepath.Dir(dest), filepath.Base(dest)+".tmp-") + tmp, err := os.MkdirTemp(filepath.Dir(dest), filepath.Base(dest)+rootfsStagingSuffix) if err != nil { return err } @@ -99,6 +105,35 @@ func unpackInto(img v1.Image, dest string) error { return nil } +// layerPaths tracks what the current layer has created, so a late opaque +// whiteout does not wipe the layer's own additions. entries holds the exact tar +// entry paths; subtree additionally holds every ancestor directory of those +// entries, so an opaque clear can preserve an implicit parent directory the +// layer never gave its own tar entry (e.g. a layer with dir/sub/file but no +// dir/sub entry). +type layerPaths struct { + entries map[string]bool + subtree map[string]bool +} + +func newLayerPaths() layerPaths { + return layerPaths{entries: map[string]bool{}, subtree: map[string]bool{}} +} + +// add records name as a current-layer entry and marks name and all its ancestor +// directories as carrying current-layer content. +func (lp layerPaths) add(name string) { + lp.entries[name] = true + for p := name; p != "." && p != string(filepath.Separator); { + lp.subtree[p] = true + parent := filepath.Dir(p) + if parent == p { + break + } + p = parent + } +} + func applyLayer(root *os.Root, layer v1.Layer) error { r, err := layer.Uncompressed() if err != nil { @@ -109,7 +144,7 @@ func applyLayer(root *os.Root, layer v1.Layer) error { // content only, but tar entry order within a layer is not guaranteed: an // opaque marker may arrive after the directory's own same-layer children, // which must survive the clear (Docker's unpacker keeps the same set). - applied := make(map[string]bool) + lp := newLayerPaths() tr := tar.NewReader(r) for { hdr, err := tr.Next() @@ -119,7 +154,7 @@ func applyLayer(root *os.Root, layer v1.Layer) error { if err != nil { return fmt.Errorf("read tar entry: %w", err) } - if err := applyEntry(root, hdr, tr, applied); err != nil { + if err := applyEntry(root, hdr, tr, lp); err != nil { return fmt.Errorf("entry %q: %w", hdr.Name, err) } } @@ -141,10 +176,11 @@ func rootRelative(cleaned string) string { return strings.TrimPrefix(cleaned, "/") } -// applyEntry applies one tar header to the rootfs. applied tracks the paths -// the current layer has created so far, so an opaque whiteout arriving after -// its directory's same-layer children does not delete them. -func applyEntry(root *os.Root, hdr *tar.Header, r io.Reader, applied map[string]bool) error { +// applyEntry applies one tar header to the rootfs. lp tracks the paths the +// current layer has created so far, so an opaque whiteout arriving after its +// directory's same-layer children (or their implicit parents) does not delete +// them. +func applyEntry(root *os.Root, hdr *tar.Header, r io.Reader, lp layerPaths) error { name := filepath.Clean(hdr.Name) if strings.HasPrefix(name, "../") || name == ".." { return fmt.Errorf("unsafe entry path %q", hdr.Name) @@ -157,7 +193,7 @@ func applyEntry(root *os.Root, hdr *tar.Header, r io.Reader, applied map[string] base := filepath.Base(name) if base == opaqueMarker { - return clearDirectory(root, filepath.Dir(name), applied) + return clearDirectory(root, filepath.Dir(name), lp) } if trimmed, ok := strings.CutPrefix(base, whiteoutPrefix); ok { // A bare ".wh." would leave an empty target and Join(dir, "") is the @@ -169,7 +205,7 @@ func applyEntry(root *os.Root, hdr *tar.Header, r io.Reader, applied map[string] target := filepath.Join(filepath.Dir(name), trimmed) return root.RemoveAll(target) } - applied[name] = true + lp.add(name) // hdr.FileInfo().Mode() maps the tar header's unix mode bits to os.FileMode // with the special bits (ModeSetuid/Setgid/Sticky) at os.FileMode's high @@ -395,7 +431,7 @@ func shouldDropSpecial(err error, special os.FileMode) bool { // keeping entries the current layer itself created: opaque markers hide // lower-layer content, and a marker ordered after its directory's same-layer // additions must not wipe them. -func clearDirectory(root *os.Root, dir string, applied map[string]bool) error { +func clearDirectory(root *os.Root, dir string, lp layerPaths) error { fi, err := root.Lstat(dir) if err != nil { if os.IsNotExist(err) { @@ -410,7 +446,7 @@ func clearDirectory(root *os.Root, dir string, applied map[string]bool) error { // under this name anyway, mirroring the non-dir replacement mkdirAll and // the regular-file path perform. if !fi.IsDir() { - if applied[dir] { + if lp.entries[dir] { // This layer created the non-dir itself; there is no lower // content beneath it for the marker to hide. return nil @@ -434,7 +470,11 @@ func clearDirectory(root *os.Root, dir string, applied map[string]bool) error { } for _, e := range entries { child := filepath.Join(dir, e.Name()) - if applied[child] { + // Keep a child the current layer created OR that has current-layer + // content beneath it: an implicit parent directory (dir/sub with no + // own tar entry, created for dir/sub/file) has no entries[child] but is + // in subtree, and deleting it would take the layer's own file with it. + if lp.subtree[child] { continue } if err := root.RemoveAll(child); err != nil { diff --git a/cmd/elfuse-oci/unpack_image_test.go b/cmd/elfuse-oci/unpack_image_test.go index a6cfdde7..8a805970 100644 --- a/cmd/elfuse-oci/unpack_image_test.go +++ b/cmd/elfuse-oci/unpack_image_test.go @@ -25,6 +25,20 @@ type tarEntry struct { body string } +// unpackRef resolves ref to its pinned image and unpacks it into dest, the +// two-step the production callers do (resolve under the store lock, then +// unpack the resolved image). It fails the test if the ref cannot be +// resolved; the returned error is unpackImage's, so callers testing unpack +// failures still see them. +func unpackRef(t *testing.T, s *store, ref, dest string) error { + t.Helper() + img, err := s.image(ref) + if err != nil { + t.Fatalf("resolve %s: %v", ref, err) + } + return unpackImage(img, dest) +} + func testTarLayer(t *testing.T, entries ...tarEntry) v1.Layer { t.Helper() var buf bytes.Buffer @@ -105,7 +119,7 @@ func TestUnpackImageAppliesLayersInOrder(t *testing.T) { t.Fatal(err) } dest := t.TempDir() - if err := unpackImage(s, "local:layered", dest); err != nil { + if err := unpackRef(t, s, "local:layered", dest); err != nil { t.Fatalf("unpackImage: %v", err) } @@ -151,7 +165,7 @@ func TestUnpackImageCleansUpPartialRootfs(t *testing.T) { parent := t.TempDir() dest := filepath.Join(parent, "rootfs") - if err := unpackImage(s, "local:partial", dest); err == nil { + if err := unpackRef(t, s, "local:partial", dest); err == nil { t.Fatal("unpackImage succeeded, want failure on fifo entry") } if _, err := os.Lstat(dest); !os.IsNotExist(err) { @@ -170,7 +184,7 @@ func TestUnpackImageCleansUpPartialRootfs(t *testing.T) { if err := os.WriteFile(sentinel, []byte("keep"), 0o644); err != nil { t.Fatal(err) } - if err := unpackImage(s, "local:partial", pre); err == nil { + if err := unpackRef(t, s, "local:partial", pre); err == nil { t.Fatal("unpackImage succeeded, want failure on fifo entry") } if _, err := os.Stat(sentinel); err != nil { @@ -194,7 +208,7 @@ func TestUnpackImagePreexistingDestUnpacksInPlace(t *testing.T) { if err := os.WriteFile(sentinel, []byte("keep"), 0o644); err != nil { t.Fatal(err) } - if err := unpackImage(s, "local:merge", dest); err != nil { + if err := unpackRef(t, s, "local:merge", dest); err != nil { t.Fatalf("unpackImage: %v", err) } if b, err := os.ReadFile(sentinel); err != nil || string(b) != "keep" { @@ -221,7 +235,7 @@ func TestApplyEntryRootNoOpsHardlinkEscapeAndSymlinkReplacement(t *testing.T) { root, dir := newRoot(t) for _, name := range []string{".", "/"} { h := regHeader(name, 0o644, 0) - if err := applyEntry(root, &h, strings.NewReader(""), map[string]bool{}); err != nil { + if err := applyEntry(root, &h, strings.NewReader(""), newLayerPaths()); err != nil { t.Fatalf("applyEntry root no-op %q: %v", name, err) } } @@ -234,7 +248,7 @@ func TestApplyEntryRootNoOpsHardlinkEscapeAndSymlinkReplacement(t *testing.T) { } hardlink := linkHeader("escape-link", "../outside") - if err := applyEntry(root, &hardlink, strings.NewReader(""), map[string]bool{}); err == nil { + if err := applyEntry(root, &hardlink, strings.NewReader(""), newLayerPaths()); err == nil { t.Fatal("applyEntry accepted hardlink target escaping root") } @@ -246,7 +260,7 @@ func TestApplyEntryRootNoOpsHardlinkEscapeAndSymlinkReplacement(t *testing.T) { t.Fatal(err) } file := regHeader("replace-me", 0o644, int64(len("inside"))) - if err := applyEntry(root, &file, strings.NewReader("inside"), map[string]bool{}); err != nil { + if err := applyEntry(root, &file, strings.NewReader("inside"), newLayerPaths()); err != nil { t.Fatalf("applyEntry replacing symlink: %v", err) } if b, err := os.ReadFile(outside); err != nil || string(b) != "outside" { @@ -264,7 +278,7 @@ func TestApplyEntryAdditionalErrorBranches(t *testing.T) { t.Run("unsupported tar type", func(t *testing.T) { root, _ := newRoot(t) h := tar.Header{Name: "weird", Typeflag: 'x'} - err := applyEntry(root, &h, strings.NewReader(""), map[string]bool{}) + err := applyEntry(root, &h, strings.NewReader(""), newLayerPaths()) if err == nil || !strings.Contains(err.Error(), "unsupported tar type") || !strings.Contains(err.Error(), tarTypeName('x')) { t.Fatalf("unsupported type err = %v, want tar type error", err) } @@ -273,7 +287,7 @@ func TestApplyEntryAdditionalErrorBranches(t *testing.T) { t.Run("absolute symlink to root", func(t *testing.T) { root, dir := newRoot(t) h := symHeader("usr/root-link", "/") - if err := applyEntry(root, &h, strings.NewReader(""), map[string]bool{}); err != nil { + if err := applyEntry(root, &h, strings.NewReader(""), newLayerPaths()); err != nil { t.Fatalf("applyEntry symlink to root: %v", err) } target, err := os.Readlink(filepath.Join(dir, "usr", "root-link")) @@ -288,14 +302,14 @@ func TestApplyEntryAdditionalErrorBranches(t *testing.T) { t.Run("parent path is regular file", func(t *testing.T) { root, dir := newRoot(t) parent := regHeader("parent", 0o644, 0) - if err := applyEntry(root, &parent, strings.NewReader(""), map[string]bool{}); err != nil { + if err := applyEntry(root, &parent, strings.NewReader(""), newLayerPaths()); err != nil { t.Fatal(err) } // A non-directory on the parent path is replaced with a real // directory (containerd/Docker behavior), not an error: layers may // legitimately turn a lower layer's file into a directory. child := regHeader("parent/child", 0o644, 0) - if err := applyEntry(root, &child, strings.NewReader(""), map[string]bool{}); err != nil { + if err := applyEntry(root, &child, strings.NewReader(""), newLayerPaths()); err != nil { t.Fatalf("applyEntry child under regular-file parent: %v, want file replaced by directory", err) } fi, err := os.Lstat(filepath.Join(dir, "parent")) @@ -310,7 +324,7 @@ func TestApplyEntryAdditionalErrorBranches(t *testing.T) { t.Run("opaque missing directory is no-op", func(t *testing.T) { root, _ := newRoot(t) h := tar.Header{Name: "missing/.wh..wh..opq", Typeflag: tar.TypeReg} - if err := applyEntry(root, &h, strings.NewReader(""), map[string]bool{}); err != nil { + if err := applyEntry(root, &h, strings.NewReader(""), newLayerPaths()); err != nil { t.Fatalf("opaque missing dir: %v", err) } }) @@ -318,14 +332,14 @@ func TestApplyEntryAdditionalErrorBranches(t *testing.T) { t.Run("opaque marker under lower-layer regular file replaces it", func(t *testing.T) { root, dir := newRoot(t) file := regHeader("notdir", 0o644, 0) - if err := applyEntry(root, &file, strings.NewReader(""), map[string]bool{}); err != nil { + if err := applyEntry(root, &file, strings.NewReader(""), newLayerPaths()); err != nil { t.Fatal(err) } // The opaque marker hides all lower content under the name, so a // lower layer's non-directory there is replaced with an empty real // directory rather than cleared through or rejected. h := tar.Header{Name: "notdir/.wh..wh..opq", Typeflag: tar.TypeReg} - if err := applyEntry(root, &h, strings.NewReader(""), map[string]bool{}); err != nil { + if err := applyEntry(root, &h, strings.NewReader(""), newLayerPaths()); err != nil { t.Fatalf("opaque marker under regular file: %v, want file replaced by empty directory", err) } fi, err := os.Lstat(filepath.Join(dir, "notdir")) @@ -337,12 +351,12 @@ func TestApplyEntryAdditionalErrorBranches(t *testing.T) { t.Run("opaque marker under same-layer regular file is a no-op", func(t *testing.T) { root, dir := newRoot(t) file := regHeader("notdir", 0o644, 0) - applied := map[string]bool{} - if err := applyEntry(root, &file, strings.NewReader(""), applied); err != nil { + lp := newLayerPaths() + if err := applyEntry(root, &file, strings.NewReader(""), lp); err != nil { t.Fatal(err) } h := tar.Header{Name: "notdir/.wh..wh..opq", Typeflag: tar.TypeReg} - if err := applyEntry(root, &h, strings.NewReader(""), applied); err != nil { + if err := applyEntry(root, &h, strings.NewReader(""), lp); err != nil { t.Fatalf("opaque marker under same-layer file: %v, want no-op", err) } fi, err := os.Lstat(filepath.Join(dir, "notdir")) @@ -403,7 +417,7 @@ func TestUnpackOpaqueAfterSameLayerChildren(t *testing.T) { t.Fatal(err) } dest := t.TempDir() - if err := unpackImage(s, "local:opq-late", dest); err != nil { + if err := unpackRef(t, s, "local:opq-late", dest); err != nil { t.Fatalf("unpackImage: %v", err) } @@ -415,6 +429,40 @@ func TestUnpackOpaqueAfterSameLayerChildren(t *testing.T) { } } +// TestUnpackOpaqueAfterImplicitParentChildren: a late opaque marker +// must preserve the current layer's descendants even when their immediate +// parent directory has no tar entry of its own (an implicit parent, created +// only because a deeper file needed it). Here the upper layer writes +// dir/sub/file with NO dir/sub entry, then an opaque marker on dir: dir/sub and +// its file must survive while the lower layer's dir/old is cleared. +func TestUnpackOpaqueAfterImplicitParentChildren(t *testing.T) { + lower := testTarLayer(t, + tarEntry{header: dirHeader("dir", 0o755)}, + tarEntry{header: regHeader("dir/old", 0o644, 0), body: "hidden"}, + ) + upper := testTarLayer(t, + // No dir/sub entry: the parent is implicit, created for dir/sub/file. + tarEntry{header: regHeader("dir/sub/file", 0o644, 0), body: "kept"}, + tarEntry{header: tar.Header{Name: "dir/.wh..wh..opq", Typeflag: tar.TypeReg, Mode: 0o644}}, + ) + + s := openTestStore(t) + if _, err := s.addImage("local:opq-implicit", testImageWithLayers(t, lower, upper)); err != nil { + t.Fatal(err) + } + dest := t.TempDir() + if err := unpackRef(t, s, "local:opq-implicit", dest); err != nil { + t.Fatalf("unpackImage: %v", err) + } + + if _, err := os.Stat(filepath.Join(dest, "dir", "old")); !os.IsNotExist(err) { + t.Fatalf("opaque-hidden dir/old = %v, want IsNotExist", err) + } + if b, err := os.ReadFile(filepath.Join(dest, "dir", "sub", "file")); err != nil || string(b) != "kept" { + t.Fatalf("dir/sub/file = %q, err=%v; want the implicit-parent child to survive the late marker", b, err) + } +} + // TestUnpackRejectsBareWhiteout pins that a malformed ".wh." entry with no // target suffix fails extraction instead of resolving to Join(dir, "") and // deleting the containing directory. @@ -433,7 +481,7 @@ func TestUnpackRejectsBareWhiteout(t *testing.T) { defer root.Close() hdr := tar.Header{Name: "opt/.wh.", Typeflag: tar.TypeReg, Mode: 0o644} - if err := applyEntry(root, &hdr, strings.NewReader(""), map[string]bool{}); err == nil { + if err := applyEntry(root, &hdr, strings.NewReader(""), newLayerPaths()); err == nil { t.Fatal("bare .wh. entry applied, want invalid-whiteout error") } if _, err := os.Stat(filepath.Join(dest, "opt", "keep")); err != nil { @@ -460,7 +508,7 @@ func TestUnpackDirReplacesLowerSymlink(t *testing.T) { t.Fatal(err) } dest := t.TempDir() - if err := unpackImage(s, "local:dir-over-link", dest); err != nil { + if err := unpackRef(t, s, "local:dir-over-link", dest); err != nil { t.Fatalf("unpackImage: %v", err) } @@ -498,7 +546,7 @@ func TestUnpackParentSymlinkReplaced(t *testing.T) { t.Fatal(err) } dest := t.TempDir() - if err := unpackImage(s, "local:parent-link", dest); err != nil { + if err := unpackRef(t, s, "local:parent-link", dest); err != nil { t.Fatalf("unpackImage: %v", err) } @@ -534,7 +582,7 @@ func TestUnpackDirEntryParentSymlinkReplaced(t *testing.T) { t.Fatal(err) } dest := t.TempDir() - if err := unpackImage(s, "local:parent-link-dir", dest); err != nil { + if err := unpackRef(t, s, "local:parent-link-dir", dest); err != nil { t.Fatalf("unpackImage: %v", err) } @@ -573,7 +621,7 @@ func TestUnpackOpaqueThroughSymlinkKeepsTarget(t *testing.T) { t.Fatal(err) } dest := t.TempDir() - if err := unpackImage(s, "local:opaque-link", dest); err != nil { + if err := unpackRef(t, s, "local:opaque-link", dest); err != nil { t.Fatalf("unpackImage: %v", err) } diff --git a/cmd/elfuse-oci/unpack_test.go b/cmd/elfuse-oci/unpack_test.go index a4f4e4b2..cd0698dc 100644 --- a/cmd/elfuse-oci/unpack_test.go +++ b/cmd/elfuse-oci/unpack_test.go @@ -33,7 +33,7 @@ func applyEntries(t *testing.T, root *os.Root, entries []tar.Header) { content = []byte(strings.Repeat("x", int(h.Size))) } hdr := h - if err := applyEntry(root, &hdr, bytes.NewReader(content), map[string]bool{}); err != nil { + if err := applyEntry(root, &hdr, bytes.NewReader(content), newLayerPaths()); err != nil { t.Fatalf("applyEntry %q: %v", h.Name, err) } } @@ -43,7 +43,7 @@ func applyEntryWithContent(t *testing.T, root *os.Root, h tar.Header, content st t.Helper() hdr := h hdr.Size = int64(len(content)) - if err := applyEntry(root, &hdr, strings.NewReader(content), map[string]bool{}); err != nil { + if err := applyEntry(root, &hdr, strings.NewReader(content), newLayerPaths()); err != nil { t.Fatalf("applyEntry %q: %v", h.Name, err) } } @@ -345,7 +345,7 @@ func TestUnpackSpecialFilesRejected(t *testing.T) { t.Run(tc.want, func(t *testing.T) { root, dir := newRoot(t) hdr := tar.Header{Name: tc.name, Mode: 0o644, Typeflag: tc.typeflag} - err := applyEntry(root, &hdr, strings.NewReader(""), map[string]bool{}) + err := applyEntry(root, &hdr, strings.NewReader(""), newLayerPaths()) if err == nil || !strings.Contains(err.Error(), "unsupported special file type "+tc.want) { t.Fatalf("applyEntry special %s err = %v, want unsupported error", tc.want, err) } @@ -359,7 +359,7 @@ func TestUnpackSpecialFilesRejected(t *testing.T) { func TestUnpackPathEscapeRejected(t *testing.T) { root, _ := newRoot(t) hdr := regHeader("../escape", 0o644, 0) - if err := applyEntry(root, &hdr, strings.NewReader(""), map[string]bool{}); err == nil { + if err := applyEntry(root, &hdr, strings.NewReader(""), newLayerPaths()); err == nil { t.Fatalf("applyEntry accepted ../escape path") } } @@ -385,7 +385,7 @@ func TestUnpackReadsExactSize(t *testing.T) { content := "abc123" r := &countingReader{b: []byte(content + "TRAILING-JUNK")} hdr := regHeader("f", 0o644, int64(len(content))) - if err := applyEntry(root, &hdr, r, map[string]bool{}); err != nil { + if err := applyEntry(root, &hdr, r, newLayerPaths()); err != nil { t.Fatalf("applyEntry: %v", err) } if r.n != len(content) { @@ -397,7 +397,7 @@ func TestUnpackReadsExactSize(t *testing.T) { short := &countingReader{b: []byte("abc")} hdr = regHeader("g", 0o644, int64(len(content))) - if err := applyEntry(root, &hdr, short, map[string]bool{}); err == nil { + if err := applyEntry(root, &hdr, short, newLayerPaths()); err == nil { t.Fatal("applyEntry accepted a short body, want size-mismatch error") } } From e69b190aeefeaf8ec7c66938d988f1d3d7832bfe Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 15 Jul 2026 23:38:02 +0200 Subject: [PATCH 19/22] Add OCI conformance checks and lifecycle CI The on-disk store is the contract: pulls must produce a valid OCI image-layout that other tools can read and that agrees with registry truth on the manifest digest. Conformance tests pin the layout shape, and scripts/oci-interop.sh cross-checks the store with crane, skopeo, and umoci. CI splits by runner capability: a Linux job runs the pure-Go store paths (pull, unpack, inspect, lifecycle) without Hypervisor.framework, a hosted macOS job builds and tests the darwin sparsebundle code and drives a run-less pull/inspect/list/rmi/prune lifecycle smoke, and the self-hosted release leg boots guests end to end under HVF: the alpine:3 default-entrypoint smoke plus a full pull -> inspect -> list -> run -> rmi -> prune lifecycle that runs python:3.12-slim with an --entrypoint override and asserts the teardown half of the lifecycle. The lifecycle teardown covers all three reclamation guardrails: a plain rmi reclaims the cold cache with the image, run --keep output refuses rmi without --force, and a live --plain-rootfs guest parked in sleep pins its cache; prune --cache --all must skip it and rmi must refuse until the guest exits, the only end-to-end exercise of the run-lock descriptor riding through the exec into elfuse. --- .github/workflows/main.yml | 190 ++++++++++++- Makefile | 39 ++- cmd/elfuse-oci/store_conformance_test.go | 343 +++++++++++++++++++++++ scripts/ci/oci-cli-smoke.sh | 108 +++++++ scripts/ci/oci-lib.sh | 154 ++++++++++ scripts/ci/oci-lifecycle.sh | 154 ++++++++++ scripts/ci/oci-python-workload.py | 117 ++++++++ scripts/ci/oci-run-smoke.sh | 54 ++++ scripts/oci-interop.sh | 209 ++++++++++++++ 9 files changed, 1362 insertions(+), 6 deletions(-) create mode 100644 cmd/elfuse-oci/store_conformance_test.go create mode 100755 scripts/ci/oci-cli-smoke.sh create mode 100755 scripts/ci/oci-lib.sh create mode 100755 scripts/ci/oci-lifecycle.sh create mode 100644 scripts/ci/oci-python-workload.py create mode 100755 scripts/ci/oci-run-smoke.sh create mode 100755 scripts/oci-interop.sh diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6276c986..3d12ff9a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,12 @@ # scan-macos : LLVM scan-build via `make analyze` # infer-macos : Facebook Infer capture + analyze over the full build # runtime-macos : HVF runtime tests on self-hosted Apple Silicon, -# including release, ASAN, UBSAN, and TSAN variants +# including release, ASAN, UBSAN, and TSAN variants, +# plus the end-to-end OCI run and image-lifecycle checks +# oci-conformance : OCI image-layout conformance + cross-tool interop +# (crane/skopeo/umoci) on Linux +# oci-macos : elfuse-oci darwin build, unit tests, sparsebundle +# round-trip, and a run-less image-lifecycle smoke # # Runtime and sanitizer tests require Hypervisor.framework, which # GitHub-hosted macOS runners do not expose. Those tests run on self-hosted @@ -94,12 +99,13 @@ jobs: run: .ci/check-security.sh - name: shellcheck - # Scoped to .ci/ -- tests/ has pre-existing warnings that the - # repository's own check-format target already surfaces. + # Scoped to .ci/ and scripts/; tests/ has pre-existing warnings that + # the repository's own check-format target already surfaces. if: ${{ !cancelled() }} run: | set -euo pipefail - mapfile -d '' files < <(git ls-files -z -- '.ci/*.sh') + # git pathspec globs cross '/', so 'scripts/*.sh' covers scripts/ci/ too. + mapfile -d '' files < <(git ls-files -z -- '.ci/*.sh' 'scripts/*.sh') shellcheck --severity=warning "${files[@]}" - name: cppcheck @@ -551,6 +557,15 @@ jobs: ls -l "$ROSETTA" + - name: Set up Go + # Only the release leg runs the OCI run smoke below, which needs the Go + # toolchain to build build/elfuse-oci. + if: ${{ matrix.run_matrix }} + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Build elfuse # make does not track EXTRA_CFLAGS changes, so an object built for one # sanitizer must not be reused for another. Checkout already wipes @@ -580,6 +595,44 @@ jobs: run: | make EXTRA_CFLAGS="$EXTRA_CFLAGS" ${{ matrix.check_target }} + - name: OCI run smoke (pull -> sparsebundle -> COW clone -> HVF boot) + # The only leg that exercises the full default `run` path end to end: + # pull an image, provision the case-sensitive sparsebundle, COW-clone it, + # boot the guest under HVF, and propagate its exit status. Release leg + # only (sanitizer legs skip the fixture/qemu-heavy paths). + if: ${{ matrix.run_matrix }} + run: | + set -euo pipefail + # Same persistent-disk convention as the fixture cache above: + # checkout wipes the workspace but this self-hosted runner's disk + # survives. pull is idempotent per digest, so a warm store skips + # the image blob downloads (only the manifest HEAD/GET goes + # out) and the warm sparsebundle cache skips the unpack. env: + # values don't expand $HOME, so export here instead. + export ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-store" + make build/elfuse-oci + scripts/ci/oci-run-smoke.sh + + - name: OCI image lifecycle (pull -> inspect -> list -> run -> rmi -> prune) + # Walks the whole user-facing image lifecycle on the only leg with + # HVF. python:3.12-slim adds what the alpine smoke above does not: + # an --entrypoint override, a glibc dynamically-linked guest, and + # the teardown half of the lifecycle (see the phase functions in + # scripts/ci/oci-lifecycle.sh). Separate store from the smoke step + # so the empty-store assertions are meaningful. Release leg only. + if: ${{ matrix.run_matrix }} + env: + ELFUSE_OCI_STORE: ${{ runner.temp }}/oci-lifecycle-store + IMG: python:3.12-slim + run: | + set -euo pipefail + # Built by the smoke step above; the make target is idempotent. + make build/elfuse-oci + # The seed store lives on the runner's persistent disk (env: does + # not expand $HOME, so export here instead). + export ELFUSE_OCI_SEED_STORE="$HOME/.cache/elfuse-ci/oci-seed-store" + scripts/ci/oci-lifecycle.sh + - name: Test matrix if: ${{ matrix.run_matrix }} run: | @@ -611,3 +664,132 @@ jobs: else echo "No externals/test-fixtures to save" fi + + # OCI image-layout conformance + cross-tool interop on Linux. elfuse-oci + # is pure Go (no Hypervisor.framework), so pull/inspect/unpack and the + # conformance tests run in hosted CI; only `run` needs HVF and is excluded. + # The on-disk store is the contract: it must be a valid OCI image-layout that + # crane/skopeo/umoci can read and that agrees with registry truth. + oci-conformance: + name: OCI conformance + interop (Linux) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + # Pinned to elfuse-oci's go-containerregistry version so the crane + # CLI reads layouts with the same schema handling it writes with. + GGCR_VERSION: v0.21.7 + # umoci release tag for the interop gate; built from a checkout below. + UMOCI_VERSION: v0.6.0 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Install jq + skopeo + # skopeo reads our layout via the oci: transport. CI treats it as part + # of the conformance gate; local runs may omit it and get a skipped + # interop section from scripts/oci-interop.sh. + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y jq skopeo + + - name: Install crane + umoci from source + # crane (registry-truth comparison) and umoci (layout parse) are Go + # tools; install crane at elfuse-oci's ggcr version where applicable. + run: | + set -euo pipefail + go install github.com/google/go-containerregistry/cmd/crane@${GGCR_VERSION} + # `go install pkg@version` refuses umoci: its go.mod carries replace + # directives. Build from a pinned checkout instead, where replace + # directives apply; a read-only `umoci list --layout` conformance + # check needs nothing newer. + git clone --quiet --depth 1 --branch "$UMOCI_VERSION" \ + https://github.com/opencontainers/umoci.git "$RUNNER_TEMP/umoci" + (cd "$RUNNER_TEMP/umoci" && \ + go build -o "$(go env GOPATH)/bin/umoci" ./cmd/umoci) + echo "$(go env GOPATH)/bin" >>"$GITHUB_PATH" + + - name: Build elfuse-oci + # Pure Go target; does not require the C toolchain or HVF. + run: make build/elfuse-oci + + - name: Go fmt + vet (Linux and darwin cross-check) + # The Makefile gate, so CI and local runs cannot drift. oci-lint vets + # native, darwin/arm64, and linux; the darwin pass compile-checks the + # sparsebundle files (csrun.go, sparsebundle.go, cache_darwin.go) + # that never build on this Linux runner. + run: make oci-lint + + - name: CLI lifecycle smoke (pull/list/inspect/unpack/rmi/prune) + # Exercises the built binary through the same user-facing flow that the + # Go unit tests model in-process. `run` itself remains covered by Go + # orchestration tests here and by macOS/HVF runtime jobs. --unpack adds + # the unpack + cache-reclaiming rmi phase to the shared smoke. + run: scripts/ci/oci-cli-smoke.sh --unpack + + - name: Go unit + conformance tests (with network pull round-trip) + # ELFUSE_OCI_NETTEST enables the pull round-trip that re-opens the store + # with crane's independent layout reader and asserts digest agreement. + env: + ELFUSE_OCI_NETTEST: "1" + run: go test -race ./cmd/elfuse-oci/ + + - name: Cross-tool interop (crane + skopeo + umoci) + # Pulls fixtures, then asserts the on-disk layout is spec-shaped and + # that available tools read it and agree with registry truth. + run: scripts/oci-interop.sh + + # Darwin elfuse-oci build + tests on a hosted macOS runner. The default `run` + # path (csrun.go, sparsebundle.go, cache_darwin.go) only compiles on darwin, so + # the Linux job above can only cross-vet it; this job actually builds and runs + # it, and drives the run-less image lifecycle (pull/inspect/list/rmi/prune) + # through the darwin binary. Hosted runners provide hdiutil + case-sensitive + # APFS (so the real sparsebundle round-trip runs) even though they lack + # Hypervisor.framework; the HVF-backed guest boot is covered by the + # self-hosted runtime-macos job. + oci-macos: + name: OCI image CLI (macOS Apple Silicon) + runs-on: macos-15 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Build elfuse-oci + run: make build/elfuse-oci + + - name: Go unit tests (darwin native) + # Runs the whole suite on darwin, exercising the sparsebundle/clone seams + # in csrun/sparsebundle/cache_darwin that the Linux job cannot compile. + # -race: the darwin concurrency-sensitive code (sweep/provision/clone, + # cache-removal lock discipline) compiles only here, so this is the only + # place the race detector ever sees it. + run: go test -race ./cmd/elfuse-oci/ + + - name: Sparsebundle round-trip (hdiutil + case-sensitive APFS) + # ELFUSE_OCI_DARWIN_CS un-skips the real hdiutil create/attach/detach + + # case-sensitive APFS sweep; hosted runners have hdiutil and APFS. + env: + ELFUSE_OCI_DARWIN_CS: "1" + run: go test -run TestDarwinCSSweep ./cmd/elfuse-oci/ + + - name: CLI lifecycle smoke (pull/inspect/list/rmi/prune, no HVF) + # The same shared smoke the Linux job drives, but through the darwin + # binary: everything short of `run` (which needs HVF) works end to + # end on a hosted runner. Without --unpack every rmi takes the + # cache-free path (nothing was ever unpacked, no --force involved) + # that the Linux job's cache-reclaiming flow does not cover. jq + # ships on the macos-15 image. + run: scripts/ci/oci-cli-smoke.sh diff --git a/Makefile b/Makefile index 1776b7ff..2762a512 100644 --- a/Makefile +++ b/Makefile @@ -102,7 +102,9 @@ endef .PHONY: all elfuse .PHONY: gen-syscall-dispatch check-syscall-dispatch -all: elfuse elfuse-oci +# Plain `make` must keep building with only the C toolchain; elfuse-oci is an +# explicit opt-in target because it needs Go (`make elfuse elfuse-oci`). +all: elfuse ## Regenerate build/dispatch.h from src/syscall/dispatch.tbl gen-syscall-dispatch: @@ -136,12 +138,45 @@ OCI_SRCS := $(shell find cmd/elfuse-oci -type f -name '*.go' 2>/dev/null) .PHONY: elfuse-oci elfuse-oci: $(OCI_BIN) +# OCI image-layout conformance + cross-tool interop. Pulls fixtures into a +# throwaway store and asserts the on-disk layout is spec-shaped and readable by +# crane/skopeo/umoci (whichever are installed locally; all are required in CI). +# Pure Go + jq; no HVF, runs on Linux. Requires network to pull fixtures. +.PHONY: oci-interop +oci-interop: $(OCI_BIN) + $(Q)scripts/oci-interop.sh + +# Go unit tests for the OCI image CLI (offline). Set ELFUSE_OCI_NETTEST=1 +# to also exercise the network pull round-trip conformance test. +.PHONY: oci-test +oci-test: + $(Q)$(GO) test ./cmd/elfuse-oci/ + +# gofmt + go vet gate for the OCI image CLI. `go vet` is run for both GOOS +# values so the darwin-only sparsebundle files are checked from Linux CI and the +# non-darwin stubs are checked from a macOS host. The darwin pass pins +# GOARCH=arm64 (the only supported darwin target, and what CI checks) so an +# amd64 Linux host does not silently vet darwin/amd64 instead. oci-lint +# bundles both so a local run matches the CI gate. +.PHONY: oci-vet oci-fmt-check oci-lint +oci-vet: + $(Q)GOOS=darwin GOARCH=arm64 $(GO) vet ./cmd/elfuse-oci/ + $(Q)GOOS=linux $(GO) vet ./cmd/elfuse-oci/ + +oci-fmt-check: + $(Q)out="$$(gofmt -l cmd/elfuse-oci)"; \ + if [ -n "$$out" ]; then \ + echo "gofmt needs to run on:"; echo "$$out"; exit 1; \ + fi + +oci-lint: oci-fmt-check oci-vet + # rm -f first: `go build -o` follows an existing symlink at the output path, # so a stale build/elfuse-oci symlink would clobber build/elfuse. $(OCI_BIN): go.mod $(OCI_SRCS) | $(BUILD_DIR) @echo " GO $@" $(Q)rm -f $@ - $(Q)cd $(CURDIR) && $(GO) build -ldflags "-X main.version=$(VERSION)" \ + $(Q)$(GO) build -ldflags "-X main.version=$(VERSION)" \ -o $@ ./cmd/elfuse-oci # Native test binaries (macOS, Hypervisor.framework) diff --git a/cmd/elfuse-oci/store_conformance_test.go b/cmd/elfuse-oci/store_conformance_test.go new file mode 100644 index 00000000..2447c183 --- /dev/null +++ b/cmd/elfuse-oci/store_conformance_test.go @@ -0,0 +1,343 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/layout" +) + +// TestStoreLayoutFiles asserts openStore creates the OCI image-layout +// scaffolding exactly: an oci-layout file with imageLayoutVersion 1.0.0, an +// index.json with an empty manifests array, and a blobs/sha256/ tree. This is +// the part of the OCI image-layout spec every conforming reader expects. +func TestStoreLayoutFiles(t *testing.T) { + s := openTestStore(t) + + b, err := os.ReadFile(filepath.Join(s.root, "oci-layout")) + if err != nil { + t.Fatal(err) + } + var lay struct { + Version string `json:"imageLayoutVersion"` + } + if err := json.Unmarshal(b, &lay); err != nil { + t.Fatalf("oci-layout is not JSON: %v (raw %q)", err, b) + } + if lay.Version != "1.0.0" { + t.Errorf("imageLayoutVersion: got %q, want 1.0.0", lay.Version) + } + + b, err = os.ReadFile(filepath.Join(s.root, "index.json")) + if err != nil { + t.Fatal(err) + } + var idx struct { + Schema int `json:"schemaVersion"` + Manifests []json.RawMessage `json:"manifests"` + } + if err := json.Unmarshal(b, &idx); err != nil { + t.Fatalf("index.json is not JSON: %v", err) + } + if idx.Schema != 2 { + t.Errorf("index schemaVersion: got %d, want 2", idx.Schema) + } + if len(idx.Manifests) != 0 { + t.Errorf("fresh index has %d manifests, want 0", len(idx.Manifests)) + } + + if fi, err := os.Stat(filepath.Join(s.root, "blobs", "sha256")); err != nil || !fi.IsDir() { + t.Errorf("blobs/sha256/ missing or not a directory: %v", err) + } +} + +// TestStoreLayoutRoundTrip stores a tiny image, then re-opens the layout with +// crane's own layout.FromPath reader (independent of our store.go write path) +// and asserts the manifest, config, and layer digests all round-trip. This +// is the offline OCI image-layout conformance signal: the on-disk bytes are +// parseable by the canonical go-containerregistry reader. +func TestStoreLayoutRoundTrip(t *testing.T) { + s := openTestStore(t) + img := tinyImage(t) + wantManifest, err := img.Digest() + if err != nil { + t.Fatal(err) + } + wantConfig, err := img.ConfigName() + if err != nil { + t.Fatal(err) + } + layers, err := img.Layers() + if err != nil { + t.Fatal(err) + } + wantLayerDigests := make([]v1.Hash, len(layers)) + for i, l := range layers { + d, err := l.Digest() + if err != nil { + t.Fatal(err) + } + wantLayerDigests[i] = d + } + + digest, err := s.addImage("local:tiny", img) + if err != nil { + t.Fatal(err) + } + if digest != wantManifest.String() { + t.Errorf("addImage digest: got %s, want %s", digest, wantManifest) + } + + // Re-open the layout from disk with crane's reader, not our handle. + p, err := layout.FromPath(s.root) + if err != nil { + t.Fatalf("layout.FromPath: %v (store is not a readable OCI layout)", err) + } + got, err := p.Image(wantManifest) + if err != nil { + t.Fatalf("re-opened layout cannot find manifest %s: %v", wantManifest, err) + } + gotManifest, err := got.Digest() + if err != nil { + t.Fatal(err) + } + if gotManifest != wantManifest { + t.Errorf("manifest digest: got %s, want %s", gotManifest, wantManifest) + } + gotConfig, err := got.ConfigName() + if err != nil { + t.Fatal(err) + } + if gotConfig != wantConfig { + t.Errorf("config digest: got %s, want %s", gotConfig, wantConfig) + } + gotLayers, err := got.Layers() + if err != nil { + t.Fatal(err) + } + if len(gotLayers) != len(wantLayerDigests) { + t.Fatalf("layer count: got %d, want %d", len(gotLayers), len(wantLayerDigests)) + } + for i, l := range gotLayers { + d, err := l.Digest() + if err != nil { + t.Fatal(err) + } + if d != wantLayerDigests[i] { + t.Errorf("layer %d digest: got %s, want %s", i, d, wantLayerDigests[i]) + } + } + + for _, h := range append([]v1.Hash{wantManifest, wantConfig}, wantLayerDigests...) { + p := filepath.Join(s.root, "blobs", h.Algorithm, h.Hex) + if _, err := os.Stat(p); err != nil { + t.Errorf("blob %s missing on disk: %v", h, err) + } + } + + gotDigest, err := s.digestFor("local:tiny") + if err != nil { + t.Fatal(err) + } + if gotDigest != wantManifest.String() { + t.Errorf("pin: got %s, want %s", gotDigest, wantManifest) + } +} + +// TestStoreInteropPullRoundTrip pulls a real image and asserts the store +// round-trips through crane's independent reader, exactly like the offline +// round-trip but with a registry-fetched image. Gated behind ELFUSE_OCI_NETTEST +// so `go test` stays green offline; CI enables it on the Linux conformance job. +func TestStoreInteropPullRoundTrip(t *testing.T) { + if os.Getenv("ELFUSE_OCI_NETTEST") != "1" { + t.Skip("set ELFUSE_OCI_NETTEST=1 to pull real images") + } + ref := "alpine:3" + cf := commonFlags{platform: defaultPlatform} + + s := openTestStore(t) + if err := pullImage(cf, s, ref); err != nil { + t.Fatalf("pull %s failed with ELFUSE_OCI_NETTEST=1: %v", ref, err) + } + + digestStr, err := s.digestFor(ref) + if err != nil { + t.Fatal(err) + } + wantManifest, err := v1.NewHash(digestStr) + if err != nil { + t.Fatal(err) + } + + p, err := layout.FromPath(s.root) + if err != nil { + t.Fatalf("layout.FromPath: %v", err) + } + got, err := p.Image(wantManifest) + if err != nil { + t.Fatalf("crane reader cannot find manifest %s: %v", wantManifest, err) + } + gotManifest, err := got.Digest() + if err != nil { + t.Fatal(err) + } + if gotManifest != wantManifest { + t.Errorf("manifest digest: got %s, want %s", gotManifest, wantManifest) + } + if _, err := got.ConfigFile(); err != nil { + t.Errorf("ConfigFile: %v", err) + } + layers, err := got.Layers() + if err != nil || len(layers) == 0 { + t.Fatalf("layers: %v (got %d)", err, len(layers)) + } + for _, l := range layers { + if _, err := l.Digest(); err != nil { + t.Errorf("layer digest: %v", err) + } + } +} + +// TestStoreDedupOnRePull asserts that adding the same image twice does not +// accumulate a second manifest descriptor in the layout index (addImage dedups +// by digest), while the ref pin still resolves to that digest. +func TestStoreDedupOnRePull(t *testing.T) { + s := openTestStore(t) + img := tinyImage(t) + want, err := img.Digest() + if err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:tiny", img); err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:tiny", img); err != nil { + t.Fatal(err) + } + + b, err := os.ReadFile(filepath.Join(s.root, "index.json")) + if err != nil { + t.Fatal(err) + } + var idx struct { + Manifests []struct { + Digest string `json:"digest"` + } `json:"manifests"` + } + if err := json.Unmarshal(b, &idx); err != nil { + t.Fatalf("index.json unmarshal: %v", err) + } + count := 0 + for _, m := range idx.Manifests { + if m.Digest == want.String() { + count++ + } + } + if count != 1 { + t.Errorf("manifest descriptor for %s appears %d times, want 1 (dedup)", want, count) + } + + got, err := s.digestFor("local:tiny") + if err != nil { + t.Fatalf("digestFor: %v", err) + } + if got != want.String() { + t.Errorf("pin: got %s, want %s", got, want) + } +} + +// TestStoreLayoutRoundTripAfterDescriptorRemoval asserts the index.json our +// removeManifestDescriptor hand-marshals stays parseable by the canonical +// go-containerregistry reader: after rmi drops one of two images, the reader +// finds the survivor and no longer finds the removed manifest. This guards the +// switch from the layout package's RemoveDescriptors to our own durable +// atomic write. +func TestStoreLayoutRoundTripAfterDescriptorRemoval(t *testing.T) { + s := openTestStore(t) + imgA := buildImage(t, []string{"/a"}) + imgB := buildImage(t, []string{"/b"}) + digestA, err := imgA.Digest() + if err != nil { + t.Fatal(err) + } + digestB, err := imgB.Digest() + if err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:a", imgA); err != nil { + t.Fatal(err) + } + if _, err := s.addImage("local:b", imgB); err != nil { + t.Fatal(err) + } + + if _, err := s.rmi("local:a", false); err != nil { + t.Fatalf("rmi local:a: %v", err) + } + + // Re-open with crane's reader, independent of our write path. + p, err := layout.FromPath(s.root) + if err != nil { + t.Fatalf("layout.FromPath after descriptor removal: %v (index.json not canonical)", err) + } + ii, err := p.ImageIndex() + if err != nil { + t.Fatal(err) + } + im, err := ii.IndexManifest() + if err != nil { + t.Fatal(err) + } + for _, d := range im.Manifests { + if d.Digest == digestA { + t.Fatalf("removed manifest %s still present in index.json", digestA) + } + } + if _, err := p.Image(digestB); err != nil { + t.Fatalf("surviving manifest %s not readable after removal: %v", digestB, err) + } +} + +// TestWriteFileDurableAtomicAndLeavesPriorOnFailure pins writeFileDurable's two +// guarantees: a successful write replaces the file with exactly the new bytes, +// and a failed write (a read-only directory blocks the temp create) leaves any +// prior file untouched rather than truncating it. +func TestWriteFileDurableAtomicAndLeavesPriorOnFailure(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "f") + if err := writeFileDurable(path, []byte("first"), 0o644); err != nil { + t.Fatalf("writeFileDurable first: %v", err) + } + if b, err := os.ReadFile(path); err != nil || string(b) != "first" { + t.Fatalf("after first write = %q, err=%v; want first", b, err) + } + if err := writeFileDurable(path, []byte("second"), 0o644); err != nil { + t.Fatalf("writeFileDurable second: %v", err) + } + if b, err := os.ReadFile(path); err != nil || string(b) != "second" { + t.Fatalf("after second write = %q, err=%v; want second", b, err) + } + + if os.Getuid() == 0 { + t.Skip("running as root: a read-only dir does not block the temp create") + } + if err := os.Chmod(dir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + if err := writeFileDurable(path, []byte("third"), 0o644); err == nil { + t.Fatal("writeFileDurable into read-only dir succeeded, want failure") + } + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(path); err != nil || string(b) != "second" { + t.Fatalf("after failed write = %q, err=%v; want prior content second", b, err) + } +} diff --git a/scripts/ci/oci-cli-smoke.sh b/scripts/ci/oci-cli-smoke.sh new file mode 100755 index 00000000..fb49a43b --- /dev/null +++ b/scripts/ci/oci-cli-smoke.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Store-level CLI lifecycle smoke: everything short of `run` (which needs +# HVF), driving the built binary through the same user-facing flow the Go +# unit tests model in-process: pull, inspect, list, rmi by ref and by +# unique digest prefix, stale-temp-blob GC, and orphan-blob prune. +# +# With --unpack it also unpacks a rootfs and checks that a plain rmi +# reclaims the cold cache (the Linux CI job). Without it, every rmi runs +# the deliberate cache-free path where nothing was ever unpacked and no +# --force is involved (the hosted-macOS CI job, whose runners lack HVF but +# exercise the darwin binary). Needs jq and network. +# +# Usage: scripts/ci/oci-cli-smoke.sh [--unpack] +# shellcheck source=scripts/ci/oci-lib.sh +. "$(dirname "$0")/oci-lib.sh" +require_bin +command -v jq >/dev/null 2>&1 || { echo "jq is required" >&2; exit 2; } + +UNPACK=0 +case "${1:-}" in +--unpack) UNPACK=1 ;; +'') ;; +*) + echo "usage: $0 [--unpack]" >&2 + exit 2 + ;; +esac + +STORE="$(mktemp -d)" +# Clean up on every exit path; a failing phase must not leak a populated +# blob store into the runner's temp dir. +trap 'rm -rf "$STORE"' EXIT +REF=alpine:3 + +phase_pull_inspect_list() { + "$BIN" version + "$BIN" pull --store "$STORE" "$REF" + "$BIN" inspect --store "$STORE" --json "$REF" \ + | jq -e '(.os == "linux") and (.architecture == "arm64")' >/dev/null + "$BIN" list --store "$STORE" | expect_grep "$REF" +} + +# A cold unpacked cache is derived state: a plain `rmi` reclaims it as part +# of removing the image, no --force needed. --force is only for a +# `run --keep` cache (retained output) or a live run's volume, neither of +# which a bare `unpack` produces. See TestRmiDropsColdCacheWithoutForce in +# cmd/elfuse-oci/lifecycle_test.go. +phase_unpack_rmi() { + local digest cache refs + "$BIN" unpack --store "$STORE" "$REF" + digest="$("$BIN" images --store "$STORE" --json | jq -er '.[0].digest')" + cache="$STORE/rootfs/sha256/${digest#sha256:}" + test -e "$cache/bin/sh" || fail "unpacked rootfs has no /bin/sh" + + must_report 'dropped unpacked cache' 'plain rmi of an unpacked cache' \ + "$BIN" rmi --store "$STORE" "$REF" + test ! -e "$cache" || fail "unpacked cache survived a plain rmi" + refs="$("$BIN" list --store "$STORE")" + [ -z "$refs" ] || fail "list not empty after rmi" + + # Restore the pulled image for the digest-rmi phase below. + "$BIN" pull --store "$STORE" "$REF" +} + +# rmi resolves a unique digest prefix from the list table, and its GC also +# sweeps an aborted download's temp blob (digest name plus random suffix), +# which is unreachable by digest. +phase_digest_rmi_stale_blob() { + local digest layer_hex stale table short refs + digest="$("$BIN" images --store "$STORE" --json | jq -er '.[0].digest')" + layer_hex="$(jq -er '.layers[0].digest' \ + "$STORE/blobs/sha256/${digest#sha256:}" | sed 's/^sha256://')" + stale="$STORE/blobs/sha256/${layer_hex}1072211852" + printf 'stale temp blob' >"$stale" + + table="$("$BIN" list --store "$STORE")" + printf '%s\n' "$table" + short="$(printf '%s\n' "$table" | awk 'NR == 2 {print $2}')" + test -n "$short" || fail "list table has no digest column to resolve" + "$BIN" rmi --store "$STORE" "$short" + test ! -e "$stale" || fail "stale temp blob survived the rmi GC" + refs="$("$BIN" list --store "$STORE")" + [ -z "$refs" ] || fail "list not empty after digest rmi" +} + +# prune's reachability GC reclaims orphan blobs whether or not their name +# parses as a digest. +phase_orphan_prune() { + local valid malformed + valid="$(printf 'ci-prune-orphan' | sha256_hex)" + malformed="$STORE/blobs/sha256/${valid}9999" + printf 'orphan blob' >"$STORE/blobs/sha256/$valid" + printf 'malformed orphan blob' >"$malformed" + "$BIN" prune --store "$STORE" + test ! -e "$STORE/blobs/sha256/$valid" || fail "orphan blob survived prune" + test ! -e "$malformed" || fail "malformed orphan blob survived prune" + "$BIN" prune --store "$STORE" --cache +} + +phase_pull_inspect_list +if [ "$UNPACK" = 1 ]; then + phase_unpack_rmi +fi +phase_digest_rmi_stale_blob +phase_orphan_prune +assert_store_empty "$STORE" + +echo "cli smoke OK (unpack=$UNPACK)" diff --git a/scripts/ci/oci-lib.sh b/scripts/ci/oci-lib.sh new file mode 100755 index 00000000..654040d5 --- /dev/null +++ b/scripts/ci/oci-lib.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Shared helpers for the OCI CI test scripts (oci-run-smoke.sh, +# oci-lifecycle.sh, oci-cli-smoke.sh). Source this first; it enables strict +# mode and an ERR trap so any unguarded failure reports its file, line, and +# command. Without the trap, a bare `test`/`grep -q` failing under plain +# `set -e` kills the script with no output at all, leaving a CI failure with +# nothing to diagnose it from. +# +# Bash 3.2 compatible: macOS ships /bin/bash 3.2, so no mapfile, wait -n, +# or ${var,,} here or in the scripts that source this. + +# -E so the ERR trap fires inside functions too. +set -Eeuo pipefail +on_err() { + local s=$? where cmd=$BASH_COMMAND + where="${BASH_SOURCE[1]:-$0}:${BASH_LINENO[0]:-?}" + # ::error:: mirrors what the pre-extraction inline steps emitted, so + # failures still surface as annotations on the PR checks page. + if [ -n "${GITHUB_ACTIONS:-}" ]; then + echo "::error::$where: $cmd (exit $s)" + fi + echo "FAIL $where: $cmd (exit $s)" >&2 +} +trap on_err ERR + +OCI_CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$OCI_CI_DIR/../.." && pwd)" + +fail() { + if [ -n "${GITHUB_ACTIONS:-}" ]; then + echo "::error::$*" + fi + echo "FAIL: $*" >&2 + exit 1 +} + +# require_bin resolves the elfuse-oci binary into BIN, the same +# resolution scripts/oci-interop.sh uses. +require_bin() { + BIN="${ELFUSE_OCI_BIN:-$ROOT/build/elfuse-oci}" + if [ ! -x "$BIN" ]; then + echo "elfuse-oci not found at $BIN (set ELFUSE_OCI_BIN or run 'make build/elfuse-oci')" >&2 + exit 2 + fi +} + +# wait_for TIMEOUT_SEC DESC CMD... polls CMD twice a second until it +# succeeds, and fails loudly with DESC on timeout. Poll loops must live +# here: an inline loop under set -e dies silently on the first transient +# failure of a command substitution. +wait_for() { + local timeout="$1" desc="$2" tries i=0 + shift 2 + tries=$((timeout * 2)) + while [ "$i" -lt "$tries" ]; do + if "$@" >/dev/null 2>&1; then + return 0 + fi + sleep 0.5 + i=$((i + 1)) + done + fail "timed out after ${timeout}s waiting for: $desc" +} + +# reap_guest kills and waits the backgrounded guest recorded in $guest, +# if one is still alive. The scripts that background a guest call this +# from their EXIT trap: a failed phase must not leak the guest, which +# would keep holding its per-digest flock or a bound host loopback port +# and poison the next run on a persistent self-hosted runner. +reap_guest() { + if [ -n "${guest:-}" ] && kill -0 "$guest" 2>/dev/null; then + kill "$guest" 2>/dev/null || true + wait "$guest" 2>/dev/null || true + fi +} + +# expect_grep PATTERN asserts stdin contains the fixed string PATTERN. +# No -q: grep must drain the pipe, or the producer dies of SIGPIPE +# (exit 141) under pipefail when grep exits at the first match. +expect_grep() { + grep -F -- "$1" >/dev/null +} + +# must_report PATTERN DESC CMD... runs a command that must SUCCEED and +# report the fixed string PATTERN on stderr. The stderr is echoed through +# either way so the actual report is visible in the log. +must_report() { + local pattern="$1" desc="$2" errfile + shift 2 + errfile="$(mktemp)" + if ! "$@" 2>"$errfile"; then + cat "$errfile" >&2 + rm -f "$errfile" + fail "$desc: command failed" + fi + cat "$errfile" >&2 + if ! grep -F -- "$pattern" "$errfile" >/dev/null; then + rm -f "$errfile" + fail "$desc: stderr does not mention '$pattern'" + fi + rm -f "$errfile" +} + +# must_refuse PATTERN DESC CMD... runs a command that must FAIL with a +# stderr containing the fixed string PATTERN. The stderr is echoed either +# way so a wrong refusal message is visible in the log. +must_refuse() { + local pattern="$1" desc="$2" errfile + shift 2 + errfile="$(mktemp)" + if "$@" 2>"$errfile"; then + cat "$errfile" >&2 + rm -f "$errfile" + fail "$desc: command succeeded, expected a refusal" + fi + cat "$errfile" >&2 + if ! grep -F -- "$pattern" "$errfile" >/dev/null; then + rm -f "$errfile" + fail "$desc: refusal does not mention '$pattern'" + fi + rm -f "$errfile" +} + +# assert_store_empty STORE asserts a store retains no image state: no +# pinned refs, no blobs, no cs/ sparsebundle bundles, no plain rootfs +# caches, and no volume still mounted beneath it. On Linux the cs/ and +# mount checks pass vacuously. +assert_store_empty() { + local store="$1" refs + # Capture first: a failing `list` inside `[ -z "$(...)" ]` would be + # swallowed (the [ builtin's status is all set -e sees), letting a + # broken list pass the gate; a plain assignment propagates the status. + refs="$("$BIN" list --store "$store")" + [ -z "$refs" ] || fail "store not empty: list still shows pinned refs" + [ -z "$(ls "$store/blobs/sha256" 2>/dev/null || true)" ] \ + || fail "store not empty: blobs remain" + [ -z "$(find "$store/cs" -mindepth 2 -maxdepth 2 -type d 2>/dev/null || true)" ] \ + || fail "store not empty: cs/ bundle dirs remain" + [ -z "$(find "$store/rootfs/sha256" -mindepth 1 -maxdepth 1 2>/dev/null || true)" ] \ + || fail "store not empty: plain rootfs caches remain" + if mount | grep -F "$store" >/dev/null; then + fail "store not empty: a volume is still mounted under $store" + fi +} + +# sha256_hex prints the hex digest of stdin. Hosted macOS runners ship +# shasum but not coreutils sha256sum. +sha256_hex() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + else + shasum -a 256 | awk '{print $1}' + fi +} diff --git a/scripts/ci/oci-lifecycle.sh b/scripts/ci/oci-lifecycle.sh new file mode 100755 index 00000000..29319819 --- /dev/null +++ b/scripts/ci/oci-lifecycle.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Whole user-facing image lifecycle against a real HVF-booted guest, then +# the teardown guardrails, one function per phase: +# run_workloads pull/inspect/list, an --entrypoint override, +# and a glibc dynamically-linked python workload +# teardown_plain_rmi a plain rmi reclaims the cold unpacked cache +# with the image +# teardown_keep_guardrails rmi refuses to discard run --keep output +# without --force; --force detaches the still- +# attached volume and drops the bundle +# teardown_live_run_lock a live --plain-rootfs guest pins its cache +# against prune --cache --all and rmi until the +# guest exits; only this exercises the lock +# descriptor's ride through the exec into elfuse +# Needs macOS with Hypervisor.framework and network for the seed pull. +# +# Usage: ELFUSE_OCI_STORE= \ +# ELFUSE_OCI_SEED_STORE= \ +# [IMG=] scripts/ci/oci-lifecycle.sh +# shellcheck source=scripts/ci/oci-lib.sh +. "$(dirname "$0")/oci-lib.sh" +require_bin +STORE="${ELFUSE_OCI_STORE:?set ELFUSE_OCI_STORE to an ephemeral store directory}" +SEED="${ELFUSE_OCI_SEED_STORE:?set ELFUSE_OCI_SEED_STORE to the warm seed store directory}" +IMG="${IMG:-python:3.12-slim}" +export ELFUSE_OCI_STORE + +guest="" +on_exit() { + rc=$? + # A failed phase must not leak the backgrounded guest: it would keep + # holding the per-digest flock (and its sleep) and poison the next + # prune/rmi on a persistent self-hosted runner. + if [ -n "$guest" ] && kill -0 "$guest" 2>/dev/null; then + kill "$guest" 2>/dev/null || true + wait "$guest" 2>/dev/null || true + fi + exit "$rc" +} +trap on_exit EXIT + +# The teardowns leave the ephemeral store EMPTY (asserted), so the +# lifecycle store itself cannot persist between CI runs. Keep a warm seed +# store on the persistent disk instead and clone it in per phase (cp -Rc, +# APFS clonefile, the same trick as the fixture-cache restore): the pull +# in run_workloads then dedups every blob by digest and only the manifest +# HEAD/GET goes out, while the empty-store assertions stay meaningful. +seed_warm_store() { + ELFUSE_OCI_STORE="$SEED" "$BIN" pull "$IMG" + # GC blobs stranded in the seed when the tag moves to a new digest. + ELFUSE_OCI_STORE="$SEED" "$BIN" prune >/dev/null +} + +reseed() { + rm -rf "$STORE" + cp -Rc "$SEED" "$STORE" +} + +run_workloads() { + "$BIN" version + "$BIN" pull "$IMG" + "$BIN" inspect "$IMG" | expect_grep 'linux/arm64' + "$BIN" list | expect_grep "$IMG" + + local out + out="$("$BIN" run --entrypoint /usr/local/bin/python3 "$IMG" \ + -c 'import json,math; print(json.dumps({"pi":round(math.pi,5),"ok":True}))')" + printf 'guest said: %s\n' "$out" + [ "$out" = '{"pi": 3.14159, "ok": true}' ] || fail "python one-liner said '$out'" + + # A non-trivial application: concurrent SQLite writers (fcntl locking, + # fsync, WAL where the guest FS supports it) plus a 64-file + # write/read/checksum fan-out. Exercises far more of the dynamically- + # linked glibc guest than the one-liner above; prints a single sentinel + # token only on full success. + out="$("$BIN" run --entrypoint /usr/local/bin/python3 "$IMG" \ + -c "$(cat "$OCI_CI_DIR/oci-python-workload.py")")" + printf 'python workload said: %s\n' "$out" + [ "$out" = elfuse-oci-python-workload-ok ] || fail "python workload said '$out'" +} + +# The runs above left the cache warm and then detached on exit, so a plain +# rmi (no --force, no separate prune) must reclaim it with the image. +teardown_plain_rmi() { + must_report 'dropped unpacked cache' 'plain rmi of a cold cache' \ + "$BIN" rmi "$IMG" + assert_store_empty "$STORE" +} + +# run --keep retains the per-run clone with the volume still attached: rmi +# must refuse without --force, and rmi --force must detach the volume, +# drop the bundle, and GC the blobs. +teardown_keep_guardrails() { + reseed + "$BIN" run --keep --entrypoint /usr/local/bin/python3 "$IMG" -c 'pass' + must_refuse 'retained run --keep output; pass --force' \ + 'rmi of run --keep output without --force' \ + "$BIN" rmi "$IMG" + "$BIN" rmi --force "$IMG" + assert_store_empty "$STORE" +} + +# The published cache is rootfs/sha256/, renamed into place when the +# unpack completes. Never match the unpacker's .tmp- staging +# sibling: it is mid-write, and prune skips it via the digest lock. +published_plain_cache() { + # sed, not `head -n1`: head exits at the first line, and under pipefail + # a find killed by the resulting SIGPIPE would fail the whole pipeline + # even though a cache was found. sed -n 1p drains its input. + find "$STORE/rootfs/sha256" -mindepth 1 -maxdepth 1 -type d \ + ! -name '*.tmp-*' 2>/dev/null | sed -n 1p | grep . +} + +# A live --plain-rootfs guest must pin its cache against prune --cache +# --all and rmi, and its exit alone (no cleanup code, SIGKILL included) +# must free it: the kernel drops the flock with the process. +teardown_live_run_lock() { + reseed + "$BIN" run --plain-rootfs --entrypoint /bin/sleep "$IMG" 60 & + guest=$! + # The run takes its per-digest lock before unpacking, so once the + # cache dir has been published the guest provably holds the lock. + wait_for 120 'published plain rootfs cache' published_plain_cache + local plain_cache + plain_cache="$(published_plain_cache)" + + "$BIN" prune --cache --all + test -d "$plain_cache" || fail 'prune --cache --all reclaimed a live plain rootfs' + must_refuse 'in use by a live run' \ + 'rmi of an image whose plain rootfs hosts a live run' \ + "$BIN" rmi "$IMG" + + kill "$guest" 2>/dev/null || true + wait "$guest" || true + guest="" + # Guest gone means the kernel released the flock; prune reclaims the + # cache (lock file included) and the image can finally be removed. + "$BIN" prune --cache --all + test ! -e "$plain_cache" || fail 'plain rootfs cache survived prune after guest exit' + "$BIN" rmi "$IMG" + assert_store_empty "$STORE" + + # Nothing left to reclaim. + "$BIN" prune --cache +} + +seed_warm_store +reseed +run_workloads +teardown_plain_rmi +teardown_keep_guardrails +teardown_live_run_lock + +echo "lifecycle OK" diff --git a/scripts/ci/oci-python-workload.py b/scripts/ci/oci-python-workload.py new file mode 100644 index 00000000..6c1c044d --- /dev/null +++ b/scripts/ci/oci-python-workload.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Non-trivial guest workload for the elfuse-oci OCI lifecycle CI. + +Exercises more of a real dynamically-linked glibc guest than a one-line print: +concurrent SQLite writers (fcntl locking, fsync, and, when the guest FS +supports it, WAL mmap), plus a filesystem fan-out whose written content is +read back and checksummed. On full success it prints a single sentinel token +the workflow asserts on; any failure exits non-zero with a diagnostic. + +Self-contained (stdlib only) so it runs under the image's bundled Python with +no network or extra packages, and is passed to the guest via `python3 -c`. +""" + +import hashlib +import os +import sqlite3 +import sys +import threading + +DB = "/tmp/elfuse-workload.db" +TREE = "/tmp/elfuse-workload-tree" +THREADS = 8 +PER_THREAD = 1000 +FANOUT = 8 + + +def setup_db(): + con = sqlite3.connect(DB) + try: + # WAL exercises the guest FS's shared-memory index (mmap) and is the + # more demanding path; if the FS cannot back it, SQLite reports a + # different mode and the concurrent-writer count check below still + # validates fcntl locking and durable commits under rollback journal. + con.execute("PRAGMA journal_mode=WAL") + con.execute( + "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, " + "tid INTEGER NOT NULL, n INTEGER NOT NULL)" + ) + con.commit() + finally: + con.close() + + +def worker(tid): + con = sqlite3.connect(DB, timeout=60) + try: + con.execute("PRAGMA busy_timeout=60000") + for n in range(PER_THREAD): + con.execute("INSERT INTO t (tid, n) VALUES (?, ?)", (tid, n)) + con.commit() + finally: + con.close() + + +def db_count(): + con = sqlite3.connect(DB, timeout=60) + try: + (count,) = con.execute("SELECT COUNT(*) FROM t").fetchone() + return count + finally: + con.close() + + +def content(i, j): + return "elfuse-workload-%d-%d\n" % (i, j) + + +def fs_fanout_ok(): + for i in range(FANOUT): + for j in range(FANOUT): + d = os.path.join(TREE, str(i), str(j)) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, "f"), "w") as fh: + fh.write(content(i, j)) + fh.flush() + os.fsync(fh.fileno()) + read_back = [] + for i in range(FANOUT): + for j in range(FANOUT): + with open(os.path.join(TREE, str(i), str(j), "f")) as fh: + read_back.append(fh.read()) + expected = [content(i, j) for i in range(FANOUT) for j in range(FANOUT)] + got = hashlib.sha256() + for p in sorted(read_back): + got.update(p.encode()) + want = hashlib.sha256() + for p in sorted(expected): + want.update(p.encode()) + return got.hexdigest() == want.hexdigest() + + +def main(): + setup_db() + threads = [threading.Thread(target=worker, args=(i,)) for i in range(THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + + count = db_count() + if count != THREADS * PER_THREAD: + print( + "sqlite row count %d != %d (concurrent writers lost rows)" + % (count, THREADS * PER_THREAD), + file=sys.stderr, + ) + sys.exit(1) + + if not fs_fanout_ok(): + print("filesystem fan-out checksum mismatch", file=sys.stderr) + sys.exit(1) + + print("elfuse-oci-python-workload-ok") + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/oci-run-smoke.sh b/scripts/ci/oci-run-smoke.sh new file mode 100755 index 00000000..2173dd60 --- /dev/null +++ b/scripts/ci/oci-run-smoke.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# End-to-end smoke for the default `run` path: pull an image, provision the +# case-sensitive sparsebundle, COW-clone it, boot the guest under HVF, and +# check output, exit status, and per-run isolation. Needs macOS with +# Hypervisor.framework; network only when the store is cold (pull is +# idempotent per digest, so a warm store skips the blob downloads). +# +# Usage: ELFUSE_OCI_STORE= scripts/ci/oci-run-smoke.sh +# shellcheck source=scripts/ci/oci-lib.sh +. "$(dirname "$0")/oci-lib.sh" +require_bin +: "${ELFUSE_OCI_STORE:?set ELFUSE_OCI_STORE to the store directory to use}" + +out="$("$BIN" run alpine:3 /bin/echo elfuse-oci-ci-ok)" +printf 'guest said: %s\n' "$out" +printf '%s\n' "$out" | expect_grep elfuse-oci-ci-ok + +# The guest's exit status must propagate through the runner untouched; +# cleanup errors must never win over it. +code=0 +"$BIN" run alpine:3 /bin/sh -c 'exit 7' || code=$? +[ "$code" -eq 7 ] || fail "guest exit status: got $code, want 7" + +# Non-trivial multi-stage shell pipeline: generate a 200k-line file, +# gzip it, decompress and byte-compare the round-trip, then checksum +# the original against a constant precomputed from the exact same +# deterministic input. Exercises fork/exec pipelines, pipes, and +# coreutils gzip/sha256sum in the guest. debian:stable-slim rather +# than alpine because the bare-name PATH search must resolve every +# candidate inside the rootfs: the sysroot resolver falls back to +# the host for absent absolute paths, and alpine ships gzip only at +# /bin/gzip while its PATH tries /usr/bin first, which on a macOS +# host holds an incompatible Mach-O gzip. Debian is usr-merged, so +# each searched binary exists at /usr/bin inside the image. +want=5af7b95208fdcff454bab3f5eddf567a688a3796c703d4fef91072e38645c062 +got="$("$BIN" run debian:stable-slim /bin/sh -c 'set -e + seq 1 200000 > /tmp/data.txt + gzip -c /tmp/data.txt > /tmp/data.gz + gunzip -c /tmp/data.gz | cmp - /tmp/data.txt + sha256sum /tmp/data.txt | cut -d" " -f1')" +printf 'debian pipeline sha256: %s\n' "$got" +[ "$got" = "$want" ] || fail "debian pipeline sha256: got $got, want $want" + +# Per-run COW clone isolation: the previous run's /tmp writes must not be +# visible to a fresh run of the same digest. Exact match, not a substring: +# a diagnostic quoting the failed command would also contain the token. +out="$("$BIN" run debian:stable-slim /bin/sh -c 'test ! -e /tmp/data.txt && echo isolated-ok')" +[ "$out" = isolated-ok ] || fail "isolation check said '$out'" + +# When a pinned tag moves, the re-pin strands the old digest's blobs; GC +# them so a persistent store stays bounded. +"$BIN" prune >/dev/null + +echo "run smoke OK" diff --git a/scripts/oci-interop.sh b/scripts/oci-interop.sh new file mode 100755 index 00000000..f23a0607 --- /dev/null +++ b/scripts/oci-interop.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# OCI image-layout conformance + cross-tool interop for the elfuse-oci store. +# +# Treats the on-disk store as the contract: after `elfuse-oci pull`, the store +# must be a valid OCI image-layout that other tools can read and that agrees +# with registry truth on the manifest digest. +# +# Hard assertions (always available, gate the script): +# - oci-layout has imageLayoutVersion 1.0.0; index.json is schemaVersion 2 +# with a manifest descriptor matching the pinned digest. +# - the manifest blob parses and references a config blob + >=1 layer blob, +# all present under blobs/sha256/. +# - if `crane` is installed, the store's pinned manifest digest matches +# registry truth for the selected platform. +# +# Best-effort third-party reads (run when present; fatal on failure so CI can +# promote them once the invocation is confirmed): +# - skopeo inspect --raw oci::@ reads our layout +# - umoci list --layout parses our layout +# +# Usage: scripts/oci-interop.sh [STORE_DIR] +# Env: ELFUSE_OCI_BIN (path to elfuse-oci; default build/elfuse-oci) +# FIXTURES (space-separated refs; default "alpine:3 busybox") +# PLAT_OS / PLAT_ARCH (platform to pull and compare; default linux/arm64) +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +BIN="${ELFUSE_OCI_BIN:-$ROOT/build/elfuse-oci}" + +# A store passed as $1 belongs to the caller; an auto-created one is ours to +# reclaim, including on a mid-run failure. +if [ -n "${1:-}" ]; then + STORE="$1" + STORE_OWNED=0 +else + STORE="$(mktemp -d -t elfuse-interop.XXXXXX)" + STORE_OWNED=1 +fi +# Absolutize the store path: the skopeo view symlinks blobs -> "$STORE/blobs", +# a target resolved relative to the (separate) view dir, so a relative store arg +# would dangle. skopeo/umoci oci: transports also want an absolute layout path. +case "$STORE" in + /*) ;; + *) STORE="$(pwd)/$STORE" ;; +esac + +# Temp files/dirs created per fixture. An EXIT trap reclaims them plus the +# auto-created store, so a failure (set -e) or fail() no longer orphans the +# store's pulled blobs or the per-iteration scratch. Bash 3.2 (macOS) errors on +# "${arr[@]}" for an empty array under set -u, so guard on length. +TMPFILES=() +cleanup() { + if [ "${#TMPFILES[@]}" -gt 0 ]; then + rm -rf "${TMPFILES[@]}" + fi + if [ "$STORE_OWNED" = 1 ]; then + rm -rf "$STORE" + fi + return 0 +} +trap cleanup EXIT + +FIXTURES="${FIXTURES:-alpine:3 busybox}" +# One platform drives BOTH the pull and the registry-truth comparison; setting +# it only on the crane side would fail the comparison against a correctly +# pulled default-platform image. +PLAT_OS="${PLAT_OS:-linux}" +PLAT_ARCH="${PLAT_ARCH:-arm64}" + +have() { command -v "$1" >/dev/null 2>&1; } + +if [ ! -x "$BIN" ]; then + echo "elfuse-oci not found at $BIN (set ELFUSE_OCI_BIN or run 'make build/elfuse-oci')" >&2 + exit 2 +fi +have jq || { echo "jq is required" >&2; exit 2; } + +echo "store: $STORE" +echo "bin: $BIN" +mkdir -p "$STORE" + +# Hard failures exit directly via `fail`. +fail() { echo "FAIL: $*" >&2; exit 1; } + +for ref in $FIXTURES; do + echo + echo "=== $ref ===" + + # Pull into the store. + "$BIN" pull --store "$STORE" --platform "$PLAT_OS/$PLAT_ARCH" "$ref" >/dev/null + digest="$(jq -er --arg ref "$ref" '.[$ref]' "$STORE/refs.json" \ + || fail "refs.json has no pin for $ref")" + echo "pinned manifest digest: $digest" + + # --- Conformance: oci-layout --- + [ "$(jq -r .imageLayoutVersion "$STORE/oci-layout")" = "1.0.0" ] \ + || fail "oci-layout imageLayoutVersion != 1.0.0" + + # --- Conformance: index.json has our manifest descriptor --- + [ "$(jq -r .schemaVersion "$STORE/index.json")" = "2" ] \ + || fail "index.json schemaVersion != 2" + jq -e --arg d "$digest" 'any(.manifests[]; .digest == $d)' \ + "$STORE/index.json" >/dev/null \ + || fail "index.json has no manifest descriptor with digest $digest" + + # --- Conformance: manifest blob parses and references config + layers --- + hex="${digest#sha256:}" + manifest_path="$STORE/blobs/sha256/$hex" + [ -f "$manifest_path" ] || fail "manifest blob missing at $manifest_path" + config_digest="$(jq -er .config.digest "$manifest_path" \ + || fail "manifest blob is not a valid image manifest (no .config.digest)")" + config_hex="${config_digest#sha256:}" + [ -f "$STORE/blobs/sha256/$config_hex" ] \ + || fail "config blob missing for $config_digest" + layer_count="$(jq '.layers | length' "$manifest_path")" + [ "$layer_count" -ge 1 ] || fail "manifest has no layers" + # Every layer blob must exist on disk. + while IFS= read -r ld; do + lx="${ld#sha256:}" + [ -f "$STORE/blobs/sha256/$lx" ] || fail "layer blob missing for $ld" + done < <(jq -r '.layers[].digest' "$manifest_path") + echo "ok: layout valid, $layer_count layer(s), config + manifest + layers present" + + # --- Interop: registry truth via crane (if installed) --- + # `elfuse-oci pull` uses crane.Pull(WithPlatform), which resolves a manifest + # list to the per-arch child manifest and pins THAT digest. So for a + # multi-arch ref, `crane digest` (the list digest) legitimately differs; we + # resolve the platform child from `crane manifest` and compare that. + if have crane; then + plat_os="$PLAT_OS"; plat_arch="$PLAT_ARCH" + top="$(crane manifest "$ref")" + if [ "$(printf '%s' "$top" | jq '.manifests // [] | any(.platform != null)')" = "true" ]; then + # first(...) keeps the comparison single-valued if several entries + # match the platform (e.g. multiple variants), and the annotation + # filter drops BuildKit attestation manifests, which are not + # runnable images. crane.Pull resolves the same first match. + reg_digest="$(printf '%s' "$top" | jq -er --arg os "$plat_os" --arg ar "$plat_arch" \ + 'first(.manifests[] + | select(.platform.os==$os and .platform.architecture==$ar + and (.annotations["vnd.docker.reference.type"] != "attestation-manifest")) + | .digest)' \ + || fail "crane manifest list for $ref has no $plat_os/$plat_arch entry")" + else + reg_digest="$(crane digest "$ref")" + fi + [ "$reg_digest" = "$digest" ] \ + || fail "crane ($reg_digest) != store pin ($digest) for $ref [$plat_os/$plat_arch]" + echo "ok: crane agrees on manifest digest ($plat_os/$plat_arch)" + else + echo "info: crane not installed; skipping registry-truth comparison" + fi + + # --- Interop: skopeo reads our layout (if installed) --- + if have skopeo; then + # skopeo's oci: transport addresses an image by ref-name annotation or + # (since skopeo 1.14) by @source-index. Our layout intentionally + # carries no ref-name annotations, and distro skopeo is often older + # than 1.14, so neither form is portable. Present a single-manifest + # view instead (the same oci-layout and blobs, index.json filtered + # to the pinned descriptor), which every skopeo version resolves + # with a bare oci: reference. + skopeo_view="$(mktemp -d -t elfuse-skopeo-view.XXXXXX)" + TMPFILES+=("$skopeo_view") + cp "$STORE/oci-layout" "$skopeo_view/oci-layout" + jq --arg d "$digest" \ + '.manifests = [.manifests[] | select(.digest == $d)]' \ + "$STORE/index.json" >"$skopeo_view/index.json" + ln -s "$STORE/blobs" "$skopeo_view/blobs" + skopeo_ref="oci:$skopeo_view" + skopeo_raw="$(mktemp -t elfuse-skopeo-raw.XXXXXX)" + skopeo_err="$(mktemp -t elfuse-skopeo-err.XXXXXX)" + TMPFILES+=("$skopeo_raw" "$skopeo_err") + if skopeo inspect --raw "$skopeo_ref" >"$skopeo_raw" 2>"$skopeo_err"; then + jq -e '(.schemaVersion == 2) and (.config.digest | type == "string") and + (.layers | type == "array") and (.layers | length >= 1)' \ + "$skopeo_raw" >/dev/null \ + || fail "skopeo read $skopeo_ref but did not return an image manifest" + echo "ok: skopeo inspect --raw $skopeo_ref read the pinned manifest" + else + echo "skopeo stderr: $(cat "$skopeo_err")" >&2 + fail "skopeo could not read $skopeo_ref" + fi + else + echo "info: skopeo not installed; skipping skopeo interop" + fi + + # --- Interop: umoci parses our layout (if installed) --- + # The layout is valid even when no descriptors carry ref-name annotations. + # elfuse keeps refs.json as its own lookup table for full pull references; + # umoci list may therefore show zero tags, but it must still parse. + if have umoci; then + umoci_out="$(mktemp -t elfuse-umoci-list.XXXXXX)" + umoci_err="$(mktemp -t elfuse-umoci-err.XXXXXX)" + TMPFILES+=("$umoci_out" "$umoci_err") + if umoci list --layout "$STORE" >"$umoci_out" 2>"$umoci_err"; then + echo "ok: umoci list --layout parsed our layout (tags: $(wc -l <"$umoci_out" | tr -d ' '))" + else + echo "umoci stderr: $(cat "$umoci_err")" >&2 + fail "umoci could not parse layout $STORE" + fi + else + echo "info: umoci not installed; skipping umoci interop" + fi +done + +echo +echo "ALL OK: store is a valid OCI image-layout and interops with available tools" +# The EXIT trap reclaims the auto-created store and any per-iteration scratch. From dc539cc2e59452b04ce613847e3aa58ebd696dd1 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 15 Jul 2026 23:38:59 +0200 Subject: [PATCH 20/22] Document OCI image support Cover the two-binary model in README and docs: usage.md documents the elfuse-oci commands and flags, testing.md the offline/CI validation split, internals.md the host-literal path fallback semantics, and oci-design.md records the design rationale (the C/Go boundary, the image-layout store with its refs.json pin table, layer application, run paths, and lifecycle GC), plus an explicit scope-and-limitations accounting of which OCI features are and are not implemented. oci-design.md also records the concurrency model: store metadata is lock-serialized, per-digest sparsebundle state is coordinated by the attach.lock/run.lock pair, and the plain digest-keyed rootfs cache is guarded by a sibling per-digest lock a run holds across the exec into elfuse, so prune skips and rmi refuses a cache a live guest still uses. usage.md notes the resolv.conf fallback nameserver and its split-DNS implication. README states the positioning up front: OCI images are consumed as a distribution vehicle for Linux root filesystems, replacing hand-built --sysroot trees, and the non-isolation limitations (host-path fallback, shared network identity, PID space, and clock) are called out explicitly rather than implied. --- README.md | 43 ++++++- docs/internals.md | 7 + docs/oci-design.md | 311 +++++++++++++++++++++++++++++++++++++++++++++ docs/testing.md | 106 +++++++++++++++ docs/usage.md | 185 +++++++++++++++++++++++++++ 5 files changed, 650 insertions(+), 2 deletions(-) create mode 100644 docs/oci-design.md diff --git a/README.md b/README.md index 6b7f7148..cd3ee18e 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ boot-time overhead those tools impose. - Xcode Command Line Tools, `clang`, `codesign`, and GNU `make` - GNU `objcopy` or `llvm-objcopy` - Hypervisor entitlement: `com.apple.security.hypervisor` +- Go, for building the `elfuse-oci` OCI CLI To build only (`make elfuse`) without running tests, just the Xcode Command Line Tools and `objcopy` (`brew install binutils`) suffice. @@ -104,11 +105,43 @@ state. The build signs `build/elfuse` before use. Override the signing identity with `SIGN_IDENTITY="Developer ID ..."` when needed. +## OCI Images + +OCI images are handled by `elfuse-oci`, a Go companion binary that owns +the whole image lifecycle and invokes `elfuse` purely as the runtime. The point +is distribution, not containment: an image is consumed as a packaged Linux root +filesystem, so a dynamically-linked guest needs no hand-built `--sysroot` tree. +The execution model is the one every elfuse run uses (Linux ELF, elfuse syscall +translation, macOS kernel), with no VM and no Linux kernel in the loop. + +This is not a container runtime. The image rootfs is the guest's root, not an +isolation boundary: an absolute guest path absent from the rootfs falls back to +the literal host path, and the guest shares the host's network identity, PID +space, and clock. There are no namespaces, cgroups, port mapping, or daemon. +The target is CLI tooling, compilers, and scripting userspace; +[docs/oci-design.md](docs/oci-design.md#scope-and-limitations) lists exactly +which OCI features are implemented. + +```sh +make elfuse elfuse-oci + +build/elfuse-oci pull alpine:3 +build/elfuse-oci run alpine:3 /bin/sh -c 'echo hello from elfuse' +``` + +Images are stored under `$ELFUSE_OCI_STORE`, or `~/.local/share/elfuse/oci` +by default. On macOS, `run` uses a case-sensitive APFS sparsebundle and a +per-run copy-on-write rootfs clone so normal APFS case folding does not +corrupt Linux filenames. + +See [docs/usage.md](docs/usage.md#oci-images) for commands and flags, and +[docs/oci-design.md](docs/oci-design.md) for the implementation model. + ## Documentation - [docs/usage.md](docs/usage.md): command-line options, x86_64 via - Rosetta, dynamic linking via `--sysroot`, and attaching `gdb` / - `lldb` to the built-in stub. + Rosetta, dynamic linking via `--sysroot`, OCI images, and attaching + `gdb` / `lldb` to the built-in stub. - [docs/testing.md](docs/testing.md): build prerequisites, the `make check` flow, the QEMU and Rosetta cross-check matrices, and fixture handling. @@ -119,6 +152,9 @@ The build signs `build/elfuse` before use. Override the signing identity with reference -- runtime lifecycle, HVF constraints, EL1 shim and HVC protocol, page-table splitting, syscall translation tables, threads / futex, fork / clone IPC, signals, ptrace, and the GDB stub. +- [docs/oci-design.md](docs/oci-design.md): how elfuse-oci, the image + store, layer unpacker, sparsebundle run path, and lifecycle commands + fit into elfuse. ## Build And Validation @@ -129,6 +165,7 @@ make elfuse # build and codesign build/elfuse make check # quick unit suite + BusyBox applet smoke make test-gdbstub # debugger integration make test-matrix # cross-check elfuse against QEMU on the same corpus +make oci-test # elfuse-oci unit and conformance tests make lint # clang-tidy ``` @@ -147,6 +184,8 @@ do. - Linux kernel features that have no user-space-syscall analog: namespaces, cgroups, kernel modules, eBPF, `io_uring`, KVM, perf events. +- Docker-compatible container runtime features such as port mapping, + detached containers, `docker exec`, image build/push, and daemon APIs. - Intel Macs. Apple Silicon only (M1 and later). - Hosting a VM from inside a guest. The guest cannot use HVF or KVM. - One guest process tree per `elfuse` host process. HVF allows one VM diff --git a/docs/internals.md b/docs/internals.md index c79afc3b..7befb776 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -879,6 +879,13 @@ How it works: disagree about where a path lives. [filenames.md](filenames.md) covers how a name is spelled once it lands on the sysroot volume. +That dispatch applies to image-launched guests as well: the sysroot is a root, +not a boundary, and `elfuse-oci run` inherits it unchanged. A guest `PATH` +search whose candidate is absent under the sysroot can therefore resolve to a +host binary; `run` compensates by guaranteeing the guest a `PATH` (Docker's +conventional default when the image env provides none), and callers can +reorder the search with `--env PATH=...`. + 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 diff --git a/docs/oci-design.md b/docs/oci-design.md new file mode 100644 index 00000000..045919d1 --- /dev/null +++ b/docs/oci-design.md @@ -0,0 +1,311 @@ +# OCI Image Support Design + +This is the reference for how elfuse consumes OCI images without becoming a +container runtime. It is the single source of truth for what is and is not +implemented. For day-to-day commands and flags, see +[usage.md](usage.md#oci-images); for validation targets, see +[testing.md](testing.md). + +## Model + +elfuse uses the OCI image format for distribution and filesystem packaging +only. It does not implement the OCI runtime spec. The goal is narrow: pull an +image, unpack its layers into a Linux rootfs, resolve the image runtime +configuration, and launch the configured program through the existing +`elfuse --sysroot` path. The guest is a single elfuse process translating +Linux syscalls to Darwin, not an isolated container. + +## Scope And Limitations + +The OCI ecosystem is three specifications plus a set of conventions. elfuse +implements the consumer side of the image format and nothing else. + +Implemented: + +- the on-disk OCI image-layout store (`oci-layout`, `index.json`, + content-addressed blobs) plus an elfuse-specific `refs.json` pin file; +- pulling with `go-containerregistry` through the ambient default keychain, + with `--platform` selection resolved against manifest lists; +- layer application: whiteouts and opaque directories, hardlinks and symlinks + (absolute targets rewritten rootfs-relative), setuid/setgid/sticky bits, and + gzip or zstd compression, all applied under `os.OpenRoot`; +- image-config resolution of `Entrypoint`, `Cmd`, `Env`, `User`, and + `WorkingDir` with Docker-style precedence; +- local lifecycle management (`list`, `rmi`, `prune`) with reachability-based + garbage collection and macOS sparsebundle cache handling; +- runtime injection of `/etc/resolv.conf`, `/etc/hosts`, and `/etc/hostname` + into the run rootfs. + +Out of scope: + +- **OCI runtime spec.** No runtime bundle or `config.json`, and no namespaces, + cgroups, seccomp, capabilities, hooks, or mounts/volumes. The image rootfs is + the guest's root but not a boundary: an absolute guest path absent from the + rootfs falls back to the host filesystem (see [Run Paths](#run-paths)), and + the guest shares the host network identity, PID space, and clock. +- **Distribution write side.** Pull only: no `push`, no image building, and no + `login`. Credentials come from the ambient default keychain (for example an + existing Docker credential store); elfuse adds none of its own. Credential + resolution is time-bounded, so a wedged helper (such as a `credsStore` whose + backing app is not running) fails the pull with an explanation rather than + hanging it; `DOCKER_CONFIG` pointing at a config without that helper forces an + anonymous pull. +- **Network and port isolation.** The guest shares the host network. There is + no port mapping, and `ExposedPorts` has no effect. +- **Ignored image-config fields.** `Volumes`, `ExposedPorts`, `Healthcheck`, + `StopSignal`, and `Labels` are accepted but have no runtime effect. +- **Daemon conveniences.** No daemon, no `exec` or `attach`, no detached + containers; each `run` is one foreground guest process. +- **Non-Linux images.** Only `linux` images, on the platforms the runtime + executes: `arm64` natively, `amd64` via Rosetta. +- **Device nodes in layers.** Character, block, and FIFO entries are rejected + rather than materialized; the C runtime synthesizes the supported `/dev` and + `/proc` entries at run time. +- **Supply-chain verification.** Content is verified against manifest digests, + but there is no signature or attestation checking (cosign, notation) and no + policy engine. + +A workload that needs any of the above needs a container runtime, not an ELF +runner that consumes OCI images. + +## Library Boundary + +`elfuse-oci` imports `go-containerregistry` for registry transport and +image-layout blob access, and owns everything above that layer: durable store +writes, layer application, runtime-configuration resolution, and lifecycle +management. The obvious alternative (shelling out to `skopeo` for pull and +delete plus `umoci` for unpack; both install cleanly on macOS) was evaluated +empirically: a prototype wrapper over the two tools was driven through this +repository's entire OCI CI suite unmodified. + +The wrapper passed the suite, but only by reimplementing on top of the tools +every macOS and lifecycle behavior the CI asserts: + +- `umoci gc` aborts on a stale temporary blob whose name is not a valid + digest (the exact debris prune must sweep), so the reachability sweep had + to be rebuilt; +- umoci stores absolute symlink targets verbatim, so the rootfs-relative + rewrite of [Layer Application](#layer-application) had to be rebuilt; +- no copy tool tolerates the setgid `chmod` `EPERM` that group inheritance + produces on macOS volumes, so the special-bit degrade had to be rebuilt; +- no tool models unpacked caches, sparsebundles, or per-run clones, so the + whole [Lifecycle](#lifecycle) layer had to be rebuilt. + +What the tools cover well (registry transport and whiteout-correct layer +application) is the part this design already delegates to +`go-containerregistry` or that changes rarely. Shelling out would also give +up crash-durable store writes (both tools write `index.json` with plain +writes), the flock-based store and live-run locking, the device-node +rejection policy (umoci materializes empty placeholder files instead), and +the single-binary install. The boundary therefore stays put: import +`go-containerregistry` as a library, own every behavior the CI asserts, and +keep skopeo and umoci as independent readers that cross-check the store (see +[Validation](#validation)). + +## Boundary Between C And Go + +There are two binaries with a one-way dependency: `elfuse-oci` calls +`elfuse`, never the reverse, so the runtime stays useful on its own as a plain +ELF runner. + +`build/elfuse` (C) is purely the Linux syscall-to-Darwin runtime, with no OCI +awareness. It provides the positional ELF launcher, the `--user`/`--workdir`/ +`--env`/`--clear-env` launch flags, and the synthetic `/proc` and `/dev` +entries served to every guest. + +`build/elfuse-oci` (Go) is the only OCI entry point. It pulls images, +maintains the image-layout store, inspects and unpacks stored images, resolves +the runtime configuration, prepares the runtime `/etc` files, and invokes +`elfuse` with the resolved launch flags. It locates `elfuse` as a sibling of +its own executable; `$ELFUSE_BIN` overrides the location for tests and wrapper +scripts. + +## Store + +The store is an OCI image-layout directory plus one pin file: + +```text +/ + oci-layout + index.json + blobs/sha256/ + refs.json +``` + +`oci-layout`, `index.json`, and `blobs/` are the standard layout. `refs.json` +maps each original image reference to the manifest digest elfuse pinned at pull +time. It is elfuse-specific lookup metadata; OCI readers parse the layout +through `index.json` and the content-addressed blobs without it. Keeping it +separate preserves the exact pull reference (`docker.io/library/alpine:3`, +`name@sha256:...`). + +The default store is `$ELFUSE_OCI_STORE` when set, otherwise +`~/.local/share/elfuse/oci`. + +## Pull And Platform Selection + +`pull` defaults to `linux/arm64`, matching the native Apple Silicon guest path. +`--platform os/arch[/variant]` selects another image, such as `linux/amd64` for +a Rosetta-backed guest. When a reference is a manifest list, `pull` fetches and +pins the selected platform's child manifest, so the pinned digest can differ +from the top-level manifest-list digest that registry tools report. + +## Layer Application + +Extraction runs under `os.OpenRoot`, so every layer path resolves relative to +the target rootfs and cannot escape via `..` or a symlink. The unpacker +implements the layer behavior common images need: + +- regular files, directories, symlinks, and hardlinks; +- Docker/OCI whiteouts and opaque directory markers; +- permission bits plus setuid, setgid, and sticky, finalized with an explicit + chmod so layer modes survive a restrictive host umask. Where an unprivileged + host rejects the setuid/setgid chmod, the unpacker drops the special bits + (with a warning) and continues rather than aborting: the rootfs is owned by + the invoking user, so those bits could not be honored there anyway. This + fires on macOS when the unpacked file's inherited group is one the invoking + user is not in (a new file takes its parent directory's group, e.g. wheel + under `/tmp`), which is what lets Debian-family images and their shadow suite + (`chage`, `passwd`, ...) unpack at all. Linux keeps the bits (an owned-file + chmod succeeds), so the C runtime's setuid-exec support still applies where + they survive; +- absolute symlink targets rewritten to rootfs-relative links; +- rejection of special files elfuse does not materialize from layers. + +Runtime `/dev` and `/proc` entries are not unpacked from layers; the C runtime +synthesizes the supported ones when the guest opens them. + +## Run Paths + +On macOS the default `run` path uses a case-sensitive APFS sparsebundle per +manifest digest, with the unpacked base rootfs inside it. Each run makes an +APFS copy-on-write clone of the base tree, launches elfuse against the clone, +removes the clone, and detaches the sparsebundle once the last concurrent run +of that digest exits. This protects case-sensitive Linux filenames on normal +(case-insensitive) macOS volumes and keeps repeated runs isolated from +image-layer mutations. + +The plain-rootfs path stays available with `--plain-rootfs` or an explicit +`--rootfs`. It uses a regular directory and execs `elfuse` directly, which is +useful for debugging and for non-Darwin operation. The default sparsebundle +sits on a volume the invoking user created, so unpacked files inherit that +user's own group and the setuid/setgid chmod above succeeds; the special-bit +degrade is therefore mostly a plain-rootfs concern, where `--rootfs` can point +at a directory whose group the user is not in. + +Before launch, `run` writes runtime `/etc/resolv.conf`, `/etc/hosts`, and +`/etc/hostname` into the run rootfs so DNS, localhost, and hostname lookups +work without network namespacing. The writes go through `os.OpenRoot` and +replace any existing entry, so an image that ships one of these names as a +symlink (including a symlinked `/etc`) cannot redirect the write outside the +rootfs. + +`run` launches `elfuse --sysroot ` with the host-literal fallback in +place: an absolute guest path absent under the rootfs resolves to the literal +host path. The rootfs is a root, not a boundary; elfuse favors transparency +over isolation, so an image-launched guest keeps the same filesystem view a +plain positional `elfuse ` run has (a development workflow that +deliberately reaches host resources such as `/etc/resolv.conf` or files under +the user's home). The guest-private prefixes are the exception: `/tmp`, +`/var/tmp`, and `.ccache` paths always resolve inside the rootfs and never +fall back to the host (see `sysroot.md`). The practical caveat is +the guest `PATH` search: with an image `Env` `PATH` putting `/usr/bin` before +`/bin`, a bare `gzip` in an image that ships it only at `/bin/gzip` resolves to +the host's incompatible `/usr/bin/gzip` (a macOS Mach-O) first. Prefer +usr-merged images, invoke by absolute path, or reorder the search with +`--env PATH=...`. When the merged environment carries no `PATH`, `run` appends +Docker's conventional default so the guest always has a search path. + +## Runtime Configuration + +`elfuse-oci` resolves the image configuration before calling `elfuse`. + +Command resolution follows Docker-style rules: + +- `--entrypoint` replaces the image Entrypoint and drops the image Cmd; +- without `--entrypoint`, CLI arguments after the reference replace the image + Cmd while preserving the image Entrypoint; +- with neither override, image Entrypoint and Cmd are concatenated; +- an empty final command is an error; +- a relative path command (one containing a slash, like `./server`) resolves + against the working directory; a bare name resolves against the merged + `PATH` inside the image rootfs, and not finding it there is an error. + Unlike Docker, the resolved absolute path is also what the guest sees as + `argv[0]` (elfuse's positional argument names both the binary to load and + the guest argv head). + +Environment resolution starts from image `Env`, or from an empty environment +under `--clear-env`. Each `--env KEY=VALUE` sets or replaces a value; a bare +`--env KEY` imports `KEY` from the host when present. + +User resolution accepts numeric `UID[:GID]` and symbolic `name[:group]`. +Symbolic names resolve against the unpacked rootfs `/etc/passwd` and +`/etc/group`, opened through `os.OpenRoot` so a symlinked account file cannot +redirect resolution to host data. Working directories must be guest-absolute. + +## Lifecycle + +The lifecycle commands operate on the local store: + +- `list` reads `refs.json` and the pinned image metadata; +- `rmi` removes a ref (or a unique SHA-256 digest prefix from `list`), + garbage-collects blobs no remaining manifest reaches, and reclaims the + image's unpacked cache when its last ref goes away; +- `prune` garbage-collects unreachable blobs without removing a named ref; + `prune --cache` also removes unpacked rootfs caches. + +Garbage collection is reachability-based: shared manifests, configs, and layers +stay on disk while any remaining ref reaches them. + +On macOS, cache cleanup also handles sparsebundle state, detaching stale mounts +and reaping per-run clone directories left by killed runs, with two safety +rules. A still-pinned digest's bundle is untouched by a plain `prune --cache` +(recovery of a crashed pinned bundle happens on the next `run`, or via `--all`), +and a volume a live run still uses is never force-detached. Liveness is decided +by a per-digest advisory lock (`run.lock`, held shared by every live run for +its whole lifetime), not by inspecting process ids, which avoids the pid-reuse +hazard. A sweep acquires that lock exclusively; while it cannot, the bundle is +left in place, and once it can, every clone is abandoned by construction and is +reaped except those a `run --keep` retained (a `.elfuse-keep` file) and any +leftover unpack staging directory. `rmi` reclaims a cache the same way: a plain +`rmi` drops the removed image's cache (derived state that goes with the image), +but refuses while a live run uses the volume (not even `--force` overrides) and +refuses without `--force` when the cache holds `run --keep` output. + +### Concurrency + +elfuse-oci is a single-user CLI, not a daemon. Store metadata stays +consistent under concurrency: pin and index updates, `rmi`, `prune`'s GC, and +`list`'s snapshot all serialize on an exclusive store file lock, so parallel +pulls and lifecycle commands cannot corrupt `refs.json`, `index.json`, or +reachability accounting. + +Per-digest sparsebundle state uses two advisory locks kept beside the bundle +(outside the mounted volume, so a detach cannot revoke them): an `attach.lock` +serializing lifecycle transitions, and the shared `run.lock` above. Concurrent +runs of the same image share one attach: the first provisions and attaches, +later runs join under a shared `run.lock`, and the volume detaches only when the +last exits. A `prune`/`rmi` sweep takes both locks non-blocking, so it either +wins (no run is live) or skips the busy bundle. A cold run unpacks into a +temporary directory and atomically renames it into place, so a concurrent probe +never sees a half-written rootfs and an interleaved sweep cannot delete one +mid-unpack. + +The plain digest-keyed rootfs cache (`rootfs//`, the default on +non-Darwin and on the `--plain-rootfs` path) follows the same liveness rule +with a single sibling `.lock`, held shared by a run from before the +cache-existence probe until the guest exits. The lock sits beside the directory +because the directory is the guest's `/` and its existence is the +unpack-complete signal. The plain run path execs `elfuse` in place, so the lock +descriptor is made exec-survivable and rides into the elfuse process: the kernel +releases the flock exactly when the guest exits, `SIGKILL` included. An explicit +`--rootfs` directory is user-managed and outside this scheme. + +## Validation + +The on-disk store is the contract: its layout is checked for spec-conformance +and cross-tool readability (crane/skopeo/umoci), and the pipeline is exercised +offline on Linux and end-to-end on macOS/HVF. `elfuse-oci` builds and +unit-tests without Hypervisor.framework; only an actual `run` guest boot needs +it. The concrete targets, env-var gates, and the Linux/macOS CI split live in +[testing.md](testing.md#oci-image-cli). diff --git a/docs/testing.md b/docs/testing.md index d5bbd978..368ab807 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -15,6 +15,7 @@ Host build requirements: - GNU `make` - GNU `objcopy` or `llvm-objcopy` - GNU coreutils +- Go, for the elfuse-oci OCI CLI - `bash` 3.2+ (the version Apple ships as `/bin/bash`) is sufficient for the test harness; no Homebrew `bash` is required. See `tests/lib/bash-compat.sh` for the cross-version shims (a portable @@ -32,6 +33,33 @@ Guest test builds additionally require: - An AArch64 Linux cross-compiler for C test programs - An AArch64 bare-metal toolchain for the assembly smoke test +OCI interop checks additionally require `jq`. `crane`, `skopeo`, and `umoci` +are used when available by `make oci-interop`; CI installs them so cross-tool +layout parsing and registry-truth checks are hard gates there. + +`elfuse-oci` uses the Go `go-containerregistry` library directly for pulling +images, selecting platforms, and maintaining the OCI image-layout store. The +`crane` CLI is only used here as an independent registry/layout checker, while +`umoci` is used to verify that another OCI implementation can parse the store; +neither tool is on the normal `elfuse-oci run` execution path. + +For a full local `make oci-interop` run on macOS: + +```sh +brew install jq skopeo umoci +go install github.com/google/go-containerregistry/cmd/crane@v0.21.7 +export PATH="$(go env GOPATH)/bin:$PATH" +``` + +For a full local run on Ubuntu: + +```sh +sudo apt-get install -y jq skopeo +go install github.com/google/go-containerregistry/cmd/crane@v0.21.7 +go install github.com/opencontainers/umoci/cmd/umoci@latest +export PATH="$(go env GOPATH)/bin:$PATH" +``` + The toolchain defaults are defined in `mk/toolchain.mk`. These variables are intended to be overridden when needed: @@ -81,6 +109,10 @@ make check make test-rosetta-all make test-gdbstub make test-matrix +make elfuse-oci +make oci-test +make oci-lint +make oci-interop make lint make clean ``` @@ -146,6 +178,13 @@ What they do: - `make test-gdbstub`: debugger integration checks against the built-in GDB stub - `make test-matrix`: cross-check `elfuse` (aarch64), QEMU (aarch64), and `elfuse` (x86_64-via-Rosetta) on overlapping corpora +- `make elfuse-oci`: build the Go OCI image CLI +- `make oci-test`: run offline elfuse-oci tests +- `make oci-lint`: `gofmt` check plus `go vet` for elfuse-oci (vet runs for both + `GOOS` values, so the darwin sparsebundle files are checked from Linux and the + non-darwin stubs from macOS) +- `make oci-interop`: pull OCI fixtures, check the raw image-layout files with + `jq`, and ask available external tools to read the same store - `make lint`: static analysis through `clang-tidy` ## Quick Iteration @@ -176,6 +215,72 @@ or run all matrix modes back-to-back with `make test-matrix`. green `make check` covers BusyBox validation. Use `make test-busybox` to iterate on a single applet failure without rerunning the unit suite. +### OCI Image CLI + +The OCI image CLI (`build/elfuse-oci`) is pure Go, so most of it builds and tests +without Hypervisor.framework; only an actual `elfuse-oci run` guest boot needs +HVF. For elfuse-oci changes: + +```sh +make elfuse-oci # build +make oci-test # offline unit + conformance tests +make oci-lint # gofmt check + go vet (both GOOS values) +ELFUSE_OCI_NETTEST=1 make oci-test # add the registry pull round-trip +make oci-interop # image-layout + cross-tool interop (needs jq) +``` + +`make oci-interop` always requires `jq`; install `crane`, `skopeo`, and `umoci` +too when you want the local run to match the CI cross-tool gate. The Darwin +sparsebundle round-trip (real `hdiutil` + case-sensitive APFS) is gated behind an +env var and runs only on macOS: + +```sh +ELFUSE_OCI_DARWIN_CS=1 go test -run TestDarwinCSSweep ./cmd/elfuse-oci/ +``` + +CI splits this coverage by what each runner can do: + +- **Linux (hosted)**: build, `gofmt`/`go vet` (including a `GOOS=darwin` + cross-vet of the sparsebundle files), the pull/inspect/unpack/list/rmi/prune + lifecycle, the `ELFUSE_OCI_NETTEST` round-trip, and crane/skopeo/umoci interop. + The unit suite runs with `-race` here. +- **macOS (hosted)**: build plus the full `go test -race` on darwin (exercising + the sparsebundle/clone code the Linux job can only cross-vet). The darwin + concurrency-sensitive code (cache sweep, provision, clone, cache-removal lock + discipline) compiles only on darwin, so this is the only place the race + detector ever sees it; keep `-race` on this step. Plus the real + `ELFUSE_OCI_DARWIN_CS` sparsebundle round-trip, and a run-less + pull/inspect/list/rmi/prune lifecycle smoke through the darwin binary. + No HVF needed. + +### Writing OCI unit-test fixtures + +An image is untrusted input: its layers control file *names and shapes*, not +just contents. When testing unpack, cache, or sweep code, build fixtures that +adversarially exercise those, not only escape-by-content: + +- **Reserved marker names.** An image can ship a path that collides with an + elfuse-managed marker (e.g. a layer with `/.elfuse-keep`). Assert the marker + is ignored when it comes from image content and honored only when + elfuse-oci wrote it out of band (see + `TestListSweepableClonesReapsImageShippedKeepFile`). +- **Implicit directories.** A layer may create `dir/sub/file` with no `dir/sub` + tar entry. Whiteout/opaque logic must treat that implicit parent as + current-layer content (see `TestUnpackOpaqueAfterImplicitParentChildren`). +- **Platform variants.** Set `cfg.Variant` (e.g. `linux/arm/v7`) so `inspect` + and `list` are checked to agree, not just os/arch. +- **Concurrency.** Use the package-level function-var seams (`afterImageResolve`, + `cranePull`, `isMountPointFn`, ...) to stage a racing pull/rmi in the exact + TOCTOU window rather than only single-command paths, and hold the store or + cache flocks directly to assert a busy path refuses. +- **macOS + HVF (self-hosted)**: end-to-end `elfuse-oci run` guest boots: + the alpine:3 default-entrypoint smoke that proves pull → sparsebundle → + COW clone → HVF launch → exit-code propagation, and a full + pull → inspect → list → run → rmi → prune lifecycle that runs + python:3.12-slim with an `--entrypoint` override and asserts both teardowns + (a plain rmi reclaiming the cold cache with the image, then a `run --keep` + cache that rmi refuses without `--force` and `--force` detaches and drops). + ## Test Matrix The matrix driver lives in `tests/test-matrix.sh`. It currently covers three @@ -406,6 +511,7 @@ Suggested minimum 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` | +| OCI lifecycle or store behavior | `make oci-lint && make oci-test && make oci-interop` | | 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/docs/usage.md b/docs/usage.md index dc6ed3d4..b994559b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -228,3 +228,188 @@ That has a few direct implications: work entirely inside the VM. Programs that link against `libfuse` (sshfs, ntfs-3g, AppImage runtimes) run without macFUSE, FUSE-T, or FSKit on the host. + +## OCI Images + +elfuse can run programs from OCI images. All image work is handled by +`build/elfuse-oci` (Go); execution still goes through the normal +`build/elfuse --sysroot` runtime, so `elfuse` itself has no OCI commands. + +```sh +build/elfuse-oci [flags] +``` + +This consumes the OCI image format for distribution; it is not a container +runtime. There are no namespaces, cgroups, port mapping, daemon, `docker exec`, +or image build/push, and the rootfs is the guest's root, not an isolation +boundary. See +[oci-design.md](oci-design.md#scope-and-limitations) for the exact list of what +is and is not implemented, and for the design model. + +### Store And Platform + +`elfuse-oci` stores images in an OCI image-layout directory. The default +store is `$ELFUSE_OCI_STORE` when set, otherwise `~/.local/share/elfuse/oci`. +Use `--store DIR` on any subcommand to override it. + +Pulls default to `linux/arm64`. Use `--platform os/arch[/variant]` to select +another platform, such as `linux/amd64` for a Rosetta-backed guest: + +```sh +build/elfuse-oci pull --platform linux/amd64 alpine:3 +``` + +### Commands + +```sh +build/elfuse-oci pull [--store DIR] [--platform os/arch[/variant]] +``` + +Pull `` into the local store and pin it by its original reference. + +```sh +build/elfuse-oci inspect [--store DIR] [--json] +``` + +Print the stored image's manifest and config summary. `--json` prints the raw +config JSON. + +```sh +build/elfuse-oci unpack [--store DIR] [--rootfs DIR] +``` + +Unpack the stored image's layers into a rootfs directory. Without `--rootfs`, +the store's digest-keyed rootfs cache is used. + +```sh +build/elfuse-oci run [flags] [args...] +``` + +Run an image. If `` is not present, `run` pulls it first; if the rootfs +cache is missing, it unpacks it first. + +```sh +build/elfuse-oci list [--store DIR] [--json] # alias: images +``` + +List pinned refs, their manifest digests, platform, compressed layer size, and +layer count. + +```sh +build/elfuse-oci rmi [--store DIR] [--force] +``` + +Remove a ref, or a unique SHA-256 digest prefix from `list`, and +garbage-collect blobs no remaining ref reaches. Removing the last ref for an +image also reclaims its unpacked cache. `rmi` refuses while a live run still +uses the cache (never overridable), and refuses without `--force` when the +cache holds `run --keep` output. + +```sh +build/elfuse-oci prune [--store DIR] [--cache] [--all] [--dry-run] +``` + +Garbage-collect unreachable blobs. `--cache` also removes unpacked rootfs +caches; with `--cache`, `--all` removes them even for still-pulled refs. +`--dry-run` reports what would be removed. + +### Running Images + +```sh +make elfuse elfuse-oci + +build/elfuse-oci run alpine:3 /bin/sh -c 'echo hello' +``` + +`run` accepts the common flags plus these run-specific flags: + +| Flag | Meaning | +|------|---------| +| `--entrypoint PATH` | Replace the image Entrypoint. The image Cmd is dropped. | +| `--env KEY=VALUE` | Set or replace a guest environment variable. Repeatable. | +| `--env KEY` | Import `KEY` from the host environment when present. | +| `--clear-env` | Start from an empty environment instead of image `Env`. | +| `--user UID[:GID]` | Run as a numeric user and optional group. | +| `--user name[:group]` | Resolve names through rootfs `/etc/passwd` and `/etc/group`. | +| `--workdir DIR` | Set the initial guest working directory. Must be absolute. | +| `--rootfs DIR` | Use an explicit rootfs directory. | +| `--plain-rootfs` | Use a plain directory cache instead of the macOS sparsebundle. | +| `--sparse-size SIZE` | Set the sparsebundle virtual size. Default is `16g`. | +| `--no-clone` | Run against the cached base rootfs directly. Mutations persist. | +| `--keep` | Keep the per-run clone and sparsebundle mount for inspection. | + +The command vector follows Docker-style rules: with no CLI args, the image +Entrypoint plus Cmd is used; CLI args after `` replace image Cmd but keep +image Entrypoint; `--entrypoint` replaces image Entrypoint and discards Cmd; an +empty final command is an error. A relative path command (`./server`) resolves +against the working directory, and a bare name resolves via the merged `PATH` +inside the image rootfs (see `oci-design.md` for the `argv[0]` caveat). + +Environment resolution starts from image `Env` (or an empty environment under +`--clear-env`); each `--env KEY=VALUE` sets or appends, and a bare `--env KEY` +imports the host value when set. `--user` defaults to image `User`, then root; +symbolic users and groups are resolved after the rootfs exists, and a name that +fails to resolve is an error. + +### macOS Rootfs Behavior + +By default, `run` uses a case-sensitive APFS sparsebundle per image digest so +the guest's case-sensitive filenames do not collide on a case-insensitive host +volume. The unpacked image lives as a cached base tree inside the sparsebundle, +and each run gets an APFS copy-on-write clone of it, so guest writes do not +mutate the cached rootfs. The clone is removed and the sparsebundle detached +when the guest exits; `--keep` leaves both for inspection, and `prune --cache` +reaps stale caches (including clones left by killed runs) later. + +Use `--plain-rootfs` or `--rootfs DIR` when you explicitly want the regular +directory path. + +### Runtime Files + +Before launching the guest, `run` writes these files into the run rootfs: + +| File | Source | +|------|--------| +| `/etc/resolv.conf` | Host resolver config, with a fallback nameserver if needed. | +| `/etc/hosts` | localhost plus the host name. | +| `/etc/hostname` | Host name. | + +The `resolv.conf` fallback (used only when the host's own config is unreadable +or empty) is Google's public `8.8.8.8`; on hosts using private or split-horizon +DNS, fix the host `/etc/resolv.conf` if guest lookups must stay on the local +resolver. The C runtime also serves synthetic `/proc` and selected `/dev` +entries to every guest, image-launched or not. + +### Host Fallback And PATH + +The image rootfs is a root, not a boundary: an absolute guest path absent from +the rootfs falls back to the literal host path, the same mechanism that lets a +plain positional `elfuse ` run reach host resources such as +`/etc/resolv.conf`. The guest-private prefixes (`/tmp`, `/var/tmp`, `.ccache`) +are the exception and always resolve inside the rootfs; see +[sysroot.md](sysroot.md) for the dispatch model. + +The visible consequence is the guest `PATH` search. With an image `PATH` +searching `/usr/bin` before `/bin`, a bare `gzip` in an image that ships it only +at `/bin/gzip` resolves to the host's incompatible `/usr/bin/gzip` (a macOS +Mach-O) first. Prefer usr-merged images, invoke by absolute path, or reorder the +search with `--env PATH=...`. When neither the image config nor `--env` provides +a `PATH`, `run` appends Docker's conventional default +(`/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`). See +[oci-design.md](oci-design.md#run-paths) for the rationale. + +### Lifecycle + +The store keeps image blobs separately from unpacked rootfs caches, and garbage +collection is reachability-based: removing a ref never deletes blobs another ref +still reaches, and removing one of several refs to the same digest keeps the +shared cache. A normal cleanup sequence: + +```sh +build/elfuse-oci list +build/elfuse-oci rmi alpine:3 +build/elfuse-oci prune --cache --dry-run +build/elfuse-oci prune --cache +``` + +See [oci-design.md](oci-design.md#lifecycle) for the GC and cache-safety model. From 2962efb68b3a50e8940b3175229680a80b3998cb Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Sat, 18 Jul 2026 20:55:16 +0200 Subject: [PATCH 21/22] Add per-image real-workload CI for elfuse-oci Issue #224 profiled five real images (python:3.12-slim, node:22-alpine, golang:1.23-alpine, eclipse-temurin:21, gcc:14). Add one CI job per image that boots the image under HVF via `elfuse-oci run` and drives that image's operations, so a change that breaks any of them is caught on the PR rather than by hand. A shared driver, scripts/ci/oci-workload.sh , maps each key to its image and guest workload under scripts/ci/workloads/ and asserts a per-image sentinel token: - python: a single-threaded SQLite insert plus an aggregate query, a small file write/read/checksum fan-out, and a JSON round-trip. - node: in-guest compute (fs/crypto/zlib/JSON) plus an HTTP server the job reaches over the host loopback; elfuse maps guest sockets to host sockets and does no network-namespace isolation, so a guest bound to 127.0.0.1 is reachable host-side. - go: gofmt over a tree of misformatted fixtures the workload writes itself, asserting the file set it names, the bytes it produces, and that a second pass names nothing. The toolchain binaries are Go programs, so this drives the runtime's own scheduler and raw syscalls; it compiles nothing, because the guest compiler dies on SIGHUP before finishing a package. - jvm: javac + java exercising collections, file I/O, SHA-256, an 8-thread pool, and a subprocess. - c: a small multi-file make project plus a heavier single translation unit compiled with gcc -O1. The go and python lanes stay inside the runtime's current limits; heavier variants that stress those limits, including an in-guest Go build, are submitted separately. The jobs run only on self-hosted Apple Silicon because `run` needs Hypervisor.framework. They share a composite action that fetches the elfuse binary from build-macos and builds elfuse-oci, and each keeps a warm per-key store on the runner's persistent disk so only the first run pulls over the network. gcc:14 and eclipse-temurin:21 ship the shadow suite, so those jobs also exercise the unpack setuid/setgid degrade end to end. The existing python workload moves under scripts/ci/workloads/; oci-lifecycle.sh follows the new path. --- .github/actions/hvf-elfuse-setup/action.yml | 66 +++++++++ .github/workflows/main.yml | 143 +++++++++++++++++++ docs/testing.md | 48 +++++-- scripts/ci/oci-lifecycle.sh | 29 +--- scripts/ci/oci-python-workload.py | 117 ---------------- scripts/ci/oci-workload.sh | 146 ++++++++++++++++++++ scripts/ci/workloads/c-workload.sh | 73 ++++++++++ scripts/ci/workloads/go-workload.sh | 84 +++++++++++ scripts/ci/workloads/jvm-workload.sh | 87 ++++++++++++ scripts/ci/workloads/node-compute.js | 49 +++++++ scripts/ci/workloads/node-server.js | 34 +++++ scripts/ci/workloads/python-workload.py | 78 +++++++++++ 12 files changed, 802 insertions(+), 152 deletions(-) create mode 100644 .github/actions/hvf-elfuse-setup/action.yml delete mode 100644 scripts/ci/oci-python-workload.py create mode 100755 scripts/ci/oci-workload.sh create mode 100644 scripts/ci/workloads/c-workload.sh create mode 100644 scripts/ci/workloads/go-workload.sh create mode 100644 scripts/ci/workloads/jvm-workload.sh create mode 100644 scripts/ci/workloads/node-compute.js create mode 100644 scripts/ci/workloads/node-server.js create mode 100644 scripts/ci/workloads/python-workload.py diff --git a/.github/actions/hvf-elfuse-setup/action.yml b/.github/actions/hvf-elfuse-setup/action.yml new file mode 100644 index 00000000..f2e8065d --- /dev/null +++ b/.github/actions/hvf-elfuse-setup/action.yml @@ -0,0 +1,66 @@ +name: HVF elfuse setup +description: > + Shared setup for the self-hosted HVF workload jobs: fail fast if the run is + superseded by a newer PR commit, then fetch the prebuilt elfuse binary from + the build-macos job and build the pure-Go elfuse-oci CLI. Assumes the repo is + already checked out. + +runs: + using: composite + steps: + # Fail fast if this run targets a commit that is no longer the PR's HEAD. + # cancel-in-progress covers a newer push, but not a manual "Re-run jobs" on + # an old run, which would burn the self-hosted runner re-testing stale code. + # Mirrors the runtime-macos guard; fails (not cancels) because repo policy + # caps the token at actions: read. The lookup fails open. + - name: Fail fast if superseded by a newer PR commit + if: github.event_name == 'pull_request' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -uo pipefail + latest=$(curl -fsSL \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$REPO/pulls/$PR_NUMBER" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"]["sha"])') \ + || latest="" + echo "Run targets : $RUN_SHA" + echo "PR HEAD now : ${latest:-}" + if [ -n "$latest" ] && [ "$latest" != "$RUN_SHA" ]; then + echo "::error::This run targets $RUN_SHA, but PR #$PR_NUMBER HEAD is now $latest -- the commit is no longer the latest. Failing instead of re-testing stale code on the self-hosted runner; re-run CI on the current commit." + exit 1 + fi + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + # Reuse the arm64 elfuse binary built + entitlement-checked by build-macos + # instead of rebuilding the C project on the self-hosted runner five times. + # Mach-O code signatures (and their embedded HVF entitlement) travel inside + # the binary, so they survive the artifact zip round-trip; only the execute + # bit is lost and restored here. + - name: Download prebuilt elfuse binary + uses: actions/download-artifact@v7 + with: + name: elfuse-${{ runner.os }}-${{ runner.arch }} + path: build + + - name: Restore execute bit + verify HVF entitlement + shell: bash + run: | + set -euo pipefail + chmod +x build/elfuse + codesign -d --entitlements - build/elfuse 2>&1 \ + | grep -q 'com\.apple\.security\.hypervisor' + + - name: Build elfuse-oci + shell: bash + run: make build/elfuse-oci diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3d12ff9a..690df999 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,6 +13,9 @@ # (crane/skopeo/umoci) on Linux # oci-macos : elfuse-oci darwin build, unit tests, sparsebundle # round-trip, and a run-less image-lifecycle smoke +# workload-* : per-image real-workload smokes (python/node/go/jvm/c) that +# boot each image under HVF on self-hosted Apple Silicon and +# drive its characteristic operations # # Runtime and sanitizer tests require Hypervisor.framework, which # GitHub-hosted macOS runners do not expose. Those tests run on self-hosted @@ -793,3 +796,143 @@ jobs: # that the Linux job's cache-reclaiming flow does not cover. jq # ships on the macos-15 image. run: scripts/ci/oci-cli-smoke.sh + + # Per-image real-workload jobs. Each boots a real image under HVF + # via `elfuse-oci run` and drives that image's characteristic operations so a + # code change that breaks any of them is caught on the PR. One job per image; + # all share .github/actions/hvf-elfuse-setup, which fetches the elfuse binary + # from build-macos and builds elfuse-oci. `run` needs Hypervisor.framework, so + # these are self-hosted only. Each keeps a warm per-key store on the runner's + # persistent disk so only the first run pulls over the network. gcc:14 and + # eclipse-temurin:21 ship the shadow suite, so they also exercise the unpack + # setuid/setgid degrade end to end. + workload-python: + name: Workload (Python) + needs: build-macos + if: > + github.repository == 'sysprog21/elfuse' && + (github.event_name == 'push' || github.event_name == 'pull_request') + runs-on: [self-hosted, macOS, arm64] + timeout-minutes: 30 + permissions: + contents: read + pull-requests: read + concurrency: + group: workload-python-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: HVF elfuse setup + uses: ./.github/actions/hvf-elfuse-setup + - name: Run python workload + run: | + set -euo pipefail + export ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-workload-python" + scripts/ci/oci-workload.sh python + + workload-node: + name: Workload (Node) + needs: build-macos + if: > + github.repository == 'sysprog21/elfuse' && + (github.event_name == 'push' || github.event_name == 'pull_request') + runs-on: [self-hosted, macOS, arm64] + timeout-minutes: 30 + permissions: + contents: read + pull-requests: read + concurrency: + group: workload-node-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: HVF elfuse setup + uses: ./.github/actions/hvf-elfuse-setup + - name: Run node workload + # Includes the HTTP-server phase: the guest binds 127.0.0.1 (elfuse maps + # sockets to host sockets, no netns), and this step curls it host-side. + run: | + set -euo pipefail + export ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-workload-node" + scripts/ci/oci-workload.sh node + + workload-go: + name: Workload (Go) + needs: build-macos + if: > + github.repository == 'sysprog21/elfuse' && + (github.event_name == 'push' || github.event_name == 'pull_request') + runs-on: [self-hosted, macOS, arm64] + timeout-minutes: 30 + permissions: + contents: read + pull-requests: read + concurrency: + group: workload-go-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: HVF elfuse setup + uses: ./.github/actions/hvf-elfuse-setup + - name: Run go workload + run: | + set -euo pipefail + export ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-workload-go" + scripts/ci/oci-workload.sh go + + workload-jvm: + name: Workload (JVM) + needs: build-macos + if: > + github.repository == 'sysprog21/elfuse' && + (github.event_name == 'push' || github.event_name == 'pull_request') + runs-on: [self-hosted, macOS, arm64] + # javac + java startup and the compile step run slower than the lighter + # images, and eclipse-temurin is a large first pull. + timeout-minutes: 45 + permissions: + contents: read + pull-requests: read + concurrency: + group: workload-jvm-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: HVF elfuse setup + uses: ./.github/actions/hvf-elfuse-setup + - name: Run jvm workload + run: | + set -euo pipefail + export ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-workload-jvm" + scripts/ci/oci-workload.sh jvm + + workload-c: + name: Workload (C) + needs: build-macos + if: > + github.repository == 'sysprog21/elfuse' && + (github.event_name == 'push' || github.event_name == 'pull_request') + runs-on: [self-hosted, macOS, arm64] + # gcc:14 is the largest first pull and the compile bursts (make + a heavier + # single TU) dominate the wall time. + timeout-minutes: 45 + permissions: + contents: read + pull-requests: read + concurrency: + group: workload-c-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: HVF elfuse setup + uses: ./.github/actions/hvf-elfuse-setup + - name: Run c workload + run: | + set -euo pipefail + export ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-workload-c" + scripts/ci/oci-workload.sh c diff --git a/docs/testing.md b/docs/testing.md index 368ab807..15754bd0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -229,10 +229,9 @@ ELFUSE_OCI_NETTEST=1 make oci-test # add the registry pull round-trip make oci-interop # image-layout + cross-tool interop (needs jq) ``` -`make oci-interop` always requires `jq`; install `crane`, `skopeo`, and `umoci` -too when you want the local run to match the CI cross-tool gate. The Darwin -sparsebundle round-trip (real `hdiutil` + case-sensitive APFS) is gated behind an -env var and runs only on macOS: +Tool prerequisites for `make oci-interop` are listed under Build Requirements +above. The Darwin sparsebundle round-trip (real `hdiutil` + case-sensitive +APFS) is gated behind an env var and runs only on macOS: ```sh ELFUSE_OCI_DARWIN_CS=1 go test -run TestDarwinCSSweep ./cmd/elfuse-oci/ @@ -252,6 +251,40 @@ CI splits this coverage by what each runner can do: `ELFUSE_OCI_DARWIN_CS` sparsebundle round-trip, and a run-less pull/inspect/list/rmi/prune lifecycle smoke through the darwin binary. No HVF needed. +- **macOS (self-hosted, HVF)**: the only place `elfuse-oci run` actually boots a + guest. An alpine:3 default-entrypoint smoke proves pull → sparsebundle → + COW clone → HVF launch → exit-code propagation; a pull → inspect → list → + run → rmi → prune lifecycle runs python:3.12-slim with an `--entrypoint` + override and asserts the teardown guardrails (a plain rmi reclaiming the + cold cache with the image, then a `run --keep` cache that rmi refuses + without `--force` and `--force` detaches and drops). Besides those, a + per-image workload suite + (`workload-python|node|go|jvm|c`) drives each image through its + characteristic operations, one job per image. The shared driver is + `scripts/ci/oci-workload.sh `; each image's guest workload lives in + `scripts/ci/workloads/`: + + - **python** (`python:3.12-slim`): a single-threaded SQLite insert plus + aggregate query, a 50-file write/read/checksum pass, and a JSON round-trip. + - **node** (`node:22-alpine`): in-guest compute (fs fan-out, crypto, zlib, + JSON) plus an HTTP server the job curls over the host loopback before + `/quit`. + - **go** (`golang:1.23-alpine`): `go version` plus a `gofmt` of a tiny file + (deliberately build-free; a full build overruns elfuse's fixed thread + table). + - **jvm** (`eclipse-temurin:21`): `javac` + `java` exercising collections, + file I/O, SHA-256, an 8-thread pool, and a subprocess. + - **c** (`gcc:14`): a small multi-file `make` project plus a larger single + translation unit compiled with `gcc -O1`. + + `gcc:14` and `eclipse-temurin:21` are Debian/Ubuntu-based and ship the shadow + suite, so these jobs also exercise the unpack setuid/setgid degrade end to + end. Each job keeps a warm per-key store on the runner's persistent disk, so + only the first run pulls over the network. Run one locally with: + + ```sh + ELFUSE_OCI_STORE=/tmp/elfuse-oci-store scripts/ci/oci-workload.sh c + ``` ### Writing OCI unit-test fixtures @@ -273,13 +306,6 @@ adversarially exercise those, not only escape-by-content: `cranePull`, `isMountPointFn`, ...) to stage a racing pull/rmi in the exact TOCTOU window rather than only single-command paths, and hold the store or cache flocks directly to assert a busy path refuses. -- **macOS + HVF (self-hosted)**: end-to-end `elfuse-oci run` guest boots: - the alpine:3 default-entrypoint smoke that proves pull → sparsebundle → - COW clone → HVF launch → exit-code propagation, and a full - pull → inspect → list → run → rmi → prune lifecycle that runs - python:3.12-slim with an `--entrypoint` override and asserts both teardowns - (a plain rmi reclaiming the cold cache with the image, then a `run --keep` - cache that rmi refuses without `--force` and `--force` detaches and drops). ## Test Matrix diff --git a/scripts/ci/oci-lifecycle.sh b/scripts/ci/oci-lifecycle.sh index 29319819..1187ffd3 100755 --- a/scripts/ci/oci-lifecycle.sh +++ b/scripts/ci/oci-lifecycle.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Whole user-facing image lifecycle against a real HVF-booted guest, then # the teardown guardrails, one function per phase: -# run_workloads pull/inspect/list, an --entrypoint override, -# and a glibc dynamically-linked python workload +# run_workloads pull/inspect/list and a glibc dynamically- +# linked python one-liner via --entrypoint # teardown_plain_rmi a plain rmi reclaims the cold unpacked cache # with the image # teardown_keep_guardrails rmi refuses to discard run --keep output @@ -25,19 +25,10 @@ SEED="${ELFUSE_OCI_SEED_STORE:?set ELFUSE_OCI_SEED_STORE to the warm seed store IMG="${IMG:-python:3.12-slim}" export ELFUSE_OCI_STORE +# reap_guest (in oci-lib.sh) keeps a failed phase from leaking the +# backgrounded guest. guest="" -on_exit() { - rc=$? - # A failed phase must not leak the backgrounded guest: it would keep - # holding the per-digest flock (and its sleep) and poison the next - # prune/rmi on a persistent self-hosted runner. - if [ -n "$guest" ] && kill -0 "$guest" 2>/dev/null; then - kill "$guest" 2>/dev/null || true - wait "$guest" 2>/dev/null || true - fi - exit "$rc" -} -trap on_exit EXIT +trap reap_guest EXIT # The teardowns leave the ephemeral store EMPTY (asserted), so the # lifecycle store itself cannot persist between CI runs. Keep a warm seed @@ -67,16 +58,6 @@ run_workloads() { -c 'import json,math; print(json.dumps({"pi":round(math.pi,5),"ok":True}))')" printf 'guest said: %s\n' "$out" [ "$out" = '{"pi": 3.14159, "ok": true}' ] || fail "python one-liner said '$out'" - - # A non-trivial application: concurrent SQLite writers (fcntl locking, - # fsync, WAL where the guest FS supports it) plus a 64-file - # write/read/checksum fan-out. Exercises far more of the dynamically- - # linked glibc guest than the one-liner above; prints a single sentinel - # token only on full success. - out="$("$BIN" run --entrypoint /usr/local/bin/python3 "$IMG" \ - -c "$(cat "$OCI_CI_DIR/oci-python-workload.py")")" - printf 'python workload said: %s\n' "$out" - [ "$out" = elfuse-oci-python-workload-ok ] || fail "python workload said '$out'" } # The runs above left the cache warm and then detached on exit, so a plain diff --git a/scripts/ci/oci-python-workload.py b/scripts/ci/oci-python-workload.py deleted file mode 100644 index 6c1c044d..00000000 --- a/scripts/ci/oci-python-workload.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env python3 -"""Non-trivial guest workload for the elfuse-oci OCI lifecycle CI. - -Exercises more of a real dynamically-linked glibc guest than a one-line print: -concurrent SQLite writers (fcntl locking, fsync, and, when the guest FS -supports it, WAL mmap), plus a filesystem fan-out whose written content is -read back and checksummed. On full success it prints a single sentinel token -the workflow asserts on; any failure exits non-zero with a diagnostic. - -Self-contained (stdlib only) so it runs under the image's bundled Python with -no network or extra packages, and is passed to the guest via `python3 -c`. -""" - -import hashlib -import os -import sqlite3 -import sys -import threading - -DB = "/tmp/elfuse-workload.db" -TREE = "/tmp/elfuse-workload-tree" -THREADS = 8 -PER_THREAD = 1000 -FANOUT = 8 - - -def setup_db(): - con = sqlite3.connect(DB) - try: - # WAL exercises the guest FS's shared-memory index (mmap) and is the - # more demanding path; if the FS cannot back it, SQLite reports a - # different mode and the concurrent-writer count check below still - # validates fcntl locking and durable commits under rollback journal. - con.execute("PRAGMA journal_mode=WAL") - con.execute( - "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, " - "tid INTEGER NOT NULL, n INTEGER NOT NULL)" - ) - con.commit() - finally: - con.close() - - -def worker(tid): - con = sqlite3.connect(DB, timeout=60) - try: - con.execute("PRAGMA busy_timeout=60000") - for n in range(PER_THREAD): - con.execute("INSERT INTO t (tid, n) VALUES (?, ?)", (tid, n)) - con.commit() - finally: - con.close() - - -def db_count(): - con = sqlite3.connect(DB, timeout=60) - try: - (count,) = con.execute("SELECT COUNT(*) FROM t").fetchone() - return count - finally: - con.close() - - -def content(i, j): - return "elfuse-workload-%d-%d\n" % (i, j) - - -def fs_fanout_ok(): - for i in range(FANOUT): - for j in range(FANOUT): - d = os.path.join(TREE, str(i), str(j)) - os.makedirs(d, exist_ok=True) - with open(os.path.join(d, "f"), "w") as fh: - fh.write(content(i, j)) - fh.flush() - os.fsync(fh.fileno()) - read_back = [] - for i in range(FANOUT): - for j in range(FANOUT): - with open(os.path.join(TREE, str(i), str(j), "f")) as fh: - read_back.append(fh.read()) - expected = [content(i, j) for i in range(FANOUT) for j in range(FANOUT)] - got = hashlib.sha256() - for p in sorted(read_back): - got.update(p.encode()) - want = hashlib.sha256() - for p in sorted(expected): - want.update(p.encode()) - return got.hexdigest() == want.hexdigest() - - -def main(): - setup_db() - threads = [threading.Thread(target=worker, args=(i,)) for i in range(THREADS)] - for t in threads: - t.start() - for t in threads: - t.join() - - count = db_count() - if count != THREADS * PER_THREAD: - print( - "sqlite row count %d != %d (concurrent writers lost rows)" - % (count, THREADS * PER_THREAD), - file=sys.stderr, - ) - sys.exit(1) - - if not fs_fanout_ok(): - print("filesystem fan-out checksum mismatch", file=sys.stderr) - sys.exit(1) - - print("elfuse-oci-python-workload-ok") - - -if __name__ == "__main__": - main() diff --git a/scripts/ci/oci-workload.sh b/scripts/ci/oci-workload.sh new file mode 100755 index 00000000..29777bc7 --- /dev/null +++ b/scripts/ci/oci-workload.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Per-image real-workload smoke: drive each image's characteristic operations +# through `elfuse-oci run` under HVF and assert a sentinel token. +# One key per image. `run` pulls on demand, so a warm persistent +# ELFUSE_OCI_STORE keeps reruns network-free. +# +# Usage: ELFUSE_OCI_STORE= scripts/ci/oci-workload.sh +# shellcheck source=scripts/ci/oci-lib.sh +. "$(dirname "$0")/oci-lib.sh" +require_bin +: "${ELFUSE_OCI_STORE:?set ELFUSE_OCI_STORE to the store directory to use}" +export ELFUSE_OCI_STORE + +key="${1:?usage: oci-workload.sh }" +WL="$OCI_CI_DIR/workloads" + +# assert_sentinel SENTINEL DESC OUTPUT: OUTPUT must contain the fixed SENTINEL. +assert_sentinel() { + printf '%s\n' "$3" | expect_grep "$1" \ + || fail "$2: output missing sentinel '$1'" +} + +# run_capture SENTINEL DESC RUN-ARGS...: run a single-shot guest workload, +# echo its output for the log, and assert the sentinel. Covers every image +# whose workload is one `run` invocation (all but node's two-phase server). +run_capture() { + local sentinel="$1" desc="$2" + shift 2 + local out + out="$("$BIN" run "$@")" + printf '%s\n' "$out" + assert_sentinel "$sentinel" "$desc" "$out" +} + +# Background guest bookkeeping for the node server phase; reap_guest (in +# oci-lib.sh) keeps a failed assertion from leaking the guest. +guest="" +node_outfile="" +on_exit() { + rc=$? + reap_guest + [ -n "$node_outfile" ] && rm -f "$node_outfile" + exit "$rc" +} +trap on_exit EXIT + +# guest_gone succeeds once the backgrounded guest has exited. +guest_gone() { + ! kill -0 "$guest" 2>/dev/null +} + +run_node() { + # Phase A: in-guest compute. + run_capture elfuse-oci-node-compute-ok node-compute \ + --entrypoint /usr/local/bin/node node:22-alpine \ + -e "$(cat "$WL/node-compute.js")" + + # Phase B: HTTP server reached over the host loopback. elfuse forwards + # socket syscalls to host sockets and does no netns isolation, so a guest + # bound to 127.0.0.1 is reachable from the host. The server binds an + # ephemeral port and prints "PORT="; read it back rather than fixing a + # port that could collide with a leaked or concurrent guest. + local reqs="${WL_NODE_REQUESTS:-100}" + node_outfile="$(mktemp)" + "$BIN" run --entrypoint /usr/local/bin/node node:22-alpine \ + -e "$(cat "$WL/node-server.js")" >"$node_outfile" 2>&1 & + guest=$! + + # Wait for the server to announce its ephemeral port. Poll rather than + # wait_for so a guest that dies (a bind failure or a runtime crash) surfaces + # its own captured output instead of an opaque timeout. + local waited=0 port="" + while [ "$waited" -lt 120 ]; do + port="$(awk -F= '/^PORT=/{print $2; exit}' "$node_outfile")" + [ -n "$port" ] && break + if ! kill -0 "$guest" 2>/dev/null; then + cat "$node_outfile" >&2 + guest="" + fail "node server exited before announcing a port" + fi + sleep 0.5 + waited=$((waited + 1)) + done + if [ -z "$port" ]; then + cat "$node_outfile" >&2 + fail "node server did not announce a port within 60s" + fi + # The PORT= line is the guest flushing stdout, not proof the socket accepts + # connections yet; probe the port directly before hammering it. + wait_for 30 "node server on 127.0.0.1:$port" \ + curl -fsS -o /dev/null "http://127.0.0.1:$port/" + printf 'node server on 127.0.0.1:%s\n' "$port" + + local i=0 body + while [ "$i" -lt "$reqs" ]; do + # Guard the substitution: a bare body=$(curl ...) would trip the ERR + # trap on any transient failure instead of the specific diagnostic. + if ! body="$(curl -fsS "http://127.0.0.1:$port/")"; then + fail "node server request $i failed (curl)" + fi + [ "$body" = elfuse-node-server-ok ] \ + || fail "node server request $i returned '$body'" + i=$((i + 1)) + done + printf 'node server answered %d requests\n' "$reqs" + + # Clean shutdown: /quit makes the guest exit 0. The connection may reset as + # the guest exits, so tolerate the curl status. If /quit never reaches the + # server the guest would run forever, so bound the wait; on timeout + # wait_for fails and the EXIT trap kills the guest rather than blocking + # to the job's timeout-minutes. + curl -fsS -o /dev/null "http://127.0.0.1:$port/quit" || true + wait_for 10 "node server exit after /quit" guest_gone + if ! wait "$guest"; then + guest="" + fail "node server exited non-zero after /quit" + fi + guest="" +} + +case "$key" in + python) + run_capture elfuse-oci-python-workload-ok python \ + --entrypoint /usr/local/bin/python3 python:3.12-slim \ + -c "$(cat "$WL/python-workload.py")" + ;; + node) run_node ;; + go) + run_capture elfuse-oci-go-workload-ok go \ + golang:1.23-alpine /bin/sh -c "$(cat "$WL/go-workload.sh")" + ;; + jvm) + run_capture elfuse-oci-jvm-workload-ok jvm \ + eclipse-temurin:21 /bin/sh -c "$(cat "$WL/jvm-workload.sh")" + ;; + c) + run_capture elfuse-oci-c-workload-ok c \ + gcc:14 /bin/sh -c "$(cat "$WL/c-workload.sh")" + ;; + *) fail "unknown workload key: $key (want python|node|go|jvm|c)" ;; +esac + +# Keep a persistent store bounded: GC blobs stranded when a pinned tag moves. +"$BIN" prune >/dev/null + +echo "workload $key OK" diff --git a/scripts/ci/workloads/c-workload.sh b/scripts/ci/workloads/c-workload.sh new file mode 100644 index 00000000..99bda6a9 --- /dev/null +++ b/scripts/ci/workloads/c-workload.sh @@ -0,0 +1,73 @@ +# shellcheck shell=sh +# C-compile image workload, run in the guest via +# `/bin/sh -c`: a small multi-file project built with make, then a larger single +# translation unit compiled with gcc -O1 (the readlinkat/execve-of-cc1/as/ld +# signature). Prints one sentinel token on success. POSIX sh (dash). gcc:14 is +# Debian-based, so this also proves the setuid/setgid unpack degrade on a +# shadow-suite image. +# +# The Makefile uses .RECIPEPREFIX so its recipes are prefixed with '>' rather +# than a literal tab, which keeps this heredoc robust. Scaled down from the +# profiled 30-file project + 8 MB TU to keep CI minutes sane; the syscall signature +# (per-TU path resolution, compiler/assembler/linker exec) is preserved. +set -e + +# A guest path absent from the rootfs falls back to the literal host path, so a +# generic name like /tmp/cwork would read and write whatever the runner left in +# its own /tmp; an elfuse-owned name is created inside the rootfs instead. +d=/tmp/elfuse-c-work +rm -rf "$d" +mkdir -p "$d" +cd "$d" + +cat > mathx.h <<'EOF' +#ifndef MATHX_H +#define MATHX_H +int tri(int n); +#endif +EOF +cat > mathx.c <<'EOF' +#include "mathx.h" +int tri(int n) { + int s = 0; + for (int i = 1; i <= n; i++) s += i; + return s; +} +EOF +cat > main.c <<'EOF' +#include +#include "mathx.h" +int main(void) { + printf("%d\n", tri(100)); + return 0; +} +EOF +cat > Makefile <<'EOF' +.RECIPEPREFIX = > +CC ?= gcc +app: main.o mathx.o +> $(CC) -O1 -o app main.o mathx.o +%.o: %.c mathx.h +> $(CC) -O1 -c -o $@ $< +EOF + +make -j1 +r=$(./app) +if [ "$r" != "5050" ]; then + echo "make project produced: $r" >&2 + exit 1 +fi + +# A larger single TU: 1000 functions dispatched through a table, summed and +# checked. Exercises a heavier gcc -O1 compile+link than the tiny project above. +awk 'BEGIN { + for (i = 0; i < 1000; i++) print "int f" i "(void){return " i ";}"; + printf "typedef int(*fn)(void);\nstatic fn t[]={"; + for (i = 0; i < 1000; i++) printf "f%d,", i; + print "};"; + print "int main(void){long s=0;for(int i=0;i<1000;i++)s+=t[i]();return s==499500?0:1;}"; +}' > big.c +gcc -O1 -o big big.c +./big + +echo elfuse-oci-c-workload-ok diff --git a/scripts/ci/workloads/go-workload.sh b/scripts/ci/workloads/go-workload.sh new file mode 100644 index 00000000..84f959d0 --- /dev/null +++ b/scripts/ci/workloads/go-workload.sh @@ -0,0 +1,84 @@ +# shellcheck shell=sh +# Go image workload, run in the guest via `/bin/sh -c`. The Go toolchain +# binaries are themselves Go programs, so driving them reaches an elfuse entry +# path no other workload in this suite uses: the Go runtime issues raw Linux +# syscalls instead of routing through libc, and schedules its own goroutines +# over a worker pool that walks and rewrites a directory tree concurrently. +# Prints one sentinel token on success. POSIX sh (busybox ash). +# +# Deliberately does not compile anything in the guest. `go build` spawns +# /usr/local/go/pkg/tool/linux_arm64/compile, which dies on SIGHUP before it +# finishes the first package, so a build step here would test that gap rather +# than the toolchain; the compile-and-run variant lives on the +# oci/workload-stress branch. gofmt needs no compiler, so it exercises the same +# runtime without depending on that gap being closed. +set -e + +# The guest has no HOME, so give the toolchain a writable cache; the go command +# refuses to start without one. GOTOOLCHAIN=local stops it from reaching for a +# toolchain over a network this job does not have. +# +# Both paths carry the elfuse- prefix on purpose. A guest path that is absent +# from the rootfs falls back to the literal host path, so a generic name like +# /tmp/gowork would find, and then write through to, whatever the runner left +# in its own /tmp; an unclaimed name is created inside the rootfs instead. +export GOCACHE=/tmp/elfuse-go-cache GOTOOLCHAIN=local +export GOMAXPROCS=2 + +go version + +d=/tmp/elfuse-go-work +rm -rf "$d" +mkdir -p "$d/src" +cd "$d/src" + +# Every fixture is written here rather than read out of the image, so what the +# assertions below pin belongs to this test and cannot move when the tag is +# republished. Each file is misformatted the same way, so gofmt must rewrite +# all of them and the expected result is one fixed byte sequence. +n=64 +i=1 +while [ "$i" -le "$n" ]; do + printf 'package p\n\nfunc F%s() {\n\tx :=1\n\t_ = x\n}\n' "$i" > "f$i.go" + i=$((i + 1)) +done + +# gofmt walks the tree across goroutines, so this is the concurrent-read half. +# It must name every fixture and nothing else. +listed=$(gofmt -l . | wc -l | tr -d ' ') +if [ "$listed" != "$n" ]; then + echo "gofmt -l named $listed files, expected $n" >&2 + gofmt -l . >&2 + exit 1 +fi + +# The rewrite half: gofmt writes each file through a temporary and renames it +# into place, so this covers create, write and rename as well. +gofmt -w . + +# Byte-exact, because "gofmt changed something" is not the same claim as +# "gofmt produced the right bytes". +expected_file=$(printf 'package p\n\nfunc F7() {\n\tx := 1\n\t_ = x\n}\n') +actual_file=$(cat f7.go) +if [ "$actual_file" != "$expected_file" ]; then + echo "f7.go after gofmt -w:" >&2 + cat f7.go >&2 + exit 1 +fi + +# Nothing may remain unformatted, which is a claim about files this test wrote. +remaining=$(gofmt -l . | wc -l | tr -d ' ') +if [ "$remaining" != "0" ]; then + echo "gofmt -l still names $remaining files after -w" >&2 + gofmt -l . >&2 + exit 1 +fi + +# go env reads the toolchain's own configuration through the same runtime. +root=$(go env GOROOT) +if [ ! -x "$root/bin/go" ]; then + echo "go env GOROOT gave $root, which holds no go binary" >&2 + exit 1 +fi + +echo "elfuse-oci-go-workload-ok files=$n formatted=$n" diff --git a/scripts/ci/workloads/jvm-workload.sh b/scripts/ci/workloads/jvm-workload.sh new file mode 100644 index 00000000..ae2042a4 --- /dev/null +++ b/scripts/ci/workloads/jvm-workload.sh @@ -0,0 +1,87 @@ +# shellcheck shell=sh +# JVM image workload, run in the guest via +# `/bin/sh -c`: javac-compile a small program, then run it exercising +# collections, file I/O, a SHA-256 digest, an 8-thread pool, and a subprocess +# (the futex/clock_gettime-heavy signature). The program prints the sentinel +# token itself on success. POSIX sh (dash). eclipse-temurin is Ubuntu-based, so +# this also proves the setuid/setgid unpack degrade on a shadow-suite image. +set -e + +# A guest path absent from the rootfs falls back to the literal host path, so a +# generic name like /tmp/jvmwork would read and write whatever the runner left +# in its own /tmp; an elfuse-owned name is created inside the rootfs instead. +d=/tmp/elfuse-jvm-work +rm -rf "$d" +mkdir -p "$d" +cd "$d" +cat > Main.java <<'EOF' +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.*; + +public class Main { + static String sha256(byte[] b) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] d = md.digest(b); + StringBuilder sb = new StringBuilder(); + for (byte x : d) sb.append(String.format("%02x", x)); + return sb.toString(); + } + + public static void main(String[] args) throws Exception { + // Collections. + Map m = new HashMap<>(); + for (int i = 0; i < 1000; i++) m.put(i, i * i); + long collSum = 0; + for (int v : m.values()) collSum += v; + + // File I/O + digest. Relative to the scratch dir the script cd'd into, + // so it stays inside the rootfs with no second absolute path to drift. + Path p = Paths.get("data.bin"); + byte[] payload = new byte[65536]; + for (int i = 0; i < payload.length; i++) payload[i] = (byte) (i & 0xff); + Files.write(p, payload); + byte[] back = Files.readAllBytes(p); + if (!Arrays.equals(payload, back)) { + System.err.println("file io mismatch"); + System.exit(1); + } + String digest = sha256(back); + + // 8 worker threads. + ExecutorService ex = Executors.newFixedThreadPool(8); + List> fs = new ArrayList<>(); + for (int t = 0; t < 8; t++) { + final int base = t; + fs.add(ex.submit(() -> { + int s = 0; + for (int i = 0; i < 100000; i++) s += (base + i) & 7; + return s; + })); + } + long threadSum = 0; + for (Future f : fs) threadSum += f.get(); + ex.shutdown(); + + // Subprocess. + Process pr = new ProcessBuilder("/bin/echo", "child-ok") + .redirectErrorStream(true).start(); + String childOut = new String(pr.getInputStream().readAllBytes()).trim(); + int rc = pr.waitFor(); + if (rc != 0 || !childOut.equals("child-ok")) { + System.err.println("subprocess failed: " + childOut); + System.exit(1); + } + + if (collSum <= 0 || threadSum <= 0 || digest.length() != 64) { + System.err.println("sanity failed"); + System.exit(1); + } + System.out.println("elfuse-oci-jvm-workload-ok"); + } +} +EOF + +javac Main.java +java Main diff --git a/scripts/ci/workloads/node-compute.js b/scripts/ci/workloads/node-compute.js new file mode 100644 index 00000000..55184af3 --- /dev/null +++ b/scripts/ci/workloads/node-compute.js @@ -0,0 +1,49 @@ +'use strict'; +// In-guest compute half of the elfuse-oci node image CI (minus the server): core-module loading, a few hundred small-file +// writes read back and hashed, a zlib gzip round-trip, JSON round-trip, and a +// crypto self-check. Prints one sentinel token on full success; any failure +// exits non-zero with a diagnostic. Passed to the guest via `node -e`. + +const crypto = require('crypto'); +const zlib = require('zlib'); +const fs = require('fs'); +const path = require('path'); + +const DIR = '/tmp/elfuse-node'; +fs.mkdirSync(DIR, { recursive: true }); + +// fs write/read fan-out, hashed back so a lost or corrupted file is caught. +const h = crypto.createHash('sha256'); +const N = 400; +for (let i = 0; i < N; i++) { + const p = path.join(DIR, 'f' + i); + fs.writeFileSync(p, 'elfuse-node-' + i + '\n'); + h.update(fs.readFileSync(p)); +} +if (h.digest('hex').length !== 64) { + console.error('file digest wrong length'); + process.exit(1); +} + +// zlib gzip round-trip over a non-trivial buffer. +const payload = Buffer.alloc(1 << 16, 0x61); +if (!zlib.gunzipSync(zlib.gzipSync(payload)).equals(payload)) { + console.error('zlib round-trip mismatch'); + process.exit(1); +} + +// JSON round-trip. +const doc = { items: Array.from({ length: 256 }, (_, i) => ({ k: i, v: 'tok-' + i })) }; +const back = JSON.parse(JSON.stringify(doc)); +if (back.items.length !== 256 || back.items[255].v !== 'tok-255') { + console.error('json round-trip mismatch'); + process.exit(1); +} + +// crypto self-check against a fixed vector. +if (crypto.createHash('sha256').update('elfuse').digest('hex').length !== 64) { + console.error('sha256 self-check failed'); + process.exit(1); +} + +console.log('elfuse-oci-node-compute-ok'); diff --git a/scripts/ci/workloads/node-server.js b/scripts/ci/workloads/node-server.js new file mode 100644 index 00000000..1437895a --- /dev/null +++ b/scripts/ci/workloads/node-server.js @@ -0,0 +1,34 @@ +'use strict'; +// HTTP-server half of the elfuse-oci node image CI. +// elfuse forwards socket syscalls to host sockets and does no network-namespace +// isolation, so a guest bound to 127.0.0.1 is reachable from the host loopback; +// the driver curls it repeatedly, then GET /quit exits the guest 0. This +// exercises node's accept4/epoll_pwait/writev/shutdown signature. Passed to the +// guest via `node -e`. +// +// The listener binds port 0 (an ephemeral port the kernel picks) and prints +// "PORT=" on stdout so the driver reads the exact port back; a fixed port +// would collide with a leaked or concurrent guest on the shared host loopback. + +const http = require('http'); + +const server = http.createServer((req, res) => { + if (req.url === '/quit') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('bye\n'); + // Close the listener and exit 0 so the run reports a clean shutdown. + server.close(() => process.exit(0)); + return; + } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('elfuse-node-server-ok\n'); +}); + +server.on('error', (e) => { + console.error('server error: ' + e.message); + process.exit(1); +}); + +server.listen(0, '127.0.0.1', () => { + console.log('PORT=' + server.address().port); +}); diff --git a/scripts/ci/workloads/python-workload.py b/scripts/ci/workloads/python-workload.py new file mode 100644 index 00000000..16d7fab5 --- /dev/null +++ b/scripts/ci/workloads/python-workload.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Guest workload for the elfuse-oci python image CI. + +A deliberately single-threaded exercise of a dynamically-linked glibc python +guest: a small SQLite insert plus an aggregate query, a few dozen files written, +read back, and checksummed, and a JSON round-trip. On full success it prints a +single sentinel token the workflow asserts on; any failure exits non-zero with a +diagnostic. The concurrent/subprocess-heavy stress variant lives on the +oci/workload-stress branch. + +Self-contained (stdlib only) so it runs under the image's bundled Python with no +network or extra packages, and is passed to the guest via `python3 -c`. +""" + +import hashlib +import json +import os +import sqlite3 +import sys + +DB = "/tmp/elfuse-workload.db" +TREE = "/tmp/elfuse-workload-tree" +ROWS = 1000 +FILES = 50 + + +def db_ok(): + con = sqlite3.connect(DB) + try: + con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER NOT NULL)") + for n in range(ROWS): + con.execute("INSERT INTO t (n) VALUES (?)", (n,)) + con.commit() + (count,) = con.execute("SELECT COUNT(*) FROM t").fetchone() + (total,) = con.execute("SELECT SUM(n) FROM t").fetchone() + finally: + con.close() + return count == ROWS and total == sum(range(ROWS)) + + +def fs_ok(): + os.makedirs(TREE, exist_ok=True) + for i in range(FILES): + with open(os.path.join(TREE, "f%d" % i), "w") as fh: + fh.write("elfuse-%d\n" % i) + fh.flush() + os.fsync(fh.fileno()) + got = hashlib.sha256() + want = hashlib.sha256() + for i in range(FILES): + with open(os.path.join(TREE, "f%d" % i)) as fh: + got.update(fh.read().encode()) + want.update(("elfuse-%d\n" % i).encode()) + return got.hexdigest() == want.hexdigest() + + +def json_ok(): + doc = {"items": [{"k": i, "v": "tok-%d" % i} for i in range(64)]} + back = json.loads(json.dumps(doc)) + return len(back["items"]) == 64 and back["items"][63]["v"] == "tok-63" + + +def main(): + if not db_ok(): + print("sqlite insert/aggregate check failed", file=sys.stderr) + sys.exit(1) + if not fs_ok(): + print("filesystem write/read checksum mismatch", file=sys.stderr) + sys.exit(1) + if not json_ok(): + print("json round-trip mismatch", file=sys.stderr) + sys.exit(1) + + print("elfuse-oci-python-workload-ok") + + +if __name__ == "__main__": + main() From cff51e7ea6c05bf0fe996951f4cc4e8c155350b4 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Wed, 22 Jul 2026 15:04:19 +0200 Subject: [PATCH 22/22] Add OCI guest execution checks The run smoke proves an image boots and computes; these checks cross the guest-execution seams it does not. A pathname AF_UNIX socket is bound inside a guest-created directory with a getsockname round-trip: the runtime translates sun_path into the sysroot on the way in and must reverse-map it on the way out, and the sparsebundle clone's deep host path additionally forces the over-length shortening indirection. A cold-provision boot (blobs cloned into an ephemeral store with the cs/ bundles dropped, so no network) must report the unpack, and the following warm re-attach of the same digest must boot without unpacking again. A dynamically linked from-image binary runs under an explicit entrypoint so PT_INTERP and its shared-object closure must resolve inside the rootfs. Wired as a runtime-macos Release-leg step beside the run smoke, sharing its warm store (python:3.12-slim joins alpine and debian there). --- .github/workflows/main.yml | 14 ++ scripts/ci/oci-exec-checks.sh | 77 ++++++++++ scripts/ci/workloads/python-workload.py | 191 +++++++++++++++++++----- 3 files changed, 244 insertions(+), 38 deletions(-) create mode 100755 scripts/ci/oci-exec-checks.sh diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 690df999..09e95ebf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -616,6 +616,20 @@ jobs: make build/elfuse-oci scripts/ci/oci-run-smoke.sh + - name: OCI execution checks (unix sockets, cold/warm boot, dynamic interp) + # Guest-execution seams the smoke above does not cross: a pathname + # AF_UNIX socket bound inside the guest with a getsockname + # round-trip, the cold-provision versus warm re-attach boot path, + # and an explicit dynamically linked from-image binary. Shares the + # smoke step's warm store (alpine/debian, plus python:3.12-slim). + # Release leg only. + if: ${{ matrix.run_matrix }} + run: | + set -euo pipefail + export ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-store" + make build/elfuse-oci + scripts/ci/oci-exec-checks.sh + - name: OCI image lifecycle (pull -> inspect -> list -> run -> rmi -> prune) # Walks the whole user-facing image lifecycle on the only leg with # HVF. python:3.12-slim adds what the alpine smoke above does not: diff --git a/scripts/ci/oci-exec-checks.sh b/scripts/ci/oci-exec-checks.sh new file mode 100755 index 00000000..e4b8a81c --- /dev/null +++ b/scripts/ci/oci-exec-checks.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Execution checks for the default `run` path beyond oci-run-smoke.sh: +# pathname AF_UNIX sockets inside the guest (bind, getsockname round-trip, +# connect, payload echo), the cold-provision versus warm re-attach boot +# seam, and explicit dynamic-interpreter resolution from the image. Needs +# macOS with Hypervisor.framework; network only when the store is cold. +# +# Usage: ELFUSE_OCI_STORE= scripts/ci/oci-exec-checks.sh +# shellcheck source=scripts/ci/oci-lib.sh +. "$(dirname "$0")/oci-lib.sh" +require_bin +: "${ELFUSE_OCI_STORE:?set ELFUSE_OCI_STORE to the store directory to use}" + +# Pathname AF_UNIX socket bound inside a guest-created directory. The +# bound name must read back byte-identical through getsockname: under a +# sysroot the runtime translates sun_path on the way in and must +# reverse-map it on the way out, and the sparsebundle clone's deep host +# path forces the over-length shortening indirection as well. +sock_py=' +import socket, threading, os +os.makedirs("/srv-sock", exist_ok=True) +path = "/srv-sock/echo.sock" +srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +srv.bind(path) +assert srv.getsockname() == path, srv.getsockname() +srv.listen(1) +def serve(): + conn, _ = srv.accept() + conn.sendall(conn.recv(64)) + conn.close() +t = threading.Thread(target=serve) +t.start() +cli = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +cli.connect(path) +cli.sendall(b"elfuse-unix-sock-ok") +print(cli.recv(64).decode()) +t.join() +' +out="$("$BIN" run --entrypoint /usr/local/bin/python3 python:3.12-slim \ + -c "$sock_py")" +printf 'unix socket check: %s\n' "$out" +printf '%s\n' "$out" | expect_grep elfuse-unix-sock-ok + +# Cold-provision versus warm re-attach. Clone the warm store's blobs into +# an ephemeral store but drop the cs/ bundles, so the first run must +# provision the sparsebundle and unpack (network-free cold boot) and the +# second must re-attach the warm base without unpacking again. +coldstore="$(mktemp -d)/store" +errf="$(mktemp)" +trap 'rm -rf "$(dirname "$coldstore")" "$errf"' EXIT +cp -Rc "$ELFUSE_OCI_STORE" "$coldstore" +rm -rf "$coldstore/cs" + +out="$(ELFUSE_OCI_STORE=$coldstore "$BIN" run alpine:3 /bin/echo cold-ok \ + 2>"$errf")" +printf '%s\n' "$out" | expect_grep cold-ok +grep -q 'Unpacking' "$errf" || fail "cold boot did not report an unpack" + +out="$(ELFUSE_OCI_STORE=$coldstore "$BIN" run alpine:3 /bin/echo warm-ok \ + 2>"$errf")" +printf '%s\n' "$out" | expect_grep warm-ok +if grep -q 'Unpacking' "$errf"; then + fail "warm re-attach unpacked again" +fi +echo "cold/warm boot check OK" + +# Dynamic-interpreter resolution: run a glibc dynamically linked binary +# from the image explicitly, so PT_INTERP and its .so closure must resolve +# inside the rootfs through path translation. +out="$("$BIN" run --entrypoint /bin/bash debian:stable-slim -c \ + 'echo elfuse-interp-ok')" +printf 'interp check: %s\n' "$out" +printf '%s\n' "$out" | expect_grep elfuse-interp-ok + +"$BIN" prune >/dev/null + +echo "exec checks OK" diff --git a/scripts/ci/workloads/python-workload.py b/scripts/ci/workloads/python-workload.py index 16d7fab5..fb43a962 100644 --- a/scripts/ci/workloads/python-workload.py +++ b/scripts/ci/workloads/python-workload.py @@ -1,74 +1,189 @@ #!/usr/bin/env python3 -"""Guest workload for the elfuse-oci python image CI. +"""Non-trivial guest workload for the elfuse-oci python image CI. -A deliberately single-threaded exercise of a dynamically-linked glibc python -guest: a small SQLite insert plus an aggregate query, a few dozen files written, -read back, and checksummed, and a JSON round-trip. On full success it prints a -single sentinel token the workflow asserts on; any failure exits non-zero with a -diagnostic. The concurrent/subprocess-heavy stress variant lives on the -oci/workload-stress branch. +Mirrors the profiled python workload: JSON/regex churn, a concurrent +SQLite writer set (fcntl locking, fsync, and WAL mmap where the guest FS +supports it), a few hundred small-file writes read back and checksummed, +an os.walk over the bundled standard library, and a batch of interpreter +subprocesses. On full success it prints a single sentinel token the workflow +asserts on; any failure exits non-zero with a diagnostic. -Self-contained (stdlib only) so it runs under the image's bundled Python with no -network or extra packages, and is passed to the guest via `python3 -c`. +Self-contained (stdlib only) so it runs under the image's bundled Python with +no network or extra packages, and is passed to the guest via `python3 -c`. """ import hashlib import json import os +import re import sqlite3 +import subprocess import sys +import threading DB = "/tmp/elfuse-workload.db" TREE = "/tmp/elfuse-workload-tree" -ROWS = 1000 -FILES = 50 +THREADS = 8 +PER_THREAD = 2500 # 8 * 2500 = 20k inserts, matching the profiled workload +FANOUT = 20 # 20 * 20 = 400 small files, matching the profiled workload +SUBPROCS = 10 -def db_ok(): +def setup_db(): con = sqlite3.connect(DB) try: - con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER NOT NULL)") - for n in range(ROWS): - con.execute("INSERT INTO t (n) VALUES (?)", (n,)) + # WAL exercises the guest FS's shared-memory index (mmap) and is the + # more demanding path; if the FS cannot back it, SQLite reports a + # different mode and the concurrent-writer count check below still + # validates fcntl locking and durable commits under rollback journal. + con.execute("PRAGMA journal_mode=WAL") + con.execute( + "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, " + "tid INTEGER NOT NULL, n INTEGER NOT NULL)" + ) con.commit() + finally: + con.close() + + +def worker(tid): + con = sqlite3.connect(DB, timeout=60) + try: + con.execute("PRAGMA busy_timeout=60000") + for n in range(PER_THREAD): + con.execute("INSERT INTO t (tid, n) VALUES (?, ?)", (tid, n)) + con.commit() + finally: + con.close() + + +def db_count(): + con = sqlite3.connect(DB, timeout=60) + try: (count,) = con.execute("SELECT COUNT(*) FROM t").fetchone() - (total,) = con.execute("SELECT SUM(n) FROM t").fetchone() + return count + finally: + con.close() + + +def db_query_ok(): + # A GROUP BY aggregate over the 20k rows: every thread must have committed + # exactly PER_THREAD rows, validating the writes are durable and correct, + # not merely counted. + con = sqlite3.connect(DB, timeout=60) + try: + rows = con.execute( + "SELECT tid, COUNT(*) FROM t GROUP BY tid ORDER BY tid" + ).fetchall() finally: con.close() - return count == ROWS and total == sum(range(ROWS)) + return rows == [(tid, PER_THREAD) for tid in range(THREADS)] -def fs_ok(): - os.makedirs(TREE, exist_ok=True) - for i in range(FILES): - with open(os.path.join(TREE, "f%d" % i), "w") as fh: - fh.write("elfuse-%d\n" % i) - fh.flush() - os.fsync(fh.fileno()) +def json_regex_churn(): + # Serialize and reparse a structured document many times, then pull every + # embedded token back out with a regex and confirm the round-trip is exact. + word = re.compile(r"tok-(\d+)") + for r in range(2000): + doc = {"round": r, "items": [{"k": i, "v": "tok-%d" % i} for i in range(16)]} + blob = json.dumps(doc) + back = json.loads(blob) + got = [int(m) for m in word.findall(blob)] + if got != list(range(16)) or back["round"] != r: + return False + return True + + +def content(i, j): + return "elfuse-workload-%d-%d\n" % (i, j) + + +def fs_fanout_ok(): + for i in range(FANOUT): + for j in range(FANOUT): + d = os.path.join(TREE, str(i), str(j)) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, "f"), "w") as fh: + fh.write(content(i, j)) + fh.flush() + os.fsync(fh.fileno()) + read_back = [] + for i in range(FANOUT): + for j in range(FANOUT): + with open(os.path.join(TREE, str(i), str(j), "f")) as fh: + read_back.append(fh.read()) + expected = [content(i, j) for i in range(FANOUT) for j in range(FANOUT)] got = hashlib.sha256() + for p in sorted(read_back): + got.update(p.encode()) want = hashlib.sha256() - for i in range(FILES): - with open(os.path.join(TREE, "f%d" % i)) as fh: - got.update(fh.read().encode()) - want.update(("elfuse-%d\n" % i).encode()) + for p in sorted(expected): + want.update(p.encode()) return got.hexdigest() == want.hexdigest() -def json_ok(): - doc = {"items": [{"k": i, "v": "tok-%d" % i} for i in range(64)]} - back = json.loads(json.dumps(doc)) - return len(back["items"]) == 64 and back["items"][63]["v"] == "tok-63" +def walk_stdlib_ok(): + # os.walk the bundled standard library and count .py modules. This exercises + # getdents/newfstatat over a deep real tree; the exact count varies by + # patch release, so only assert it is unmistakably a full stdlib. + root = os.path.dirname(os.__file__) + n = 0 + for _, _, files in os.walk(root): + n += sum(1 for f in files if f.endswith(".py")) + return n > 200 + + +def subprocesses_ok(): + # Fork/exec the interpreter repeatedly; each child echoes a token this + # parent verifies, exercising execve of a dynamically-linked glibc binary. + for i in range(SUBPROCS): + code = "print('child-%d')" % i + out = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if out != "child-%d" % i: + return False + return True def main(): - if not db_ok(): - print("sqlite insert/aggregate check failed", file=sys.stderr) + setup_db() + threads = [threading.Thread(target=worker, args=(i,)) for i in range(THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + + count = db_count() + if count != THREADS * PER_THREAD: + print( + "sqlite row count %d != %d (concurrent writers lost rows)" + % (count, THREADS * PER_THREAD), + file=sys.stderr, + ) sys.exit(1) - if not fs_ok(): - print("filesystem write/read checksum mismatch", file=sys.stderr) + + if not db_query_ok(): + print("sqlite per-thread aggregate mismatch", file=sys.stderr) sys.exit(1) - if not json_ok(): - print("json round-trip mismatch", file=sys.stderr) + + if not json_regex_churn(): + print("json/regex round-trip mismatch", file=sys.stderr) + sys.exit(1) + + if not fs_fanout_ok(): + print("filesystem fan-out checksum mismatch", file=sys.stderr) + sys.exit(1) + + if not walk_stdlib_ok(): + print("stdlib walk found too few modules", file=sys.stderr) + sys.exit(1) + + if not subprocesses_ok(): + print("subprocess output mismatch", file=sys.stderr) sys.exit(1) print("elfuse-oci-python-workload-ok")