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; }