From 16403c8797867445d9e79f468b092783d0cf84a4 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Wed, 3 Jun 2026 18:53:48 -0700 Subject: [PATCH] [dlfcn] E: Add tests for ctors/dtors and DT_RUNPATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new test suite (`dlfcn-init-runpath-c`) that exercises the three System V ABI capabilities the Nanvix dynamic loader now implements (esaurez/nanvix PR `feat/dlfcn-init-array-and-runpath`): 1. `libctor.so` defines `.init_array` and `.fini_array` entries via `__attribute__((constructor))` / `((destructor))`. The constructor writes a sentinel into a library-local global; the destructor writes a different sentinel into the test program's exported `g_dtor_ran` global so the witness survives the library being unloaded. The test asserts both sentinels appear in the expected order. 2. `libparent.so` is linked against `libchild.so` (creating a `DT_NEEDED` edge) and built with `-Wl,--enable-new-dtags, -rpath,lib/subdir`, which the linker emits as `DT_RUNPATH lib/subdir`. At runtime `libchild.so` is staged into `lib/subdir/` only — never `lib/` — so the only way `dlopen` can succeed is by honouring `libparent.so`'s `DT_RUNPATH`. Both libraries are built flat in `build/` and re-staged into the correct ramfs paths via the existing `SUITE_RAMFS_LIBS` mechanism in `.nanvix/z.py`, matching the pattern already used by `dlfcn-c`. The suite is registered in `STANDALONE_ONLY_SUITES` because it requires ramfs-bundled `.so` files, and in `ALL_SUITES`, the host `Makefile`, and the container `src/Makefile`. Test output on a standalone Nanvix VM running the updated loader: === dlfcn init_array + DT_RUNPATH tests === PASS: init_array fires on dlopen PASS: fini_array fires on dlclose PASS: DT_RUNPATH dependency search 3 passed, 0 failed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .nanvix/z.py | 7 + Makefile | 2 +- src/Makefile | 2 +- src/dlfcn-init-runpath-c/Makefile | 69 +++++++++ src/dlfcn-init-runpath-c/libs/ctor.c | 38 +++++ src/dlfcn-init-runpath-c/libs/parent.c | 19 +++ src/dlfcn-init-runpath-c/libs/subdir/child.c | 16 ++ src/dlfcn-init-runpath-c/main.c | 146 +++++++++++++++++++ 8 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 src/dlfcn-init-runpath-c/Makefile create mode 100644 src/dlfcn-init-runpath-c/libs/ctor.c create mode 100644 src/dlfcn-init-runpath-c/libs/parent.c create mode 100644 src/dlfcn-init-runpath-c/libs/subdir/child.c create mode 100644 src/dlfcn-init-runpath-c/main.c diff --git a/.nanvix/z.py b/.nanvix/z.py index 5c47e76..f83a3ca 100644 --- a/.nanvix/z.py +++ b/.nanvix/z.py @@ -67,6 +67,7 @@ "c-bindings", "dlfcn-c", "dlfcn-global-c", + "dlfcn-init-runpath-c", "dlfcn-needed-c", "dlfcn-pie-c", "echo-c", @@ -101,6 +102,7 @@ # Suites that require ramfs-bundled shared libraries and only run in standalone mode. STANDALONE_ONLY_SUITES = [ "dlfcn-c", + "dlfcn-init-runpath-c", "dlfcn-pie-c", ] @@ -113,6 +115,11 @@ # Maps suite name to a list of (source_filename_in_build_dir, ramfs_target_path). SUITE_RAMFS_LIBS: dict[str, list[tuple[str, str]]] = { "dlfcn-c": [("libmul.so", "lib/libmul.so")], + "dlfcn-init-runpath-c": [ + ("libctor.so", "lib/libctor.so"), + ("libparent.so", "lib/libparent.so"), + ("libchild.so", "lib/subdir/libchild.so"), + ], "dlfcn-pie-c": [("libmul-pie.so", "lib/libmul-pie.so")], } diff --git a/Makefile b/Makefile index bfcb117..44d796d 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ PROCESS_MODE ?= multi-process MEMORY_SIZE ?= 128mb # Test suites to build. -SUITES := c-bindings dlfcn-c dlfcn-pie-c echo-c echo-cpp file-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp thread-c +SUITES := c-bindings dlfcn-c dlfcn-init-runpath-c dlfcn-pie-c echo-c echo-cpp file-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp thread-c # ELF binaries produced by each suite. BINARIES := $(addsuffix .elf,$(SUITES)) diff --git a/src/Makefile b/src/Makefile index 45cb2f9..5eee457 100644 --- a/src/Makefile +++ b/src/Makefile @@ -65,7 +65,7 @@ export LIBRARIES_DIR ?= $(BINARIES_DIR) # Test Suites #=============================================================================== -SUITES := c-bindings dlfcn-c dlfcn-global-c dlfcn-needed-c dlfcn-pie-c echo-c echo-cpp file-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp thread-c +SUITES := c-bindings dlfcn-c dlfcn-global-c dlfcn-init-runpath-c dlfcn-needed-c dlfcn-pie-c echo-c echo-cpp file-c hello-c hello-cpp memory-c misc-c network-c noop-c noop-cpp thread-c #=============================================================================== # Build Rules diff --git a/src/dlfcn-init-runpath-c/Makefile b/src/dlfcn-init-runpath-c/Makefile new file mode 100644 index 0000000..797a928 --- /dev/null +++ b/src/dlfcn-init-runpath-c/Makefile @@ -0,0 +1,69 @@ +# Copyright(c) The Maintainers of Nanvix. +# Licensed under the MIT License. +# +# dlfcn-init-runpath-c: Tests for `.init_array` / `.fini_array` +# constructor and destructor invocation, and for DT_RUNPATH-driven +# DT_NEEDED dependency search. + +PROGRAM_NAME := dlfcn-init-runpath-c + +SOURCES := $(wildcard *.c) +OBJECTS := $(SOURCES:.c=.o) +BINARY := $(PROGRAM_NAME).elf + +all: $(OBJECTS) libs-all + $(CC) $(LDFLAGS) -pie -rdynamic -Wl,--no-dynamic-linker $(OBJECTS) $(LIBRARIES) -o $(BINARIES_DIR)/$(BINARY) + +clean: libs-clean + rm -f $(OBJECTS) + rm -f $(BINARIES_DIR)/$(BINARY) + +# libctor.so - constructor/destructor witness, used by the .init_array tests. +# libchild.so - dependency of libparent.so. Will be staged into +# lib/subdir/ at ramfs time so it is only reachable via +# libparent.so's DT_RUNPATH (z.py SUITE_RAMFS_LIBS). +# libparent.so - DT_NEEDED=libchild.so, DT_RUNPATH=lib/subdir/. +# +# `--enable-new-dtags` ensures the linker emits DT_RUNPATH (not the +# deprecated DT_RPATH) for libparent.so, matching what modern +# toolchains produce by default. +libs-all: + $(CC) libs/ctor.c -shared -fPIC -o $(LIBRARIES_DIR)/libctor.so + @if ! $(READELF) -d $(LIBRARIES_DIR)/libctor.so | grep -q '(INIT_ARRAY)'; then \ + echo "Error: INIT_ARRAY missing in $(LIBRARIES_DIR)/libctor.so"; \ + $(READELF) -d $(LIBRARIES_DIR)/libctor.so; \ + exit 1; \ + fi + @if ! $(READELF) -d $(LIBRARIES_DIR)/libctor.so | grep -q '(FINI_ARRAY)'; then \ + echo "Error: FINI_ARRAY missing in $(LIBRARIES_DIR)/libctor.so"; \ + $(READELF) -d $(LIBRARIES_DIR)/libctor.so; \ + exit 1; \ + fi + $(CC) libs/subdir/child.c -shared -fPIC -o $(LIBRARIES_DIR)/libchild.so + $(CC) libs/parent.c -shared -fPIC \ + -L$(LIBRARIES_DIR) -lchild \ + -Wl,--enable-new-dtags,-rpath,lib/subdir \ + -o $(LIBRARIES_DIR)/libparent.so + @if ! $(READELF) -d $(LIBRARIES_DIR)/libparent.so | grep -E -q '\(NEEDED\)[[:space:]]+Shared library: \[libchild\.so\]'; then \ + echo "Error: DT_NEEDED libchild.so missing in $(LIBRARIES_DIR)/libparent.so"; \ + $(READELF) -d $(LIBRARIES_DIR)/libparent.so; \ + exit 1; \ + fi + @if ! $(READELF) -d $(LIBRARIES_DIR)/libparent.so | grep -E -q '\(RUNPATH\)[[:space:]]+Library runpath: \[lib/subdir\]'; then \ + echo "Error: DT_RUNPATH lib/subdir missing in $(LIBRARIES_DIR)/libparent.so"; \ + $(READELF) -d $(LIBRARIES_DIR)/libparent.so; \ + exit 1; \ + fi + @if $(READELF) -d $(LIBRARIES_DIR)/libparent.so | grep -q '(RPATH)'; then \ + echo "Error: legacy DT_RPATH present in $(LIBRARIES_DIR)/libparent.so (expected DT_RUNPATH only)"; \ + $(READELF) -d $(LIBRARIES_DIR)/libparent.so; \ + exit 1; \ + fi + +libs-clean: + rm -f $(LIBRARIES_DIR)/libctor.so + rm -f $(LIBRARIES_DIR)/libchild.so + rm -f $(LIBRARIES_DIR)/libparent.so + +%.o: %.c + $(CC) $(CFLAGS) -fPIE $< -c -o $@ diff --git a/src/dlfcn-init-runpath-c/libs/ctor.c b/src/dlfcn-init-runpath-c/libs/ctor.c new file mode 100644 index 0000000..5033ae2 --- /dev/null +++ b/src/dlfcn-init-runpath-c/libs/ctor.c @@ -0,0 +1,38 @@ +/* + * Copyright(c) The Maintainers of Nanvix. + * Licensed under the MIT License. + */ + +/* + * libctor.so - shared library that exercises `.init_array` and + * `.fini_array` semantics required by the System V gABI. + * + * The constructor sets `ctor_ran` to a known sentinel value, and the + * destructor writes a different sentinel into the test program's + * `g_dtor_ran` global so that observation outlives the unloaded + * library. `g_dtor_ran` is declared `extern` here and resolved by the + * Nanvix loader's global symbol table (the main executable was linked + * with `-rdynamic`). + */ + +/* Public state exposed via dlsym so the test can read it after dlopen. */ +volatile int ctor_ran = 0; + +/* Main executable owns the destructor witness. */ +extern volatile int g_dtor_ran; + +static void __attribute__((constructor)) my_ctor(void) +{ + ctor_ran = 0xC70A; +} + +static void __attribute__((destructor)) my_dtor(void) +{ + g_dtor_ran = 0xD70A; +} + +/* Sanity entry point so the test can confirm dlsym still works. */ +int ctor_value(void) +{ + return 42; +} diff --git a/src/dlfcn-init-runpath-c/libs/parent.c b/src/dlfcn-init-runpath-c/libs/parent.c new file mode 100644 index 0000000..bab54cb --- /dev/null +++ b/src/dlfcn-init-runpath-c/libs/parent.c @@ -0,0 +1,19 @@ +/* + * Copyright(c) The Maintainers of Nanvix. + * Licensed under the MIT License. + */ + +/* + * libparent.so - shared library whose DT_NEEDED entry points at + * libchild.so, but whose runtime search path (DT_RUNPATH) is + * `lib/subdir/`. The Nanvix loader must consult DT_RUNPATH before + * falling back to the default `lib/` directory, otherwise libchild.so + * will not be located and dlopen will fail. + */ + +extern int child_value(int x); + +int parent_value(int x) +{ + return child_value(x) * 2; +} diff --git a/src/dlfcn-init-runpath-c/libs/subdir/child.c b/src/dlfcn-init-runpath-c/libs/subdir/child.c new file mode 100644 index 0000000..2cdb6fc --- /dev/null +++ b/src/dlfcn-init-runpath-c/libs/subdir/child.c @@ -0,0 +1,16 @@ +/* + * Copyright(c) The Maintainers of Nanvix. + * Licensed under the MIT License. + */ + +/* + * libchild.so - dependency of libparent.so. Installed under a + * non-default directory (`lib/subdir/`) so the only way the loader + * can find it via the DT_NEEDED edge in libparent.so is by honouring + * libparent's DT_RUNPATH. + */ + +int child_value(int x) +{ + return x + 7; +} diff --git a/src/dlfcn-init-runpath-c/main.c b/src/dlfcn-init-runpath-c/main.c new file mode 100644 index 0000000..fda212f --- /dev/null +++ b/src/dlfcn-init-runpath-c/main.c @@ -0,0 +1,146 @@ +/* + * Copyright(c) The Maintainers of Nanvix. + * Licensed under the MIT License. + */ + +/* + * dlfcn-init-runpath-c: Tests for `.init_array` / `.fini_array` + * constructor and destructor invocation, and for DT_RUNPATH-driven + * dependency search. + * + * Test 1 (constructor): dlopen libctor.so, dlsym `ctor_ran`, and + * confirm the constructor sentinel was written. + * + * Test 2 (destructor): dlclose libctor.so and confirm the destructor + * wrote its sentinel into the main executable's `g_dtor_ran` global. + * `g_dtor_ran` must be exported via -rdynamic so the loader's global + * symbol table can satisfy the `extern volatile int g_dtor_ran;` + * reference in the library. + * + * Test 3 (DT_RUNPATH): dlopen lib/libparent.so, which has + * DT_NEEDED=libchild.so and DT_RUNPATH=lib/subdir/. The loader must + * probe DT_RUNPATH before the default `lib/` directory; otherwise + * libchild.so is not located. + */ + +#include +#include +#include +#include + +/* Witness for libctor.so's destructor -- defined here so it survives the + * library being unloaded. Marked volatile to keep the optimiser away. + */ +volatile int g_dtor_ran = 0; + +static int tests_passed = 0; +static int tests_failed = 0; + +static void pass(const char *name) +{ + printf(" PASS: %s\n", name); + fflush(stdout); + tests_passed++; +} + +static void fail(const char *name, const char *reason) +{ + printf(" FAIL: %s (%s)\n", name, reason); + fflush(stdout); + tests_failed++; +} + +/* + * Test 1: Constructor in `.init_array` must run before dlopen returns. + */ +static void test_init_array(void) +{ + void *h = dlopen("lib/libctor.so", RTLD_NOW); + if (h == NULL) { + fail("init_array fires on dlopen", dlerror()); + return; + } + + volatile int *ctor_ran = (volatile int *)dlsym(h, "ctor_ran"); + if (ctor_ran == NULL) { + fail("init_array fires on dlopen", "ctor_ran symbol missing"); + dlclose(h); + return; + } + + if (*ctor_ran != 0xC70A) { + fail("init_array fires on dlopen", "constructor sentinel not set"); + dlclose(h); + return; + } + + /* Sanity check: ordinary symbol resolution still works after init. */ + int (*fn)(void) = NULL; + *(void **)(&fn) = dlsym(h, "ctor_value"); + if (fn == NULL || fn() != 42) { + fail("init_array fires on dlopen", "ctor_value() wrong"); + dlclose(h); + return; + } + + /* Reset the dtor witness so test_fini_array measures a fresh signal. */ + g_dtor_ran = 0; + dlclose(h); + + pass("init_array fires on dlopen"); + + /* Bridge directly into the destructor test while the witness is fresh. */ + if (g_dtor_ran != 0xD70A) { + fail("fini_array fires on dlclose", "destructor sentinel not set"); + return; + } + pass("fini_array fires on dlclose"); +} + +/* + * Test 3: DT_RUNPATH must be consulted when resolving DT_NEEDED bare + * names. libparent.so depends on libchild.so but libchild.so only + * exists under lib/subdir/, which is libparent's DT_RUNPATH. + */ +static void test_dt_runpath(void) +{ + void *h = dlopen("lib/libparent.so", RTLD_NOW); + if (h == NULL) { + fail("DT_RUNPATH dependency search", dlerror()); + return; + } + + int (*fn)(int) = NULL; + *(void **)(&fn) = dlsym(h, "parent_value"); + if (fn == NULL || fn(5) != 24) { + /* parent_value(5) = child_value(5) * 2 = (5 + 7) * 2 = 24 */ + fail("DT_RUNPATH dependency search", "parent_value() wrong"); + dlclose(h); + return; + } + + dlclose(h); + pass("DT_RUNPATH dependency search"); +} + +int main(int argc, const char *argv[]) +{ + (void)argc; + (void)argv; + + printf("=== dlfcn init_array + DT_RUNPATH tests ===\n"); + fflush(stdout); + + test_init_array(); + test_dt_runpath(); + + printf("\n%d passed, %d failed\n", tests_passed, tests_failed); + fflush(stdout); + + if (tests_failed == 0) { + const char *magic = "ok"; + write(STDOUT_FILENO, magic, 3); + } + + return tests_failed > 0 ? 1 : 0; +}