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/.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 6276c986..09e95ebf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,15 @@ # 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 +# 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 @@ -94,12 +102,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 +560,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 +598,58 @@ 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 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: + # 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 +681,272 @@ 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 + + # 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/Makefile b/Makefile index a5ab62fc..2762a512 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 \ @@ -39,7 +40,8 @@ SRCS := \ syscall/mem.c \ syscall/path.c \ syscall/fuse.c \ - syscall/sidecar.c \ + syscall/casefold.c \ + syscall/casefold-walk.c \ syscall/chown-overlay.c \ syscall/fs.c \ syscall/fs-stat.c \ @@ -100,6 +102,8 @@ endef .PHONY: all elfuse .PHONY: gen-syscall-dispatch check-syscall-dispatch +# 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 @@ -125,6 +129,56 @@ 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) + +# 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)$(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) @@ -192,6 +246,29 @@ $(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 $@ $^ + +## 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. @@ -214,6 +291,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/README.md b/README.md index 99bc6a2d..cd3ee18e 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 @@ -52,8 +55,9 @@ 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 +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 @@ -62,7 +66,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 @@ -101,18 +105,56 @@ 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. +- [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 / 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 @@ -123,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 ``` @@ -141,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/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..6b80d31e --- /dev/null +++ b/cmd/elfuse-oci/bundlelock_test.go @@ -0,0 +1,208 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + + "golang.org/x/sys/unix" +) + +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) + } +} + +// 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 +// 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/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 new file mode 100644 index 00000000..d9651432 --- /dev/null +++ b/cmd/elfuse-oci/cache_key.go @@ -0,0 +1,40 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "path/filepath" + + "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" +) + +// 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 +} 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 new file mode 100644 index 00000000..aeeddfff --- /dev/null +++ b/cmd/elfuse-oci/commands.go @@ -0,0 +1,302 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +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) + 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 +} + +// 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 + } + // 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 + } + } + // 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.Fprintf(os.Stderr, "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", "", "unpack into DIR (default: the store's digest-keyed rootfs cache)") + 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) + 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, "print the raw image config JSON") + if err := fs.Parse(args); err != nil { + return cf, false, "", err + } + 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 + } + // 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 err := pullImage(cf, s, ref); 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 + } + // 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) + } + } + // 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 { + // 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. 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 + // 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(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 + } + // 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 := prepareRootfsForRun(rf.rootfs, spec); err != nil { + return err + } + return execElfuseForRun(rf.rootfs, spec, rootfsLock) +} + +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", "", "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 + } + 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/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.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..0946abe3 --- /dev/null +++ b/cmd/elfuse-oci/common_test.go @@ -0,0 +1,181 @@ +// 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 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 { + t.Fatal(err) + } + if !asJSON { + t.Error("asJSON = false, want true") + } + if ref != "alpine:3" { + t.Errorf("ref = %q, want alpine:3", ref) + } +} + +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", + "--plain-rootfs", + "--sparse-size", "32g", + "--no-clone", + "--keep", + "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.plainRootfs || rf.sparseSize != "32g" || !rf.noClone || !rf.keepRootfs { + t.Errorf("sparse 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 + 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 }}, + {"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 { + 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 new file mode 100644 index 00000000..115a2ba4 --- /dev/null +++ b/cmd/elfuse-oci/csrun.go @@ -0,0 +1,221 @@ +// 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 + 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) { + 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 string, img v1.Image, 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) + // 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)) + } + } + 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 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 + } + // 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). 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 + 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 { + 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 + 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. + 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, 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) + } 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..d40579b2 --- /dev/null +++ b/cmd/elfuse-oci/csrun_darwin_test.go @@ -0,0 +1,513 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "archive/tar" + "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 + oldSidecar := writeKeptSidecarForRun + oldExit := osExitForRun + oldNow := runNowUnixNano + t.Cleanup(func() { + ensureCaseSensitiveRootfsForRun = oldEnsure + clonefileForRun = oldClone + spawnElfuseWaitForRun = oldSpawn + cleanupCloneAndMountForRun = oldCleanup + writeKeptSidecarForRun = oldSidecar + 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 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) + } + 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", + nil, + "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, v1.Image, 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 + } + // --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() { + r := recover() + code, ok := r.(runExitCode) + 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", nil, "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, 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(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", nil, "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) + } + img, err := s.image("local:tiny") + if err != nil { + t.Fatal(err) + } + + m, rootfs, err := ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:tiny", img, 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) + } + img, err := s.image("local:tiny") + if err != nil { + t.Fatal(err) + } + + m, gotRootfs, err := ensureCaseSensitiveRootfs(commonFlags{store: s.root}, s, "local:tiny", img, 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) + + // 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 { + 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 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) + } + 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) +} + +// 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 new file mode 100644 index 00000000..972310af --- /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 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/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/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 new file mode 100644 index 00000000..fb18f2f6 --- /dev/null +++ b/cmd/elfuse-oci/inspect.go @@ -0,0 +1,95 @@ +// 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 { + // 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 + } + 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 + + 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\n", "Platform:", platform) + 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..ea5a7cc5 --- /dev/null +++ b/cmd/elfuse-oci/inspect_test.go @@ -0,0 +1,229 @@ +// 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) + } + } +} + +// 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") { + 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/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 new file mode 100644 index 00000000..4bf4bf84 --- /dev/null +++ b/cmd/elfuse-oci/main.go @@ -0,0 +1,141 @@ +// 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, 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: +// +// 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...] +// 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:...). `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 ( + "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 + 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 + +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 + --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) + +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 +`) +} + +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 "unpack": + return cmdUnpack(rest) + case "inspect": + 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 new file mode 100644 index 00000000..7a761c07 --- /dev/null +++ b/cmd/elfuse-oci/main_command_test.go @@ -0,0 +1,153 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +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) { + 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) + } +} + +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/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/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 new file mode 100644 index 00000000..9adf0201 --- /dev/null +++ b/cmd/elfuse-oci/run.go @@ -0,0 +1,152 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "syscall" +) + +var execElfuseForRun = execElfuse + +// 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 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") + } + if _, err := os.Stat(bin); err != nil { + return "", fmt.Errorf("elfuse binary not found at %s (set $ELFUSE_BIN): %w", bin, err) + } + return bin, nil +} + +// 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 +// precedence matrix) rather than the host environ. +// +// "--" 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, + "--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, "--") + argv = append(argv, spec.Args...) + return argv +} + +// 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. +// +// 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 lock != nil { + if err := lock.PreserveAcrossExec(); err != nil { + return fmt.Errorf("preserve rootfs lock across exec: %w", err) + } + } + 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 +// 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 := resolveElfuseBin() + if err != nil { + return 0, 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 new file mode 100644 index 00000000..de9710d9 --- /dev/null +++ b/cmd/elfuse-oci/run_test.go @@ -0,0 +1,203 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// writeElfuseStub writes a #!/bin/sh stub script that stands in for the +// 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 { + 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 +} + +// 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 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 := resolveElfuseBin() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("resolveElfuseBin with env = %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}, nil) + 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) + } + } +} + +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 new file mode 100644 index 00000000..ee7d31c4 --- /dev/null +++ b/cmd/elfuse-oci/runspec.go @@ -0,0 +1,363 @@ +// 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 + + // 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. +// +// 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/sparsebundle.go b/cmd/elfuse-oci/sparsebundle.go new file mode 100644 index 00000000..1e67ac61 --- /dev/null +++ b/cmd/elfuse-oci/sparsebundle.go @@ -0,0 +1,303 @@ +// 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")) +} + +// 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..8d106232 --- /dev/null +++ b/cmd/elfuse-oci/sparsebundle_test.go @@ -0,0 +1,527 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +// 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) + } + } + }) + } +} + +// 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 +// 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 new file mode 100644 index 00000000..71edcd86 --- /dev/null +++ b/cmd/elfuse-oci/store.go @@ -0,0 +1,425 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "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 + } + // 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:..."). +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 + } + // 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 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 + } + 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 + } + for _, h := range digests { + p := filepath.Join(s.root, "blobs", h.Algorithm, h.Hex) + if err := fsyncFile(p); err != nil { + return err + } + } + if err := fsyncFile(filepath.Join(s.root, "index.json")); err != nil { + return err + } + return fsyncDir(s.root) +} + +// 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 nil, err + } + 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 +// 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) { + // 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: lock: %w", err) + } + return func() { _ = l.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 +} + +// 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 +// 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) + } + // 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()) + }) + 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_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/cmd/elfuse-oci/store_test.go b/cmd/elfuse-oci/store_test.go new file mode 100644 index 00000000..b43deaf4 --- /dev/null +++ b/cmd/elfuse-oci/store_test.go @@ -0,0 +1,563 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "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) + } +} + +// 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) + 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) + } + }) + } +} + +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") + } + 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) + } +} + +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 new file mode 100644 index 00000000..3d805f82 --- /dev/null +++ b/cmd/elfuse-oci/test_helpers_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 elfuse contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "archive/tar" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "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" +) + +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 + } + 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, nil); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(98) + } + os.Exit(99) + } + os.Exit(m.Run()) +} + +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 +} + +// 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 new file mode 100644 index 00000000..f6bff989 --- /dev/null +++ b/cmd/elfuse-oci/unpack.go @@ -0,0 +1,485 @@ +// 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" +) + +// 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). +// - `.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(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. + 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)+rootfsStagingSuffix) + 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 +} + +// 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 { + 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). + lp := newLayerPaths() + 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, lp); 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. 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) + } + 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), lp) + } + 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) + } + 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 + // 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, lp layerPaths) 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 lp.entries[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()) + // 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 { + 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..8a805970 --- /dev/null +++ b/cmd/elfuse-oci/unpack_image_test.go @@ -0,0 +1,645 @@ +// 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 +} + +// 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 + 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 := unpackRef(t, 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 := 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) { + 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 := unpackRef(t, 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 := unpackRef(t, 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(""), newLayerPaths()); 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(""), newLayerPaths()); 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"), newLayerPaths()); 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(""), 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) + } + }) + + 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(""), newLayerPaths()); 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(""), 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(""), 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")) + 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(""), newLayerPaths()); 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(""), 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(""), 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")) + 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) + 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(""), lp); 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 := unpackRef(t, 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) + } +} + +// 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. +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(""), newLayerPaths()); 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 := unpackRef(t, 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 := unpackRef(t, 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 := unpackRef(t, 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 := unpackRef(t, 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..cd0698dc --- /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), newLayerPaths()); 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), newLayerPaths()); 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(""), 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) + } + 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(""), newLayerPaths()); 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, newLayerPaths()); 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, newLayerPaths()); 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") + } +} diff --git a/docs/filenames.md b/docs/filenames.md new file mode 100644 index 00000000..bce7d31e --- /dev/null +++ b/docs/filenames.md @@ -0,0 +1,482 @@ +# 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 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 + +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. + +## 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. + +## 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; `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 +`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: +`readlink` has to return what was written, so a target cannot be translated on +the way in. + +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 + +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 +``` + +`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 +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 e6732d48..be7377f5 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 | @@ -802,6 +803,10 @@ Resolving in `path_translate_at` instead means `chmod`, `chown`, `truncate`, `statfs`, and the xattr calls all inherit the backing path from one place, so an `open` followed by any of them on the same name stays consistent. +The early return also takes shm objects out of sysroot resolution entirely, so +the `/tmp/elfuse-shm-` backing directory is reached as a host path and is +unaffected by the sysroot's redirect of guest `/tmp`. + Only a non-empty flat leaf is redirected. Bare `/dev/shm` and `/dev/shm/` stay on the sysroot path so the synthetic-directory intercepts keep answering for them, and `statfs` on a shm leaf or on `/dev/shm` reports `TMPFS_MAGIC` @@ -865,18 +870,37 @@ 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. +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. + +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 -correctly. `elf_resolve_interp()` in `src/core/elf.c` is shared between -`src/main.c` and `src/syscall/exec.c`. +correctly. The two loaders resolve `PT_INTERP` differently by design: +guest-issued execs route it through `path_translate_at()` like any other +guest path, while the initial process is loaded by the core bootstrap, +which cannot call into the syscall translator and keeps +`elf_resolve_interp()` (`src/core/elf.c`), a literal sysroot +concatenation plus a `/lib/` fallback. ### 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 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 c42e1b72..15754bd0 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,7 +33,34 @@ 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`. +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: - `CROSS_COMPILE` @@ -46,7 +74,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 +88,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" ``` @@ -79,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 ``` @@ -102,6 +136,28 @@ 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 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`) @@ -111,6 +167,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")` @@ -118,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 @@ -148,6 +215,98 @@ 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) +``` + +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/ +``` + +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. +- **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 + +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. + ## Test Matrix The matrix driver lives in `tests/test-matrix.sh`. It currently covers three @@ -178,6 +337,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 @@ -358,6 +536,8 @@ 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` | +| 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 bef8aca2..b994559b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -17,7 +17,7 @@ Supported user-facing options: | `-V`, `--version` | Print the build version and exit | | `-v`, `--verbose` | Enable syscall-level and loader diagnostics | | `-t`, `--timeout N` | Per-iteration vCPU watchdog, in seconds (default `10`, `0` disables) | -| `--sysroot PATH` | Resolve guest absolute paths under `PATH` first | +| `--sysroot PATH` | Resolve guest absolute paths under `PATH`, falling back to the host for paths it does not hold | | `--create-sysroot PATH` | Provision a case-sensitive APFS sparsebundle mounted at `PATH`, then use it as the sysroot | | `--no-rosetta` | Disable the x86_64-via-Rosetta translator (also `ELFUSE_NO_ROSETTA=1`) | | `--gdb PORT` | Listen for a GDB RSP client on `PORT` (aarch64 guests only) | @@ -56,7 +56,9 @@ anything else that inspects `$?`. ### Worked Examples The guest reads and writes the host filesystem directly (no overlay, -no volume mount), so file arguments are just file arguments. +no volume mount), so file arguments are just file arguments. Under +`--sysroot` the temporary directories are the exception; see +[Dynamic Linking And Sysroots](#dynamic-linking-and-sysroots). Run a Linux static `jq` against a host JSON file: @@ -138,8 +140,8 @@ the SHA-256-keyed translations. Dynamic Linux guests need a sysroot that contains the expected interpreter and shared libraries. `elfuse` reads `PT_INTERP`, loads the requested interpreter -from the supplied sysroot, and redirects guest absolute-path opens to that tree -before falling back to the host filesystem. +from the supplied sysroot, and redirects guest absolute-path opens to that tree, +falling back to the host filesystem for paths the sysroot does not hold. Example: @@ -153,15 +155,29 @@ 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 + `stat`, `open`, and directory listings, addresses the same place. Host files + under those directories are therefore invisible to the guest, and a guest + program or interpreter stored there cannot be loaded; pass one from anywhere + 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 @@ -212,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. diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..69efb907 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/sysprog21/elfuse + +go 1.26.4 + +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 + 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 + 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/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/config.mk b/mk/config.mk index 969a39fc..41beb8e5 100644 --- a/mk/config.mk +++ b/mk/config.mk @@ -21,7 +21,10 @@ 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/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 3cbc1c58..cc81e2cb 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -11,13 +11,26 @@ test-matrix test-matrix-elfuse-aarch64 test-matrix-qemu-aarch64 \ test-full test-multi-vcpu test-rwx test-sysroot-rename \ test-case-collision test-case-collision-fallback test-getdents64-overlong \ - test-sysroot-host-fallback test-sysroot-case-exact \ + test-sysroot-tmp-remove test-sysroot-host-fallback test-sysroot-case-exact \ test-sysroot-create-paths test-fork-ipc-protocol-host \ test-vcpu-run-hooks-host test-identity-override-host \ 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-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 \ + test-nosysroot-literal-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 \ + 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 test-hello: $(ELFUSE_BIN) $(TEST_HELLO_DEP) @@ -80,7 +93,9 @@ 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 \ + $(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 @@ -92,6 +107,20 @@ 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 + @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)━━━ 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 \ @@ -99,7 +128,9 @@ 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 \ + $(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 @@ -111,6 +142,28 @@ 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)━━━ 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)━━━ 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)━━━ 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" @@ -125,10 +178,34 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage \ @$(MAKE) --no-print-directory test-getdents64-overlong @printf "\n$(BLUE)━━━ sysroot host-fallback validation ━━━$(RESET)\n" @$(MAKE) --no-print-directory test-sysroot-host-fallback + @printf "\n$(BLUE)━━━ sysroot /tmp remove/rename consistency ━━━$(RESET)\n" + @$(MAKE) --no-print-directory test-sysroot-tmp-remove @printf "\n$(BLUE)━━━ sysroot byte-exact lookup validation ━━━$(RESET)\n" @$(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)━━━ 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)━━━ 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)━━━ 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" + @$(MAKE) --no-print-directory test-nosysroot-literal-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" @@ -252,7 +329,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 @@ -276,12 +353,43 @@ test-sysroot-host-fallback: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-host-fallbac $(BUILD_DIR)/test-sysroot-host-fallback \ "$$hostdir" "$$mirror/final.txt" "$$mirror/both.txt" +## Stages a directory under the runner's own /tmp, the one prefix the guest +## addresses through the redirect rather than through the host-literal fallback, +## so a guest that still reached the host would see entries it cannot remove. +## The sysroot itself comes from TMPDIR (/var/folders on macOS), which is not +## redirected, so the harness can inspect where the guest's own writes landed. +## Placement is asserted without spelling any name under the sysroot: on a +## case-insensitive volume the name codec may store any component under an +## escaped spelling, so the harness checks only that the redirect populated the +## sysroot's /tmp at all. The guest side proves the file itself by reading it +## back. +## Verify guest /tmp resolves through the sysroot for lookups as well as creates +test-sysroot-tmp-remove: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-tmp-remove + @set -e; \ + tmpdir=$$(mktemp -d); \ + hostdir=$$(mktemp -d /tmp/elfuse-tmp-remove.XXXXXX); \ + trap 'rm -rf "$$tmpdir" "$$hostdir"' EXIT; \ + sysroot="$$tmpdir/sysroot"; \ + mkdir -p "$$sysroot" "$$hostdir/d"; \ + printf 'x' > "$$hostdir/f"; \ + printf 'x' > "$$hostdir/r"; \ + $(ELFUSE_BIN) --sysroot "$$sysroot" \ + $(BUILD_DIR)/test-sysroot-tmp-remove "$$hostdir"; \ + if [ "$$(ls -A "$$hostdir" | sort | tr '\n' ' ')" != "d f r " ]; then \ + printf "$(RED)FAIL$(RESET) guest reached the host /tmp staging dir\n"; \ + exit 1; \ + fi; \ + if [ ! -d "$$sysroot/tmp" ] || [ -z "$$(ls -A "$$sysroot/tmp")" ]; then \ + printf "$(RED)FAIL$(RESET) guest /tmp write did not land in the sysroot\n"; \ + exit 1; \ + fi + ## 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; \ @@ -301,6 +409,493 @@ 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 + +## 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; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-symlink-target; \ + if [ -e "/symlink-target" ]; then \ + printf "$(RED)FAIL$(RESET) a create through a link escaped to the host root\n"; \ + exit 1; \ + 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 + +# 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 ' '); \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-absock-names; \ + if [ -e "/sockdir" ]; then \ + printf "$(RED)FAIL$(RESET) a socket path escaped to the host root\n"; \ + exit 1; \ + 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 +# 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 +# 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 +# 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); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + $(ELFUSE_BIN) --sysroot "$$tmpdir" \ + $(BUILD_DIR)/test-sysroot-inotify-names; \ + 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 + +## 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" + +# 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" + +# 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 @@ -328,14 +923,18 @@ test-sysroot-create-paths: $(ELFUSE_BIN) $(BUILD_DIR)/test-sysroot-create-paths mounted_tmp="$$tmpdir/case-sysroot/tmp/elfuse-sysroot-create-paths/file.txt"; \ host_out_dir="$$tmpdir/host-out"; \ host_out="$$host_out_dir/result.txt"; \ - trap 'rm -rf "$$tmpdir"; rm -rf /tmp/elfuse-sysroot-create-paths' EXIT; \ - rm -rf /tmp/elfuse-sysroot-create-paths; \ + trap 'rm -rf "$$tmpdir"; rm -rf /tmp/elfuse-sysroot-create-paths /tmp/race_dir' EXIT; \ + rm -rf /tmp/elfuse-sysroot-create-paths /tmp/race_dir; \ mkdir -p "$$host_out_dir"; \ $(ELFUSE_BIN) --create-sysroot "$$tmpdir/case-sysroot" $(BUILD_DIR)/test-sysroot-create-paths "$$guest_tmp" "$$mounted_tmp" "$$host_out" "$$tmpdir/case-sysroot"; \ if [ -e "$$guest_tmp" ]; then \ printf "$(RED)FAIL$(RESET) guest /tmp escaped to host /tmp\n"; \ exit 1; \ fi; \ + if [ -e /tmp/race_dir ]; then \ + printf "$(RED)FAIL$(RESET) TOCTOU race created a directory outside the sysroot\n"; \ + exit 1; \ + fi; \ if [ ! -f "$$host_out" ]; then \ printf "$(RED)FAIL$(RESET) host fallback path was not created\n"; \ exit 1; \ @@ -775,6 +1374,23 @@ 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 + +# 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 + $(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/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 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-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/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..1187ffd3 --- /dev/null +++ b/scripts/ci/oci-lifecycle.sh @@ -0,0 +1,135 @@ +#!/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 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 +# 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 + +# reap_guest (in oci-lib.sh) keeps a failed phase from leaking the +# backgrounded guest. +guest="" +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 +# 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'" +} + +# 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-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/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..fb43a962 --- /dev/null +++ b/scripts/ci/workloads/python-workload.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Non-trivial guest workload for the elfuse-oci python image CI. + +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`. +""" + +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" +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 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 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 rows == [(tid, PER_THREAD) for tid in range(THREADS)] + + +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 p in sorted(expected): + want.update(p.encode()) + return got.hexdigest() == want.hexdigest() + + +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(): + 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 db_query_ok(): + print("sqlite per-thread aggregate mismatch", file=sys.stderr) + sys.exit(1) + + 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") + + +if __name__ == "__main__": + main() 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. diff --git a/src/core/launch.c b/src/core/launch.c new file mode 100644 index 00000000..5cb56e61 --- /dev/null +++ b/src/core/launch.c @@ -0,0 +1,244 @@ +/* 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 +#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/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" + +/* 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; + + /* 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, + 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; + } + + /* 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; + 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); + } + + /* 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) < + 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..d946197d --- /dev/null +++ b/src/core/launch.h @@ -0,0 +1,90 @@ +/* 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) stay coherent however the launch was kicked off. + * + * 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 + +#include +#include + +typedef struct { + /* Host path to the guest ELF; may be a FUSE-materialized temp when + * elf_host_temp is set. + */ + const char *elf_path; + + /* 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; + + /* Host filesystem path to the sysroot the guest sees as / (absolute), + * or NULL when the guest runs without a sysroot. + */ + const char *sysroot; + + /* 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; + + /* 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; + + /* 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. + */ + 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..7afa085b 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" @@ -108,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, @@ -135,18 +252,6 @@ 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. - */ -_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 * offset without the others would silently overlap two regions. Enforce them at @@ -238,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(); @@ -261,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 @@ -294,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" @@ -314,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; } } @@ -377,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 @@ -423,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) @@ -438,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; } @@ -463,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; } } @@ -478,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) { @@ -615,122 +843,76 @@ 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. + /* 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 (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(); + 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; } - - /* vcpu_run_loop owns guest execution until exit, fatal signal, or timeout. + /* build_guest_env strdups its own copies, so the raw override array and + * its strings are no longer needed. */ - 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. - */ - 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. + 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. */ - 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 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. */ - 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, + .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, + .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. 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/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/casefold-walk.c b/src/syscall/casefold-walk.c new file mode 100644 index 00000000..adcbbf7a --- /dev/null +++ b/src/syscall/casefold-walk.c @@ -0,0 +1,507 @@ +/* + * 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 +/* fsobj_type_t and VLNK, for the ATTR_CMN_OBJTYPE the probe requests. */ +#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 */ + /* 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 + * 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; + if (errno == ENOTDIR) + return PROBE_NOTDIR; + return errno == ENOENT ? 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, + 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_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 + * 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: + 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 + * 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, + bool *is_link) +{ + char probe_path[LINUX_PATH_MAX]; + size_t probe_len = len; + probe_result_t verdict; + + *present = false; + + /* An escape-shaped guest name is stored escaped unconditionally, so it can + * never be mistaken for the encoding of a different name. Probing its + * literal spelling would find some unrelated file. + */ + if (!casefold_is_escaped(guest)) { + if (str_copy_trunc(probe_path, out, sizeof(probe_path)) >= + sizeof(probe_path)) { + errno = ENAMETOOLONG; + return PROBE_ERROR; + } + if (append_component(probe_path, sizeof(probe_path), &probe_len, + guest) < 0) + return PROBE_ERROR; + + verdict = probe_exact(base_fd, probe_path, guest, is_link); + 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, is_link)) { + 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 || 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 + * 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->link_rest_offset = 0; + walk->link_guest_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) { + 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 { + 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 + * to the host: something is already there. + */ + 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; + } + + 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..720b088b --- /dev/null +++ b/src/syscall/casefold-walk.h @@ -0,0 +1,123 @@ +/* + * 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 */ + /* 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 { + /* 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; + /* 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; + /* 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 + * 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.c b/src/syscall/casefold.c new file mode 100644 index 00000000..5273e0bd --- /dev/null +++ b/src/syscall/casefold.c @@ -0,0 +1,395 @@ +/* + * 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: 4096 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 4096u + +/* Bits each payload symbol carries. Twelve packs three input bytes into two + * symbols, so a 255-byte name costs 170 symbols and stays far inside the + * 255-unit budget. + */ +#define CASEFOLD_SYM_BITS 12u + +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..3c730a7d --- /dev/null +++ b/src/syscall/casefold.h @@ -0,0 +1,111 @@ +/* + * 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) + +/* Symbols the long tier spends on a guest name of n bytes: one carrying the + * length, then one per 12 bits of payload. Computed in size_t because the + * result sizes buffers. + */ +#define CASEFOLD_SYMBOLS(n) ((size_t) 1 + ((size_t) 2 * (n) + 2) / 3) + +/* Longest host spelling any escaped name can take, in bytes. Each symbol is a + * 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 + \ + (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 + * 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/src/syscall/exec.c b/src/syscall/exec.c index 27b71dbb..21e50af1 100644 --- a/src/syscall/exec.c +++ b/src/syscall/exec.c @@ -209,31 +209,84 @@ 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 keeps + * elf_resolve_interp because the core layer cannot call into the syscall + * translator. + */ +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 @@ -386,17 +439,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 +456,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. @@ -762,11 +814,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/src/syscall/fs.c b/src/syscall/fs.c index a7ce56af..edab1a48 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; @@ -1632,10 +1610,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, + int name_rc = path_translate_dirent_name(de->d_name, guest_name, sizeof(guest_name)); - if (name_rc > 0) - continue; 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 +1933,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 +1977,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 +2031,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 +2355,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/inotify.c b/src/syscall/inotify.c index 3fe0070a..4cb4739e 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); @@ -356,6 +357,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(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 +382,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 +654,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/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 9478ae44..d5a724cc 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; @@ -552,13 +554,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) < @@ -570,7 +583,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 f0488476..b591ac2b 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); @@ -850,10 +849,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; @@ -952,7 +951,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) { uint32_t actual_len = (uint32_t) out_len; diff --git a/src/syscall/path.c b/src/syscall/path.c index 2abbe415..0b53ee05 100644 --- a/src/syscall/path.c +++ b/src/syscall/path.c @@ -19,23 +19,14 @@ #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 -#endif #define PROC_PATH_COMPONENTS_MAX (LINUX_PATH_MAX / 2) -/* True when path equals prefix exactly, or extends it with '/'. Avoids the - * surprise where "/sys/devices/system/cpufoo" would match a bare strncmp on - * "/sys/devices/system/cpu" and pull an unrelated path through the intercept - * layer. - */ -static bool path_prefix_match(const char *path, const char *prefix, size_t plen) +bool path_prefix_match(const char *path, const char *prefix, size_t plen) { if (strncmp(path, prefix, plen) != 0) return false; @@ -152,10 +143,61 @@ 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. + */ +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, + 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, @@ -170,8 +212,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; @@ -208,8 +250,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. */ @@ -223,12 +265,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), @@ -254,25 +290,75 @@ 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; + 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) < - 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; } - /* 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()) { + /* 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; + + 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, 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; } if (!tx->host_path) { @@ -285,11 +371,37 @@ 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, - const char *host_name, +int path_translate_dirent_name(const char *host_name, char *guest_name, size_t guest_name_sz) { @@ -298,43 +410,26 @@ 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 sysroot that folds case holds escaped names, so only then is there + * anything to decode. Everywhere else 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. + */ + if (!casefold_active()) { + 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; -} - -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; + /* Decoding an on-disk name needs nothing but the name: no directory to + * consult, 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) @@ -373,6 +468,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) { @@ -408,6 +527,49 @@ 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, 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 (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; + } + scan = sysroot_buf; + } + } + while (path_next_component(&scan, &comp, &len)) { if (++walk_count > MAXSYMLINKS) { errno = ELOOP; @@ -417,7 +579,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; @@ -862,9 +1028,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; @@ -876,6 +1085,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); } } @@ -932,7 +1148,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 @@ -955,9 +1171,16 @@ 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 *abs_out, + size_t abs_outsz, + char *host_out, + size_t host_outsz) { char base[LINUX_PATH_MAX]; + + *in_sysroot = false; if (dirfd_guest_base_path(dirfd, base, sizeof(base)) < 0) return -1; @@ -970,21 +1193,55 @@ 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; + /* 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; } @@ -1119,6 +1376,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, @@ -1202,6 +1486,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); @@ -1222,9 +1513,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) @@ -1242,8 +1538,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) { @@ -1252,21 +1548,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] == '/') { @@ -1308,11 +1596,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; } @@ -1356,8 +1641,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 @@ -1384,8 +1669,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 dd5db372..dc737cd2 100644 --- a/src/syscall/path.h +++ b/src/syscall/path.h @@ -57,12 +57,41 @@ static inline int path_translation_at_flags(const path_translation_t *tx, return tx->is_dev_shm ? (at_flags | AT_SYMLINK_NOFOLLOW) : at_flags; } +/* True when path equals prefix exactly, or extends it with '/'. Avoids the + * surprise where "/sys/devices/system/cpufoo" would match a bare strncmp on + * "/sys/devices/system/cpu" and pull an unrelated path through the intercept + * layer. + */ +bool path_prefix_match(const char *path, const char *prefix, size_t plen); + /* Advance *pathp to the next '/'-separated component, skipping empty segments * 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 @@ -91,8 +120,46 @@ 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, - const char *host_name, +/* 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 + * 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); + +/* Decode one on-disk entry name to the guest-visible spelling. Needs nothing + * but the name: without a folding sysroot every name means itself, and with + * one an escape decodes by rule. 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(const char *host_name, char *guest_name, size_t guest_name_sz); int resolve_proc_at_path(guest_fd_t dirfd, @@ -137,13 +204,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-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-state.c b/src/syscall/proc-state.c index 9995cbd6..92718026 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", @@ -518,8 +539,11 @@ static bool sysroot_path_exists(const char *resolved_path, bool follow_final) /* Resolve an absolute guest path against --sysroot. This keeps absolute guest * filesystem syscalls inside the sysroot when the target exists there, and * otherwise falls back to the literal host path so apps can still reach host - * resources such as /tmp or /etc/resolv.conf. Containment via realpath() is - * enforced only when the path actually resolves under sysroot, to prevent + * resources such as /etc/resolv.conf. The temp roots + * (is_sysroot_backed_temp_path) and the guest system directories + * (is_guest_system_path) are excluded from that fallback and stay on the + * sysroot spelling whether or not they exist there. Containment via realpath() + * is enforced only when the path actually resolves under sysroot, to prevent * symlink escape from a tree the caller intended to stay inside. */ static bool lexical_normalize_absolute_path(char *dest, @@ -609,6 +633,121 @@ static bool is_guest_system_path(const char *path) return false; } +/* The temp roots are sysroot-backed for every operation, lookup as much as + * create. Creates redirect so a guest's temp files cannot collide on the host's + * case-insensitive /tmp; a lookup falling back to the host would then surface + * entries that no create-resolved unlink or rename could touch, leaving them + * readable but not removable. The roots match as directories too, not only as + * prefixes of their children, so a listing cannot enumerate host entries whose + * children resolve into the sysroot. + * + * /var/tmp also lands in is_guest_system_path(), which claims all of /var for + * macOS SIP reasons; it belongs here on its own terms as a temp root, so the + * two rules stay independent. ccache matches by component because HOME may be a + * macOS home directory (/Users//.ccache) that no other rule covers; the + * component test is deliberately generous, matching a final leaf or a regular + * file too, an over-match that only widens what resolves into the sysroot. + */ +static bool is_sysroot_backed_temp_path(const char *path) +{ + if (path_prefix_match(path, "/tmp", 4) || + path_prefix_match(path, "/var/tmp", 8)) + return true; + + static const char ccache_dir[] = ".ccache"; + const char *scan = path; + const char *comp; + size_t len; + while (path_next_component(&scan, &comp, &len)) + if (len == sizeof(ccache_dir) - 1 && !memcmp(comp, ccache_dir, len)) + return true; + 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, @@ -622,14 +761,80 @@ 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; + bool followed_link = false; + char followed[LINUX_PATH_MAX]; + if (casefold_active()) { + casefold_walk_t walk; + casefold_verdict_t verdict = + resolve_through_links(sr, path, follow_final, buf, bufsz, &walk, + followed, sizeof(followed)); + + 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; + /* 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) { + 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; @@ -637,10 +842,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). @@ -648,9 +864,40 @@ static const char *proc_resolve_sysroot_path_flags(const char *path, char norm_path[LINUX_PATH_MAX]; bool has_norm = lexical_normalize_absolute_path(norm_path, path, sizeof(norm_path)); - if (is_guest_system_path(has_norm ? norm_path : path)) + /* Resolution stops at the sysroot spelling for both classes. The caller's + * own syscall reports the path absent when the sysroot does not hold it, + * which is what it is in the guest's namespace. + */ + const char *path_to_check = has_norm ? norm_path : path; + if (is_guest_system_path(path_to_check) || + 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; } @@ -679,41 +926,104 @@ 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; + bool followed_link = false; + char followed[LINUX_PATH_MAX]; + if (casefold_active()) { + casefold_walk_t walk; + + /* 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; + } + /* 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; + } - 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; + /* 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; @@ -721,29 +1031,27 @@ 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 /tmp, /var/tmp, and ccache get - * forcefully redirected to the sysroot to avoid host case-collisions; - * everything else falls back to the host literal. - * Guest system directories must also be forced to resolve to the sysroot. + /* 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. */ char norm_path[LINUX_PATH_MAX]; bool has_norm = lexical_normalize_absolute_path(norm_path, path, sizeof(norm_path)); const char *path_to_check = has_norm ? norm_path : path; - if (strncmp(path_to_check, "/tmp/", 5) && - strncmp(path_to_check, "/var/tmp/", 9) && - !strstr(path_to_check, "/.ccache/") && - !is_guest_system_path(path_to_check)) + if (!is_sysroot_backed_temp_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/src/syscall/proc.h b/src/syscall/proc.h index 66d3c0ac..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. */ @@ -263,9 +271,11 @@ bool proc_sysroot_casefold_enabled(void); /* Resolve an absolute guest path through the stored sysroot. * - * Returns path unchanged when no sysroot applies or when the sysroot-backed - * path does not exist, buf when a sysroot-backed file exists, or NULL if - * sysroot path construction would truncate or escape containment checks. + * Returns buf when a sysroot-backed file exists, and also when the path names a + * temp root or a guest system directory, both of which resolve there whether or + * not they exist. Returns path unchanged when no sysroot applies or when any + * other sysroot-backed path does not exist, or NULL if sysroot path + * construction would truncate or escape containment checks. */ const char *proc_resolve_sysroot_path(const char *path, char *buf, 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 8f8c341f..4f20e50d 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -2048,8 +2048,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 @@ -2153,10 +2154,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/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/copy-arg.c b/tests/copy-arg.c new file mode 100644 index 00000000..9e63a501 --- /dev/null +++ b/tests/copy-arg.c @@ -0,0 +1,38 @@ +/* + * 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 + +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; + while ((n = read(in, buf, sizeof(buf))) > 0) + if (write(out, buf, (size_t) n) != n) + 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/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/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; +} 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-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-case-collision.c b/tests/test-case-collision.c index ac6d3610..8f759ca0 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,73 @@ 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 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); + + /* 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-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; +} diff --git a/tests/test-casefold-walk-host.c b/tests/test-casefold-walk-host.c new file mode 100644 index 00000000..d9a7e1d0 --- /dev/null +++ b/tests/test-casefold-walk-host.c @@ -0,0 +1,487 @@ +/* + * 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; + + /* 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"); + + /* 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) + ok(); + else + fail("dangling link exists without following", "expected found"); + if (casefold_resolve_at(AT_FDCWD, root, "/dangling", true, out, sizeof(out), + &walk) == CASEFOLD_SYMLINK && + walk.link_rest_offset == strlen("/dangling")) + ok(); + else + 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 + * 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; +} diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index 8565766f..a7fbb558 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -353,12 +353,32 @@ 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-path-matrix + 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 +# 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 +390,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 +829,41 @@ 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. + # + # 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. + # + # 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" + 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" + test_check "$runner" "test-sysroot-path-matrix" "0 failed" \ + "$bindir/test-sysroot-path-matrix" } run_coreutils_tests() @@ -1233,7 +1293,7 @@ run_suite() # detector does not recognize yet. EXPECTED_BASELINES=( "elfuse-aarch64|238|0" - "qemu-aarch64|218|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-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-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; +} 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-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-create-paths.c b/tests/test-sysroot-create-paths.c index 4a386a12..e44d2016 100644 --- a/tests/test-sysroot-create-paths.c +++ b/tests/test-sysroot-create-paths.c @@ -330,7 +330,7 @@ int main(int argc, char **argv) _exit(0); } - int hit_eloop = 0; + bool hit_eloop = false; int iterations = 0; while (iterations < 10000) { iterations++; @@ -339,7 +339,7 @@ int main(int argc, char **argv) close(fd); unlink("/tmp/race_dir/file.txt"); } else if (errno == ELOOP) { - hit_eloop = 1; + hit_eloop = true; break; } } @@ -361,15 +361,13 @@ int main(int argc, char **argv) unlink(host_race_file); rmdir(host_race_dir); - /* Assert that no artifact exists outside mounted_sysroot_root on the - * host */ - struct stat host_st; - if (stat("/tmp/race_dir", &host_st) == 0) - FAIL("TOCTOU race created directory outside of sysroot"); - else if (hit_eloop) - FAIL("TOCTOU race returned ELOOP"); - else - PASS(); + /* The host-escape half of this assertion lives in the harness + * (mk/tests.mk). Guest /tmp is sysroot-backed for lookups as well as + * creates, so a stat of /tmp/race_dir from in here reports on the + * sysroot copy the lines above just deleted and can never observe a + * host artifact, whether or not the race produced one. + */ + EXPECT_TRUE(!hit_eloop, "TOCTOU race returned ELOOP"); } SUMMARY("test-sysroot-create-paths"); 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; +} 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-inotify-names.c b/tests/test-sysroot-inotify-names.c new file mode 100644 index 00000000..7b22029d --- /dev/null +++ b/tests/test-sysroot-inotify-names.c @@ -0,0 +1,297 @@ +/* + * 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. + */ +static bool escape_leaked; + +/* 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 (!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(void) +{ + 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"); + } + } + + /* 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; +} 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-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-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-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-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; +} 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; +} diff --git a/tests/test-sysroot-symlink-target.c b/tests/test-sysroot-symlink-target.c new file mode 100644 index 00000000..756b094a --- /dev/null +++ b/tests/test-sysroot-symlink-target.c @@ -0,0 +1,363 @@ +/* + * Following a symlink whose target names an escaped file + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A symlink stores the bytes the guest gave it, and it has to: readlink(2) owes + * those bytes 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, and an absolute target is worse still: the host resolves it from its + * own root rather than from the sysroot, so the link reaches a different + * filesystem entirely. + * + * 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"); + + /* The link itself is never rewritten: whatever the guest wrote comes back, + * and operations that do not follow keep seeing the link. + */ + TEST("readlink returns the bytes the guest wrote"); + 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'; + at(want, sizeof(want), "Target.One"); + EXPECT_TRUE(!strcmp(buf, want), "target was rewritten"); + } + + 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; +} diff --git a/tests/test-sysroot-tmp-remove.c b/tests/test-sysroot-tmp-remove.c new file mode 100644 index 00000000..768d0352 --- /dev/null +++ b/tests/test-sysroot-tmp-remove.c @@ -0,0 +1,195 @@ +/* + * Lookup and removal agree on a guest /tmp path + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Guest paths under /tmp, /var/tmp, and ~/.ccache are force-redirected into the + * sysroot so a guest's temp files avoid host case-collisions. Whatever a guest + * can see at such a path, it must also be able to remove: a file it can stat + * and read it must be able to unlink and rename, a directory it can stat it + * must be able to rmdir, and every name a directory listing reports must stat. + * + * The regression this pins: only creates were redirected. Lookups fell back to + * the literal host path, so a /tmp entry present on the host but absent from + * the sysroot was visible to stat and open, while unlink, rmdir, and rename + * (all create-resolved) addressed the sysroot and returned ENOENT for it. The + * entry was readable but not removable, and rm -rf of such a directory failed + * midway with "No such file or directory" despite -f, because readdir listed a + * child that the following unlink could not resolve. Redirecting only the + * children left the same disagreement on the directory itself: a listing of + * /tmp enumerated host entries whose names no longer resolved. + * + * Code under test: sys_unlinkat, sys_renameat2, and sys_getdents64 in + * src/syscall/fs.c over the resolvers in src/syscall/proc-state.c. + * + * The harness (mk/tests.mk) stages a directory under the host's own /tmp, so + * the guest addresses it through the redirect, and passes: + * + * argv[1] a guest /tmp directory, absent from the sysroot, holding regular + * file `f`, subdirectory `d`, and regular file `r` to rename. + * + * The harness then checks placement from the host side: the staging directory + * still holds exactly those three entries, and the sysroot gained the + * redirected directory. It asserts at directory granularity because a + * case-insensitive sysroot stores leaves under sidecar-mangled names, so the + * guest side proves the file itself by reading its bytes back. + * + * The invariants are one-directional on purpose: a path the guest cannot see at + * all is trivially consistent, since there is nothing to remove. The defect is + * the disagreement, not which way it is resolved. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +/* True when the entry at @path is not visible to the guest at all. Only an + * absent entry counts: any other lstat errno (ELOOP, EACCES, ENAMETOOLONG) is a + * resolver fault the test must surface, so the entry reads as visible and the + * operation that follows reports the real error. lstat, not stat, so this is + * the same visibility notion listing_resolves() checks against. + */ +static bool invisible(const char *path) +{ + struct stat st; + if (lstat(path, &st) == 0) + return false; + return errno == ENOENT || errno == ENOTDIR; +} + +/* Create @path, write a known byte, then reopen and read it back. Reading + * through a second open is what proves the redirect sends both operations to + * the same file, which a create alone does not. + */ +static bool round_trips(const char *path) +{ + int fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0644); + if (fd < 0) + return false; + bool ok = write(fd, "k", 1) == 1; + if (close(fd) != 0) + return false; + if (!ok) + return false; + + fd = open(path, O_RDONLY); + if (fd < 0) + return false; + char c = '\0'; + ok = read(fd, &c, 1) == 1 && c == 'k'; + if (close(fd) != 0) + return false; + return ok; +} + +/* Every name a directory listing reports must resolve for a following lstat, + * which is what a recursive remove does between readdir and unlink. lstat, not + * stat, so a dangling symlink staged by an unrelated host process does not read + * as a disagreement. An absent directory reports no names and so cannot + * disagree with anything; any other opendir errno is a resolver fault and + * fails. readdir and lstat share one resolver here, so this check supplements + * the unlink, rmdir, and rename lanes below, which are the genuine regression + * catchers. Reports the offending name, or the unopenable directory itself, + * through @offender. + */ +static bool listing_resolves(const char *dir, + char *offender, + size_t offender_sz) +{ + DIR *d = opendir(dir); + if (!d) { + if (errno == ENOENT || errno == ENOTDIR) + return true; + snprintf(offender, offender_sz, "%s", dir); + return false; + } + + bool consistent = true; + struct dirent *de; + while (consistent && (de = readdir(d))) { + if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) + continue; + + char path[PATH_MAX]; + struct stat st; + snprintf(path, sizeof(path), "%s/%s", dir, de->d_name); + if (lstat(path, &st) != 0) { + snprintf(offender, offender_sz, "%s", de->d_name); + consistent = false; + } + } + closedir(d); + return consistent; +} + +int main(int argc, char **argv) +{ + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + const char *dir = argv[1]; + char p[PATH_MAX], q[PATH_MAX]; + + printf("test-sysroot-tmp-remove: lookup and removal agree under /tmp\n"); + + /* Runs before anything is created, while the sysroot still has no /tmp of + * its own. That is the state in which an unredirected lookup falls through + * to the host and enumerates entries the guest cannot address. + */ + TEST("a /tmp listing resolves"); + char offender[NAME_MAX + 1] = ""; + bool listed_ok = listing_resolves("/tmp", offender, sizeof(offender)); + char msg[sizeof(offender) + 64]; + snprintf(msg, sizeof(msg), "/tmp listing disagreed at %s", offender); + EXPECT_TRUE(listed_ok, msg); + + TEST("a stat-visible /tmp file unlinks"); + snprintf(p, sizeof(p), "%s/f", dir); + EXPECT_TRUE(invisible(p) || unlink(p) == 0, + "visible file could not be unlinked"); + + TEST("a stat-visible /tmp directory rmdir's"); + snprintf(p, sizeof(p), "%s/d", dir); + EXPECT_TRUE(invisible(p) || rmdir(p) == 0, + "visible directory could not be removed"); + + TEST("a stat-visible /tmp file renames"); + snprintf(p, sizeof(p), "%s/r", dir); + snprintf(q, sizeof(q), "%s/r2", dir); + EXPECT_TRUE(invisible(p) || rename(p, q) == 0, + "visible file could not be renamed"); + + /* The other half of the contract: a guest's own /tmp file, which the create + * resolver places in the sysroot, must remain removable. This is the common + * case any container workload depends on; a fix that made the host-visible + * entry invisible must not have broken it. + */ + TEST("a guest-created /tmp file round-trips"); + snprintf(p, sizeof(p), "%s/made", dir); + int fd = open(p, O_CREAT | O_WRONLY, 0644); + EXPECT_TRUE(fd >= 0 && close(fd) == 0 && unlink(p) == 0, + "guest-created file could not be created then unlinked"); + + /* Left behind for the harness, which asserts the sysroot gained the + * redirected directory while the host staging directory gained nothing. + * Without that, the checks above would also be satisfied by a guest that + * reached nothing at all. + */ + TEST("a guest-created /tmp file reads back"); + snprintf(p, sizeof(p), "%s/kept", dir); + EXPECT_TRUE(round_trips(p), "guest-created file did not read back"); + + SUMMARY("test-sysroot-tmp-remove"); + 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();