From 59f797f8dc3fef691feeea9b8dd0d449ed44c6c8 Mon Sep 17 00:00:00 2001 From: Samueru-sama Date: Thu, 27 Aug 2026 13:40:20 -0400 Subject: [PATCH] feat(preload): assemble LIBVA_DRIVERS_PATH for libva's driver search libva never opens its driver by soname. va_openDriver() constructs /_drv_video.so and opens that absolute path, walking LIBVA_DRIVERS_PATH or a VA_DRIVERS_PATH compiled into whichever libva runs. A bundled libva therefore carries its build host's dri layout into a process running on a different one, and no library path can correct it, because no soname lookup ever happens. The preload now assembles the list: when libva.so.2 is in the process, every host /dri found on LD_LIBRARY_PATH and in the conventional directories is APPENDED to LIBVA_DRIVERS_PATH, behind anything already set. A process that never loads libva is untouched, and the bundle's own lib/dri is never added: a bundle that ships VA drivers manages the variable itself. The scan runs once from the constructor and again after every successful dlopen until libva turns up, which is what covers the gstreamer shape, where libva enters as a dependency of a dlopened plugin. The guard means settled, not ran: latching it on 'libva absent' disarmed the late-load case and was measured doing so before E97 passed. Measured as E95 through E100 in experiments/30-run-tests.sh, on the glibc 2.31 floor. Built from main the section scores 2 of 6; with the change 6 of 6, and the whole evidence table is 59/59 with every prior prediction still held. E99 pins the bundle exclusion by turning the absence into a word, the way E85 does. The first E97 draft also measured a general interposer property, now in docs/traps.md: a dlopen through an interposer consults the interposer's own RUNPATH, not the calling binary's, so a soname reachable only through the caller's runpath stops resolving under the preload. docs/integrating.md gains the VA-API section and states what stays UNVERIFIED: a real iHD_drv_video.so or i965_drv_video.so across a libc boundary. That needs a host with one installed. --- docs/integrating.md | 22 ++++ docs/traps.md | 12 +++ experiments/30-run-tests.sh | 178 ++++++++++++++++++++++++++++++ src/cross-libc-dlopen.c | 208 +++++++++++++++++++++++++++++++++++- 4 files changed, 417 insertions(+), 3 deletions(-) diff --git a/docs/integrating.md b/docs/integrating.md index 6be7606..0a8ef58 100644 --- a/docs/integrating.md +++ b/docs/integrating.md @@ -121,6 +121,28 @@ target is the bundled dispatcher whenever the bundle *or* the host has a vendor library for it, and the host's own library only otherwise; `examples/` shows the failure this rule exists to prevent. +### VA-API drivers + +VA-API needs one thing the library path cannot give. `libva.so.2` never opens +its driver by soname: it constructs `/_drv_video.so` and opens that +absolute path, walking `LIBVA_DRIVERS_PATH` or a default compiled into the +libva being run. That default names the layout of the distro that built the +library, so a bundled libva looks for the runtime host's drivers in the wrong +place and no `--library-path` can fix it. + +When `libva.so.2` is in the process, the preload assembles the answer: every +host `/dri` directory it finds is appended to `LIBVA_DRIVERS_PATH`, +behind anything already set. A process that never loads libva is not touched. +The bundle's own `lib/dri` is never added. A bundle that ships VA drivers +manages the variable itself, and its entries stay ahead of anything appended +here. + +Measured with a stand-in driver by E95 through E100 in +[`experiments/30-run-tests.sh`](../experiments/30-run-tests.sh), on the glibc +2.31 floor. ⚠ **A real `iHD_drv_video.so` or `i965_drv_video.so` carried +across a libc boundary is UNVERIFIED.** What that needs is a host with one +installed, and [`report/README.md`](report/README.md) does not have that run. + ### A plain binary, no bundle anywhere ```bash diff --git a/docs/traps.md b/docs/traps.md index d901c0e..bb7ad5b 100644 --- a/docs/traps.md +++ b/docs/traps.md @@ -71,6 +71,18 @@ about it. --- +### A `dlopen` by soname can stop finding a library your own RUNPATH names + +The `dlopen` this project interposes runs with the interposer as its caller, +and the search consults the **caller's** `DT_RPATH` and `DT_RUNPATH`. A binary +that reaches a plugin by soname through its own runpath gets +`cannot open shared object file` under the preload and works without it. Reach +the library through `LD_LIBRARY_PATH` instead, or `dlopen` an object that +NEEDs the soname you want, which is what gstreamer does to its va plugin. +Measured while writing E97, in [`experiments/30-run-tests.sh`](../experiments/30-run-tests.sh). + +--- + ### `couldn't get an RGB, Double-buffered visual` is not about visuals Or about libc. It is the message a glvnd dispatcher gives when the host ships no diff --git a/experiments/30-run-tests.sh b/experiments/30-run-tests.sh index bf4e6fc..6480f0b 100644 --- a/experiments/30-run-tests.sh +++ b/experiments/30-run-tests.sh @@ -1003,6 +1003,184 @@ else cd .. fi +echo +echo "-- Q. libva's driver search: LIBVA_DRIVERS_PATH is ours to assemble" +# +# libva never dlopens its driver by soname. va_openDriver() walks a search +# list and dlopens the ABSOLUTE path it constructs from each entry, +# /_drv_video.so. The list is LIBVA_DRIVERS_PATH, or the +# VA_DRIVERS_PATH compiled into whichever libva is running. That compiled +# default names the layout of the distro that BUILT it, so a bundled libva +# carries its build host's answer into a process on a different one. No +# library path can correct that, because no soname lookup ever happens. +# +# The feature under test: when libva.so.2 is in the process, the preload +# appends the host's /dri directories to LIBVA_DRIVERS_PATH, +# behind anything already set, and touches nothing in a process that never +# loads libva. The bundle's own lib/dri is never added: a bundle that +# ships VA drivers manages this variable itself, and those entries already +# sit ahead of anything appended here. +# +# The fake libva below implements va_openDriver's CONTRACT: getenv, +# colon-split, constructed absolute path, RTLD_NOW|RTLD_GLOBAL|RTLD_NODELETE, +# dlsym __vaDriverInit_1_0. The fake drivers answer with the directory +# they were built for, so the verdict line names WHICH directory won rather +# than merely that a directory did. The answer round-trips through the +# driver's own function, so a pass is not "a string appeared". +mkdir -p /work/va/host/lib/dri /work/va/userdri /work/va/bundle/lib/dri +cat > va_drv.c <<'CEOF' +#include +static const char *const va_vendor = VA_VENDOR; +int __vaDriverInit_1_0(void *ctx) { (void)ctx; return 0; } +const char *va_driver_vendor(void) { return va_vendor; } +CEOF +gcc -shared -fPIC -O2 -DVA_VENDOR='"HOST"' va_drv.c -o /work/va/host/lib/dri/vastub_drv_video.so 2>"$BERR" || bfail vastub-HOST +gcc -shared -fPIC -O2 -DVA_VENDOR='"USER"' va_drv.c -o /work/va/userdri/vastub_drv_video.so 2>"$BERR" || bfail vastub-USER +gcc -shared -fPIC -O2 -DVA_VENDOR='"BUNDLE"' va_drv.c -o /work/va/bundle/lib/dri/vastub_drv_video.so 2>"$BERR" || bfail vastub-BUNDLE + +cat > va_libva.c <<'CEOF' +#define _GNU_SOURCE +#include +#include +#include +static void *drv; +int vaInitialize(void) { + const char *search = secure_getenv("LIBVA_DRIVERS_PATH"); + if (!search || !*search) return 1; + char *copy = strdup(search); + if (!copy) return 1; + int rc = 1; + for (char *dir = strtok(copy, ":"); dir; dir = strtok(NULL, ":")) { + char path[4096]; + snprintf(path, sizeof path, "%s/%s%s", dir, "vastub", "_drv_video.so"); + void *h = dlopen(path, RTLD_NOW | RTLD_GLOBAL | RTLD_NODELETE); + if (!h) continue; /* silent on files that are not there, as libva is */ + int (*init)(void *) = (int (*)(void *))dlsym(h, "__vaDriverInit_1_0"); + if (init && init(NULL) == 0) { drv = h; rc = 0; break; } + dlclose(h); + } + free(copy); + return rc; +} +const char *va_stub_vendor(void) { + if (!drv) return "(no driver)"; + const char *(*f)(void) = (const char *(*)(void))dlsym(drv, "va_driver_vendor"); + return f ? f() : "(no vendor)"; +} +CEOF +gcc -shared -fPIC -O2 va_libva.c -o /work/va/libva.so.2 \ + -Wl,-soname,libva.so.2 -ldl 2>"$BERR" || bfail libva.so.2 + +# Consumer A links libva at startup, the shape of every ffmpeg/mpv/browser +cat > va_consumer.c <<'CEOF' +#include +#include +extern int vaInitialize(void); +extern const char *va_stub_vendor(void); +int main(void) { + const char *e = getenv("LIBVA_DRIVERS_PATH"); + if (vaInitialize() != 0) { printf("NO-DRIVER env=[%s]\n", e ? e : "(unset)"); return 1; } + printf("DRIVER=%s env=[%s]\n", va_stub_vendor(), e ? e : "(unset)"); + return 0; +} +CEOF +gcc -O2 va_consumer.c -o va_consumer \ + -L/work/va -l:libva.so.2 -Wl,-rpath,/work/va 2>"$BERR" || bfail va_consumer + +# Consumer B never loads libva: the guard's other arm +cat > va_nolib.c <<'CEOF' +#include +#include +int main(void) { + const char *e = getenv("LIBVA_DRIVERS_PATH"); + printf("env=[%s]\n", e ? e : "(unset)"); + return 0; +} +CEOF +gcc -O2 va_nolib.c -o va_nolib 2>"$BERR" || bfail va_nolib + +# Consumer C dlopens a PLUGIN that NEEDs libva, the shape of gstreamer +# loading libgstva.so: libva rides in as a dependency of the dlopened +# object, after main. Direct references keep the NEEDED alive against +# --as-needed. (dlopen("libva.so.2") directly from the consumer does NOT +# work here, and the reason is its own finding: an interposed dlopen's +# caller is the preload, so the consumer's own RUNPATH is not searched. +# A bundle-shaped process reaches libva through LD_LIBRARY_PATH or through +# a dlopened object's dependency, which is the shape below.) +cat > va_late.c <<'CEOF' +#define _GNU_SOURCE +#include +#include +#include +int main(void) { + void *h = dlopen("/work/va/va_plugin.so", RTLD_NOW); + if (!h) { printf("NO-PLUGIN %s\n", dlerror()); return 1; } + int (*init)(void) = (int (*)(void))dlsym(h, "plugin_va_init"); + const char *(*vend)(void) = (const char *(*)(void))dlsym(h, "plugin_va_vendor"); + const char *e = getenv("LIBVA_DRIVERS_PATH"); + if (!e || !*e) { printf("NO-DRIVER env=[(unset)]\n"); return 1; } + if (init() != 0) { printf("NO-DRIVER env=[%s]\n", e); return 1; } + printf("DRIVER=%s env=[%s]\n", vend(), e); + return 0; +} +CEOF +cat > va_plugin.c <<'CEOF' +extern int vaInitialize(void); +extern const char *va_stub_vendor(void); +int plugin_va_init(void) { return vaInitialize(); } +const char *plugin_va_vendor(void) { return va_stub_vendor(); } +CEOF +gcc -shared -fPIC -O2 va_plugin.c -o /work/va/va_plugin.so \ + -L/work/va -l:libva.so.2 -Wl,-rpath,/work/va 2>"$BERR" || bfail va_plugin +gcc -O2 va_late.c -o va_late -ldl 2>"$BERR" || bfail va_late + +# E95: libva linked at startup. /work/va/host/lib stands in for the host's +# libdir on the process's search list; the conventional directories +# cannot know /work, so only the assembled list can answer. +run E95 OK "DRIVER=HOST env=[/work/va/host/lib/dri" \ + env LD_LIBRARY_PATH=/work/va/host/lib \ + LD_PRELOAD=/work/cross-libc-dlopen.so ./va_consumer + +# E96: the control, and the case that fails without the feature. Feature +# off means the variable stays unset, the fake libva walks nothing, +# and the driver is never found. +run E96 FAIL "NO-DRIVER env=[(unset)]" \ + env CROSS_LIBC_DLOPEN=0 LD_LIBRARY_PATH=/work/va/host/lib \ + LD_PRELOAD=/work/cross-libc-dlopen.so ./va_consumer + +# E97: the late load. gstreamer dlopens its va plugin, which pulls libva in +# after main; the check that assembles the list runs after that very +# dlopen returns, which is still before vaInitialize can read it. +run E97 OK "DRIVER=HOST env=[/work/va/host/lib/dri" \ + env LD_LIBRARY_PATH=/work/va/host/lib \ + LD_PRELOAD=/work/cross-libc-dlopen.so ./va_late + +# E98: a value already set keeps its place. The user's directory is tried +# first and answers USER; the host directory is APPENDED behind it, +# the same place the conventions put every appended path entry. +run E98 OK "DRIVER=USER env=[/work/va/userdri:/work/va/host/lib/dri" \ + env LIBVA_DRIVERS_PATH=/work/va/userdri LD_LIBRARY_PATH=/work/va/host/lib \ + LD_PRELOAD=/work/cross-libc-dlopen.so ./va_consumer + +# E99: the bundle's own dri directory never enters the list. An absence, +# and `run` can only assert presence, so the absence is turned into a +# word, the way E85 does. +run E99 OK "bundle-dri-absent" sh -c \ + 'out=$(env CROSS_LIBC_DLOPEN_ROOT=/work/va/bundle \ + LD_LIBRARY_PATH=/work/va/host/lib \ + LD_PRELOAD=/work/cross-libc-dlopen.so \ + ./va_consumer 2>&1) + case "$out" in + */work/va/bundle/*) echo "bundle-dri-PRESENT: $out" ;; + *"DRIVER=HOST"*) echo "bundle-dri-absent" ;; + *) echo "no-driver: $out" ;; + esac' + +# E100: no libva in the process, so the variable is not ours to write. +# Consumer B links nothing of the kind and the feature is ON. +run E100 OK "env=[(unset)]" \ + env LD_PRELOAD=/work/cross-libc-dlopen.so ./va_nolib + echo echo "================================================================" echo " predictions matched: $PASS mismatched: $FAIL" diff --git a/src/cross-libc-dlopen.c b/src/cross-libc-dlopen.c index 3151367..7660ddc 100644 --- a/src/cross-libc-dlopen.c +++ b/src/cross-libc-dlopen.c @@ -1434,6 +1434,201 @@ VISIBLE void cross_libc_dlopen_init_now(void) { cld_load_global_scope_libs(); } +// --------------------------------------------------------------------------- +// libva's driver search list (LIBVA_DRIVERS_PATH) +// +// libva never dlopens its driver by soname. va_openDriver() walks a search +// list and dlopens the ABSOLUTE path it constructs from each entry, +// /_drv_video.so. The list is LIBVA_DRIVERS_PATH, or the +// VA_DRIVERS_PATH compiled into the libva being run. That compiled default +// names the layout of whatever distro BUILT that libva, and a bundled +// libva therefore carries its build host's answer into a process running +// on a different one. No library path can correct this, because no soname +// lookup happens; the list is ours to assemble, the same act sharun +// performs for ld.so. +// +// Why only when libva is loaded: the variable is libva's, and a process +// that never loads libva gets nothing written into its environment. The +// check is dl_iterate_phdr, once from the constructor (libva linked at +// startup) and once after every successful dlopen until it fires (libva +// dlopened late, as gstreamer loads its va plugin). Either route has fired +// before vaInitialize can read the variable: a dlopen of libva itself is +// the last load that can precede that read, and the check runs after that +// very dlopen returns. +// +// Nothing here opens a library (conventions/code.md): appending directories +// to a search list is not searching it, and libva does its own searching +// from the result. The bundle's own lib/dri is deliberately never included: +// a bundle that ships VA drivers manages LIBVA_DRIVERS_PATH itself, and +// whatever it set already sits ahead of anything appended here. +// +// The existing value keeps priority and is never clobbered: anything this +// assembles goes at the END, the same place the conventions put appended +// library-path entries, so a user's or a launcher's choice always wins. +#define CLD_VA_LIBVA_SONAME "libva.so." +#define CLD_VA_DRIVERS_PATH "LIBVA_DRIVERS_PATH" + +#if defined(__x86_64__) +# define CLD_TRIPLET "x86_64-linux-gnu" +#elif defined(__aarch64__) +# define CLD_TRIPLET "aarch64-linux-gnu" +#elif defined(__i386__) +# define CLD_TRIPLET "i386-linux-gnu" +#else +# define CLD_TRIPLET "unknown" +#endif + +// The conventional libdirs of the hosts this runs on, each probed as +// /dri. Debian and Ubuntu keep their VA drivers under the triplet +// directory, Alpine in /usr/lib/dri, Fedora in /usr/lib64/dri. A missed +// access() costs nothing. +static const char *const cld_va_libdirs[] = { + "/usr/lib/" CLD_TRIPLET, "/lib/" CLD_TRIPLET, + "/usr/lib64", "/lib64", + "/usr/lib", "/lib", + "/usr/local/lib", "/usr/local/lib64", + NULL +}; + +static int cld_va_phdr_cb(struct dl_phdr_info *info, size_t size, void *data) { + (void)size; + const char *name = info->dlpi_name; + const char *base = strrchr(name, '/'); + base = base ? base + 1 : name; + if (strncmp(base, CLD_VA_LIBVA_SONAME, + sizeof(CLD_VA_LIBVA_SONAME) - 1) == 0) { + *(int *)data = 1; + return 1; + } + return 0; +} + +static int cld_va_libva_loaded(void) { + // dlpi_name is the soname for DT_NEEDED libraries and a path for + // dlopened ones, so the basename is what has to match either way + int found = 0; + dl_iterate_phdr(cld_va_phdr_cb, &found); + return found; +} + +struct cld_va_list { + char buf[4096]; + size_t used; +}; + +static int cld_va_has(const struct cld_va_list *l, const char *dir) { + size_t len = strlen(dir); + const char *p = l->buf; + while (*p) { + const char *end = strchr(p, ':'); + if (!end) end = p + strlen(p); + if ((size_t)(end - p) == len && strncmp(p, dir, len) == 0) + return 1; + p = *end ? end + 1 : end; + } + return 0; +} + +static void cld_va_add(struct cld_va_list *l, const char *dir) { + char path[PATH_MAX]; + int n = snprintf(path, sizeof(path), "%s/dri", dir); + if (n < 0 || n >= (int)sizeof(path)) + return; + if (access(path, X_OK) != 0) + return; + if (cld_va_has(l, path)) + return; + int w = snprintf(l->buf + l->used, sizeof(l->buf) - l->used, + "%s%s", l->used ? ":" : "", path); + if (w < 0 || (size_t)w >= sizeof(l->buf) - l->used) { + DEBUG_PRINT("LIBVA_DRIVERS_PATH is full; %s omitted\n", path); + return; + } + l->used += (size_t)w; +} + +// One guarded run. ⛔ The guard means "settled", not "ran": a constructor +// that latched on "libva absent" would disarm the post-dlopen scan for the +// process's whole life, and the late-load case would never fire. Measured +// here as E97, which went exactly that way on the first revision. So the +// scan re-runs after every successful dlopen until libva is found; before +// that, each pass costs one phdr walk per dlopen, and a dlopen is already +// the more expensive of the two. +static int cld_va_done; + +static void cld_va_setup(void) { + if (cld_va_done) + return; + + if (!cross_libc_dlopen_mode()) { + cld_va_done = 1; + return; + } + + // Not loaded yet is NOT settled: the next dlopen may bring libva in. + if (!cld_va_libva_loaded()) { + DEBUG_PRINT("LIBVA_DRIVERS_PATH: libva not loaded, untouched\n"); + return; + } + cld_va_done = 1; + + struct cld_va_list l = { { 0 }, 0 }; + + // The value already set, the user's or the launcher's, keeps its + // place at the front of libva's walk. + const char *cur = getenv(CLD_VA_DRIVERS_PATH); + if (cur && *cur) { + int w = snprintf(l.buf, sizeof(l.buf), "%s", cur); + if (w < 0 || (size_t)w >= sizeof(l.buf)) { + DEBUG_PRINT("LIBVA_DRIVERS_PATH already overlong, left alone\n"); + return; + } + l.used = (size_t)w; + } + + // The process's own search list first: a launcher that already + // assembled the host library path (sharun) has written the host's + // answers there, and /dri is where those answers keep VA. + const char *lp = getenv("LD_LIBRARY_PATH"); + if (lp && *lp) { + char *copy = strdup(lp); + if (copy) { + for (char *p = strtok(copy, ":"); p; p = strtok(NULL, ":")) + if (is_host_library_path(p)) + cld_va_add(&l, p); + free(copy); + } + } + + for (size_t i = 0; cld_va_libdirs[i]; i++) + cld_va_add(&l, cld_va_libdirs[i]); + + if (!l.used) { + DEBUG_PRINT("LIBVA_DRIVERS_PATH: libva loaded, no host dri " + "directory found, untouched\n"); + return; + } + + if (cld_dryrun_enabled()) { + fprintf(stderr, + " [cross-libc-dlopen.so] >> DRYRUN LIBVA_DRIVERS_PATH " + "would be: %s\n", l.buf); + return; + } + + if (setenv(CLD_VA_DRIVERS_PATH, l.buf, 1) != 0) { + DEBUG_PRINT("LIBVA_DRIVERS_PATH setenv failed: %s\n", + strerror(errno)); + return; + } + DEBUG_PRINT("LIBVA_DRIVERS_PATH=%s\n", l.buf); +} + +__attribute__((constructor)) +static void cld_va_init(void) { + cld_va_setup(); +} + // core libraries are never stripped nor loaded twice, rewriting the // dynamic linker or libc is a one way ticket to segfault city. ld-linux // carries no soname so RTLD_NOLOAD cannot catch it, hence this list @@ -1801,13 +1996,20 @@ VISIBLE void *dlopen(const char *filename, int flags) { int handled = 0; void *host = cld_attempt(dlopen_orig, filename, flags, &handled); if (handled) { - if (host) + if (host) { + // a successful load may have brought libva in; the call is + // one branch once it has fired (see the LIBVA section above) + cld_va_setup(); DEBUG_PRINT("cross-libc dlopen success: %s\n", filename); - else + } else { DEBUG_PRINT("cross-libc dlopen failed: %s\n", filename); + } return host; } DEBUG_PRINT("dlopen pass-through: %s\n", filename); - return dlopen_orig(filename, flags); + void *pass = dlopen_orig(filename, flags); + if (pass) + cld_va_setup(); + return pass; }