diff --git a/.gitignore b/.gitignore
index 8b74c9dc..c954ed59 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ bin/
coverage.*
*.coverprofile
profile.cov
+hack/test/out
# Dependency directories (remove the comment below to include it)
# vendor/
diff --git a/hack/test/nvidia-test.sh b/hack/test/nvidia-test.sh
new file mode 100755
index 00000000..bd3102a3
--- /dev/null
+++ b/hack/test/nvidia-test.sh
@@ -0,0 +1,439 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-only
+#
+# This file is part of the distrobox project:
+# https://github.com/89luca89/distrobox
+#
+# Copyright (C) 2021 distrobox contributors
+#
+# distrobox is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3
+# as published by the Free Software Foundation.
+#
+# distrobox is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with distrobox; if not, see .
+#
+set -u
+#
+# Runs inside the test VM (spawned by test-nvidia-integration.sh). Installs the
+# nvidia userspace driver for the host distro, then for each guest image checks
+# every driver file is mirrored into the container with a matching checksum.
+# The distro comes from $1 or /mnt/share/distro; the result and diagnostics go
+# to the serial console and the /mnt/out share.
+#
+
+DISTRO="${1:-$(cat /mnt/share/distro 2> /dev/null || true)}"
+SERIAL=/dev/ttyS0
+OUT=/mnt/out
+BOX_IMAGES="quay.io/toolbx/ubuntu-toolbox:24.04 registry.fedoraproject.org/fedora-toolbox:44 quay.io/toolbx/arch-toolbox:latest"
+
+log()
+{
+ printf '[test] %s\n' "$*"
+ printf '[test] %s\n' "$*" > "${SERIAL}" 2> /dev/null || true
+}
+pass()
+{
+ printf '=== RESULT: PASS ===\n' > "${SERIAL}" 2> /dev/null || true
+ sync
+ sleep 2
+ poweroff -f
+ exit 0
+}
+fail()
+{
+ log "FAIL: $*"
+ printf '=== RESULT: FAIL ===\n' > "${SERIAL}" 2> /dev/null || true
+ sync
+ sleep 2
+ poweroff -f
+ exit 1
+}
+dbx()
+{
+ /usr/local/bin/distrobox "$@"
+}
+outok()
+{
+ [ -d "${OUT}" ] && [ -w "${OUT}" ]
+}
+# run_log CMD...: append CMD's combined output to install.log (and echo it to
+# the console via cloud-init), returning CMD's own exit status. A bare
+# "cmd | tee" would return tee's status instead, and POSIX sh has no pipefail.
+run_log()
+{
+ {
+ "$@" 2>&1
+ echo "$?" > /tmp/.rc
+ } | tee -a "${OUT}/install.log"
+ read -r _rc < /tmp/.rc
+ return "${_rc}"
+}
+
+[ -n "${DISTRO}" ] || fail "no distro given"
+log "host ${DISTRO}: installing container manager + nvidia userspace driver"
+case "${DISTRO}" in
+ ubuntu)
+ export DEBIAN_FRONTEND=noninteractive
+ run_log apt-get update -y || fail "apt update"
+ run_log apt-get install -y podman || fail "install podman"
+ b="$(apt-cache search --names-only '^nvidia-utils-[0-9]+$' | grep -oE '[0-9]+' | sort -rn | head -1)"
+ [ -n "${b}" ] || fail "no nvidia-utils package in this image"
+ pkgs="libnvidia-compute-${b} libnvidia-gl-${b} libnvidia-decode-${b} libnvidia-encode-${b} libnvidia-extra-${b} nvidia-utils-${b}"
+ # shellcheck disable=SC2086
+ run_log apt-get install -y ${pkgs} || fail "install nvidia"
+ dpkg --add-architecture i386 && run_log apt-get update -y
+ run_log apt-get install -y "libnvidia-gl-${b}:i386" && pkgs="${pkgs} libnvidia-gl-${b}:i386"
+ # shellcheck disable=SC2086
+ pm_list()
+ {
+ dpkg -L ${pkgs} 2> /dev/null
+ }
+ ;;
+ fedora)
+ run_log dnf install -y podman "https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" || {
+ tail -25 "${OUT}/install.log" > "${SERIAL}" 2> /dev/null
+ fail "install podman/rpmfusion (see install.log)"
+ }
+ pkgs="xorg-x11-drv-nvidia-libs xorg-x11-drv-nvidia-cuda-libs"
+ # shellcheck disable=SC2086
+ run_log dnf install -y ${pkgs} || {
+ tail -25 "${OUT}/install.log" > "${SERIAL}" 2> /dev/null
+ fail "install nvidia (see install.log)"
+ }
+ run_log dnf install -y xorg-x11-drv-nvidia-libs.i686 && pkgs="${pkgs} xorg-x11-drv-nvidia-libs.i686"
+ # shellcheck disable=SC2086
+ pm_list()
+ {
+ rpm -ql ${pkgs} 2> /dev/null
+ }
+ ;;
+ arch)
+ sed -i '/^#\[multilib\]/,/^#Include/ s/^#//' /etc/pacman.conf
+ run_log pacman -Sy --noconfirm --needed podman || fail "install podman"
+ pkgs="nvidia-utils opencl-nvidia"
+ # shellcheck disable=SC2086
+ run_log pacman -S --noconfirm --needed ${pkgs} || fail "install nvidia"
+ run_log pacman -S --noconfirm --needed lib32-nvidia-utils && pkgs="${pkgs} lib32-nvidia-utils"
+ # shellcheck disable=SC2086
+ pm_list()
+ {
+ pacman -Ql ${pkgs} 2> /dev/null | awk '{print $2}'
+ }
+ ;;
+ *)
+ fail "unknown distro: ${DISTRO}"
+ ;;
+esac
+ldconfig 2> /dev/null || true
+
+install -m0755 /mnt/share/distrobox /usr/local/bin/distrobox || fail "install distrobox"
+
+# Every driver file is required in the guest except these non-runtime bits.
+is_excluded()
+{
+ case "$1" in
+ */share/doc/* | */share/man/* | */share/lintian/* | */share/licenses/* | */share/metainfo/* | */share/applications/* | */share/icons/* | */share/pixmaps/*) return 0 ;;
+ */include/* | *.h | *.hpp | *.a | *.la) return 0 ;;
+ */lib/modules/* | *.ko | *.ko.* | */firmware/*) return 0 ;;
+ */.build-id/* | */lib/debug/* | */systemd/* | *.service | *.socket | */udev/* | *.rules) return 0 ;;
+ */modprobe.d/* | */modules-load.d/* | */etc/alternatives/* | */etc/ld.so.conf.d/*) return 0 ;;
+ */sysusers.d/* | */dbus-1/system.d/* | */nvidia/files.d/* | */nvidia-powerd/* | *-key-documentation) return 0 ;;
+ *copyright | *changelog* | *NEWS* | *README* | *.md) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+elfclass()
+{
+ od -An -t u1 -j 4 -N 1 "$1" 2> /dev/null | tr -d ' '
+}
+
+# Build the manifest once (host file -> expected box path), reproducing
+# distrobox-init routing: linker libs to the ELF-class bucket, fixed-path
+# plugins to the guest-native lib root by class ({LIB32}/{LIB64}, expanded in
+# the guest), configs/binaries keep their real path.
+manifest="$(mktemp)"
+pm_list | sort -u | while read -r f; do
+ [ -f "${f}" ] || [ -L "${f}" ] || continue
+ is_excluded "${f}" && continue
+ # distrobox-init rewrites an absolute library_path in ICD/vendor JSONs to the
+ # bare soname, so the guest copy differs from the host original. Checksum the
+ # same rewrite here (a no-op for JSONs without an absolute path) so the mirror
+ # check compares against what the guest will actually hold.
+ case "${f}" in
+ *.json) h="$(sed 's@\("library_path"[[:space:]]*:[[:space:]]*"\)/[^"]*/\([^"/]*"\)@\1\2@g' "${f}" 2> /dev/null | sha256sum | cut -d' ' -f1)" ;;
+ *) h="$(sha256sum "${f}" 2> /dev/null | cut -d' ' -f1)" ;;
+ esac
+ [ -n "${h}" ] || continue
+ real="${f}"
+ [ -L "${f}" ] && real="$(readlink -f "${f}" 2> /dev/null)"
+ case "${f}" in
+ */xorg/* | */gbm/* | */vdpau/* | */wine/*)
+ # Fixed-path plugin: guest-native lib root by ELF class. The guest
+ # check expands {LIB32}/{LIB64}; keep the "/..." sub-path.
+ sub="${f}"
+ case "${sub}" in
+ /usr/lib/*-linux-*/*) sub="${sub#/usr/lib/*-linux-*/}" ;;
+ /usr/lib64/*) sub="${sub#/usr/lib64/}" ;;
+ /usr/lib32/*) sub="${sub#/usr/lib32/}" ;;
+ /usr/lib/*) sub="${sub#/usr/lib/}" ;;
+ *) ;;
+ esac
+ if [ "$(elfclass "${real}")" = 1 ]; then bp="{LIB32}/${sub}"; else bp="{LIB64}/${sub}"; fi
+ ;;
+ *)
+ base="${f##*/}"
+ case "${base}" in
+ *nvidia*.so* | *libcuda* | libnvcuvid* | libnvoptix*)
+ if [ "$(elfclass "${real}")" = 1 ]; then
+ bp="/usr/lib/distrobox-nvidia/lib32/${base}"
+ else bp="/usr/lib/distrobox-nvidia/lib64/${base}"; fi
+ ;;
+ *) bp="${f}" ;;
+ esac
+ ;;
+ esac
+ printf '%s %s\n' "${h}" "${bp}"
+done > "${manifest}"
+nreq="$(grep -c . "${manifest}")"
+[ "${nreq}" -gt 0 ] || fail "empty manifest (install or oracle bug)"
+# Coverage floor: the manifest must carry every entry-point stem, else a package
+# rename or a find-glob miss would let every guest pass on a hollow file set.
+for stem in libcuda.so libnvidia-ml.so libGLX_nvidia.so libEGL_nvidia.so libnvidia-opencl.so; do
+ grep -qE "/${stem}[^/]*\$" "${manifest}" || fail "coverage floor: no ${stem}* in manifest (package set or glob changed)"
+done
+outok && pm_list | sort -u > "${OUT}/package-files.txt"
+
+# Each guest image is a gating check against the same manifest.
+bad=""
+for box in ${BOX_IMAGES}; do
+ name="nvtest-$(printf '%s' "${box##*/}" | sed 's/[:.]/-/g')"
+ log "guest ${box}: create --nvidia"
+ dbx create --yes --nvidia --image "${box}" --name "${name}" > "${SERIAL}" 2>&1 || fail "create ${box}"
+ dbx enter "${name}" -- true > "${SERIAL}" 2>&1 || fail "enter ${box}"
+ ok=0
+ for _ in $(seq 1 60); do
+ dbx enter "${name}" -- test -f /etc/ld.so.conf.d/00-distrobox-nvidia.conf && {
+ ok=1
+ break
+ }
+ sleep 2
+ done
+ [ "${ok}" = 1 ] || fail "${box}: mirror did not complete"
+
+ # shellcheck disable=SC2016 # $path/$want expand in the guest sh
+ res="$(dbx enter -T "${name}" -- sh -c '
+ l64=/usr/lib; for d in /usr/lib/x86_64-linux-gnu /usr/lib64 /usr/lib; do [ -d "$d" ] && { l64=$d; break; }; done
+ case "$l64" in
+ */x86_64-linux-gnu) l32=/usr/lib/i386-linux-gnu ;;
+ */lib64)
+ # Fedora keeps 32-bit in /usr/lib; Arch /usr/lib64 -> /usr/lib, so its 32-bit tree is /usr/lib32.
+ if [ "$(readlink -m /usr/lib64)" = "$(readlink -m /usr/lib)" ]; then l32=/usr/lib32; else l32=/usr/lib; fi ;;
+ *) l32=/usr/lib32 ;;
+ esac
+ [ "$(readlink -m "$l32")" = "$(readlink -m "$l64")" ] && l32=""
+ n=0; okc=0; skip=0
+ while read -r want path; do
+ n=$((n + 1))
+ case "$path" in
+ "{LIB32}/"*) [ -n "$l32" ] || { skip=$((skip + 1)); continue; }; path="$l32/${path#"{LIB32}/"}" ;;
+ "{LIB64}/"*) path="$l64/${path#"{LIB64}/"}" ;;
+ esac
+ [ -e "$path" ] || { echo "ABSENT $path"; continue; }
+ # Each 64-bit ELF we mirror (bucket libs, plugins, nvidia binaries)
+ # must resolve its NEEDED *nvidia* libs through the bucket. Only
+ # nvidia deps count - a missing system lib (e.g. libnvidia-pkcs11
+ # wanting OpenSSL 1.1) belongs to the guest; non-ELF/PE and 32-bit skip.
+ if [ "$(od -An -t x1 -N 4 "$path" 2>/dev/null | tr -d " ")" = 7f454c46 ] &&
+ [ "$(od -An -t u1 -j 4 -N 1 "$path" 2>/dev/null | tr -d " ")" = 2 ]; then
+ lddout="$(ldd "$path" 2>&1)"; lddrc=$?
+ [ "$lddrc" -ne 0 ] && ! printf "%s" "$lddout" | grep -q "not a dynamic executable" && echo "LDDFAIL $path [rc=$lddrc]"
+ miss="$(printf "%s\n" "$lddout" | sed -n "s/^[[:space:]]*\(.*\) => not found/\1/p" | grep -iE "nvidia|cuda|nvcuvid|nvoptix" | tr "\n" " ")"
+ [ -n "$miss" ] && echo "UNRESOLVED $path [missing: $miss]"
+ fi
+ got="$(sha256sum "$path" 2>/dev/null | cut -d" " -f1)"
+ [ "$got" = "$want" ] && { okc=$((okc + 1)); continue; }
+ sz="$(wc -c < "$path" 2>/dev/null || echo "?")"
+ real="$(readlink -f "$path" 2>/dev/null || echo "$path")"
+ mnt="$(findmnt -no SOURCE "$path" 2>/dev/null || true)"
+ own="$(pacman -Qoq "$path" 2>/dev/null || rpm -qf "$path" 2>/dev/null || dpkg -S "$path" 2>/dev/null || true)"
+ echo "MISMATCH $path [box ${sz}B ${got} real=$real mnt=${mnt:-none} owner=${own:-unowned}]"
+ done
+ # Findability: each entry-point soname must resolve THROUGH our bucket.
+ # ldd above only proves a mirrored file own NEEDED links; a vendor lib
+ # loaded purely by dlopen-by-soname (libGLX_nvidia, ...) is nobody NEEDED.
+ for so in libcuda.so.1 libnvidia-ml.so.1 libGLX_nvidia.so.0 libEGL_nvidia.so.0 libnvidia-opencl.so.1; do
+ case "$(ldconfig -p 2>/dev/null | grep -F "$so (" | head -1)" in
+ */distrobox-nvidia/*) ;;
+ *) echo "UNCACHED $so" ;;
+ esac
+ done
+ echo "READ $n"
+ echo "OK $okc"
+ echo "SKIP32 $skip"' < "${manifest}")"
+ rn="$(printf '%s\n' "${res}" | awk '$1=="READ"{print $2}')"
+ [ "${rn:-0}" = "${nreq}" ] || fail "${box}: guest read ${rn:-0}/${nreq} (stdin not forwarded?)"
+
+ # Positive accounting: OK + 32-bit-skip + ABSENT + MISMATCH must equal the
+ # lines read, so a file slipping through unclassified cannot hide as a pass.
+ okc="$(printf '%s\n' "${res}" | awk '$1=="OK"{print $2}')"
+ skc="$(printf '%s\n' "${res}" | awk '$1=="SKIP32"{print $2}')"
+ na="$(printf '%s\n' "${res}" | grep -c '^ABSENT ' || true)"
+ nm="$(printf '%s\n' "${res}" | grep -c '^MISMATCH ' || true)"
+ [ "$((${okc:-0} + ${skc:-0} + na + nm))" = "${nreq}" ] || fail "${box}: accounting off (ok=${okc:-0} skip=${skc:-0} absent=${na} mismatch=${nm} != ${nreq})"
+
+ # Loadability probes (no GPU, no manifest needed - a separate dbx enter fed a
+ # heredoc, so the body reads as ordinary shell). Each checks something the
+ # per-file pass cannot: that every ICD names a resolvable library, that the
+ # self-contained driver libs relocate cleanly, and that the fixed-path plugins
+ # sit in their loader's own directory.
+ probes="$(
+ dbx enter -T "${name}" -- sh << 'PROBE'
+soname_path() { ldconfig -p 2>/dev/null | grep -F "$1 (" | head -1 | sed 's/.*=> //'; }
+
+# EGL vendor / OpenCL / EGL-platform configs get the lightweight static check:
+# they name a bare soname the loaders find via ld.so, so resolving it in the
+# cache is a faithful proxy. Vulkan ICDs get a real load check (below) instead.
+icdn=0
+for f in $(find /etc /usr/share /usr/lib /usr/lib64 -type f \
+ \( -path '*/glvnd/egl_vendor.d/*nvidia*' -o -path '*/OpenCL/vendors/*nvidia*' \
+ -o -path '*/egl_external_platform.d/*nvidia*' \) 2>/dev/null); do
+ case "$f" in
+ *.icd) lib=$(grep -m1 '\.so' "$f" 2>/dev/null | tr -d '[:space:]') ;;
+ *) lib=$(tr -d '\n' < "$f" 2>/dev/null | sed -n 's/.*"library_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') ;;
+ esac
+ [ -n "$lib" ] || continue
+ icdn=$((icdn + 1))
+ case "$lib" in
+ */*)
+ # Absolute library_path must exist at that path. If it does not but the
+ # basename resolves in the cache, the lib was relocated to the bucket and
+ # the config points at the pre-mirror absolute path.
+ if [ ! -e "$lib" ]; then
+ if ldconfig -p 2>/dev/null | grep -qF "${lib##*/} ("; then
+ echo "ICD_UNRESOLVED $f $lib (absolute path absent; ${lib##*/} is in the cache - lib relocated to bucket)"
+ else
+ echo "ICD_UNRESOLVED $f $lib"
+ fi
+ fi
+ ;;
+ *) [ -n "$(soname_path "$lib")" ] || echo "ICD_UNRESOLVED $f $lib" ;;
+ esac
+done
+echo "ICDN $icdn"
+
+# Vulkan ICDs: drive the real loader instead of guessing. Install vulkaninfo +
+# the loader, then let it try to load each mirrored nvidia ICD. Without a GPU the
+# library still dlopens (device enumeration is a separate, later step), so a
+# "Failed loading library"/"Ignoring this JSON" line naming an nvidia lib means
+# its library_path did not resolve. A failed install is a visible SKIP, never a
+# silent pass. sudo -n avoids a hang if the guest lacks passwordless sudo.
+if ! command -v vulkaninfo > /dev/null 2>&1; then
+ {
+ if command -v apt-get > /dev/null 2>&1; then
+ timeout 300 sudo -n apt-get update -qq && timeout 300 sudo -n apt-get install -y -qq vulkan-tools
+ elif command -v dnf > /dev/null 2>&1; then
+ timeout 300 sudo -n dnf install -y -q vulkan-tools vulkan-loader
+ elif command -v pacman > /dev/null 2>&1; then
+ timeout 300 sudo -n pacman -Sy --noconfirm --needed vulkan-tools vulkan-icd-loader
+ fi
+ } > /dev/null 2>&1
+fi
+if command -v vulkaninfo > /dev/null 2>&1; then
+ nicd=$(find /etc /usr/share -type f -path '*/vulkan/icd.d/*nvidia*' 2>/dev/null | wc -l)
+ vkraw=$(VK_LOADER_DEBUG=warn,error timeout 60 vulkaninfo 2>&1)
+ vkfail=$(printf '%s\n' "$vkraw" | grep -iE 'Ignoring this JSON|Failed loading library' | grep -i nvidia)
+ if [ -n "$vkfail" ]; then
+ # Classify the rejection. A missing *nvidia* lib means the mirror dropped
+ # something -> hard fail. A missing guest lib (libGLdispatch/libX11, the GL
+ # stack the image itself must ship) is not distrobox's job -> SKIP, named.
+ # The lib loading but nvidia declining to return vkCreateInstance is the
+ # driver refusing to initialise in this guest, downstream of the mirror
+ # (ubuntu/fedora negotiate the same mirrored driver fine) -> SKIP. Only a
+ # truly unattributable failure stays a hard fail.
+ miss=$(printf '%s\n' "$vkraw" | sed -n 's/.* \([^ ]*\): cannot open shared object.*/\1/p' | sort -u)
+ nvmiss=$(printf '%s\n' "$miss" | grep -iE 'nvidia|cuda' | tr '\n' ' ')
+ if [ -n "$nvmiss" ]; then
+ echo "ICD_LOAD_FAILED nvidia lib(s) unresolved: $nvmiss"
+ elif [ -n "$miss" ]; then
+ echo "SKIP icd-vulkan (nvidia ICD present, but guest image lacks: $(printf '%s' "$miss" | tr '\n' ' '))"
+ elif printf '%s\n' "$vkraw" | grep -qi 'vk_icdGetInstanceProcAddr'; then
+ echo "SKIP icd-vulkan (nvidia ICD loaded, driver declined to init - no vkCreateInstance; not a mirror fault)"
+ else
+ printf '%s\n' "$vkfail" | sed -n 's/.*ICD JSON \(.*\)\. Ignoring.*/\1/p' | sort -u | while read -r badlib; do echo "ICD_LOAD_FAILED $badlib"; done
+ fi
+ # Full loader failure chain (nvidia libs + the dlopen "cannot open" root
+ # cause) for the OUT diagnostics, so the exact failing dependency is on
+ # record rather than only the classified token.
+ printf '%s\n' "$vkraw" | grep -iE 'nvidia|cannot open|Ignoring this JSON|Failed loading|undefined symbol|GLdispatch|glvnd' | while read -r l; do echo "VKLOG $l"; done
+ fi
+ echo "ICDVK $nicd"
+else
+ echo "SKIP icd-vulkan (vulkaninfo unavailable / install failed)"
+fi
+
+# Symbol binding: self-contained driver libs must relocate cleanly. ldd -r forces
+# function+data relocation, catching a newer-host-driver vs older-guest-glibc
+# undefined-symbol break that plain ldd passes. GLVND vendor libs are excluded -
+# they leave dispatch symbols for libGLdispatch to supply.
+for so in libcuda.so.1 libnvidia-ml.so.1 libnvidia-encode.so.1 libnvcuvid.so.1; do
+ p=$(soname_path "$so")
+ [ -n "$p" ] || continue
+ und=$(ldd -r "$p" 2>&1 | sed -n 's/^[[:space:]]*undefined symbol: \(.*\)/\1/p' | tr '\n' ' ')
+ [ -n "$und" ] && echo "UNLOADABLE $so [undefined: $und]"
+done
+
+# Plugin colocation: Xorg/GBM/VDPAU drivers are dlopen'd from the consumer
+# library's own / dir, so the correct location is defined by the
+# guest consumer, not the mirror. Anchor to the 64-bit consumer and check only
+# 64-bit plugins - a 32-bit plugin lives in its own tree matched by a 32-bit
+# loader - and canonicalise both sides so usr-merge/multiarch symlinks compare
+# equal and overlapping find roots collapse.
+for pair in libgbm.so.1:gbm libvdpau.so.1:vdpau; do
+ consumer=${pair%%:*}
+ sub=${pair#*:}
+ cp=$(ldconfig -p 2>/dev/null | grep -F "$consumer (" | grep -F ',x86-64)' | head -1 | sed 's/.*=> //')
+ [ -n "$cp" ] || { echo "SKIP plugin-$sub (guest has no 64-bit $consumer)"; continue; }
+ exp=$(readlink -m "$(dirname "$cp")/$sub")
+ for pl in $(find /usr/lib /usr/lib64 -type f -path "*/$sub/*nvidia*" 2>/dev/null | xargs -r readlink -f | sort -u); do
+ [ "$(od -An -t u1 -j 4 -N 1 "$pl" 2>/dev/null | tr -d ' ')" = 2 ] || continue
+ [ "$(readlink -m "$(dirname "$pl")")" = "$exp" ] || echo "PLUGIN_MISPLACED $pl [expected in $exp]"
+ done
+done
+PROBE
+ )"
+ printf '%s\n' "${probes}" | grep '^SKIP ' | while read -r sk; do log "guest ${box}: ${sk}"; done
+ icdn="$(printf '%s\n' "${probes}" | awk '$1=="ICDN"{print $2}')"
+ vkicd="$(printf '%s\n' "${probes}" | awk '$1=="ICDVK"{print $2}')"
+ log "guest ${box}: probes - ${icdn:-0} egl/cl config(s), ${vkicd:-0} vulkan icd(s) load-checked"
+
+ probs="$(printf '%s\n' "${res}" "${probes}" | grep -E '^(ABSENT|MISMATCH|UNRESOLVED|LDDFAIL|UNCACHED|ICD_UNRESOLVED|ICD_LOAD_FAILED|UNLOADABLE|PLUGIN_MISPLACED) ' || true)"
+ n="$(printf '%s\n' "${probs}" | grep -c . || true)"
+ if outok; then
+ printf '%s\n' "${probs}" | grep . > "${OUT}/failures-${name}.txt" 2> /dev/null || true
+ # Raw Vulkan loader failure chain (only when the nvidia ICD did not load),
+ # so the exact dlopen root cause is on record, not just the token.
+ vklog="$(printf '%s\n' "${probes}" | sed -n 's/^VKLOG //p')"
+ [ -z "${vklog}" ] || printf '%s\n' "${vklog}" > "${OUT}/vulkan-loader-${name}.txt"
+ # Inventory for manual review: the bucket plus the fixed-path plugins
+ # wherever they landed (nvidia-named, so guest Mesa backends stay out).
+ dbx enter "${name}" -- sh -c '
+ find /usr/lib/distrobox-nvidia -type f 2>/dev/null
+ find /usr/lib /usr/lib32 /usr/lib64 /usr/lib/*-linux-gnu -type f \
+ \( -path "*/gbm/*nvidia*" -o -path "*/vdpau/*nvidia*" -o -path "*/xorg/*nvidia*" -o -path "*/nvidia/wine/*" \) 2>/dev/null
+ ' | sort -u > "${OUT}/box-files-${name}.txt" || true
+ fi
+ if [ "${n}" -gt 0 ]; then
+ log "guest ${box}: ${n} problem(s) (of ${nreq} files)"
+ bad="${bad} ${box}"
+ else log "guest ${box}: OK (${nreq} files)"; fi
+done
+
+[ -z "${bad}" ] || fail "guest images failed:${bad}"
+log "all guests passed (${nreq} files each)"
+pass
diff --git a/hack/test/test-nvidia-integration.sh b/hack/test/test-nvidia-integration.sh
new file mode 100755
index 00000000..b3d10ca0
--- /dev/null
+++ b/hack/test/test-nvidia-integration.sh
@@ -0,0 +1,70 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-only
+#
+# This file is part of the distrobox project:
+# https://github.com/89luca89/distrobox
+#
+# Copyright (C) 2021 distrobox contributors
+#
+# distrobox is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3
+# as published by the Free Software Foundation.
+#
+# distrobox is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with distrobox; if not, see .
+#
+set -eu
+#
+# hack/test/test-nvidia-integration.sh
+#
+# distrobox --nvidia integration test. Builds distrobox, stages the host binary
+# and the in-VM script (nvidia-test.sh, as the run.sh entry point vm-run.sh
+# executes) into a share, then hands it to the generic vm-run.sh which boots a
+# throwaway VM and waits for the result. The in-VM logic (install the
+# driver, create --nvidia for each guest image, checksum every mirrored file)
+# lives in nvidia-test.sh. Diagnostics land in
+# ./last-out-/.
+#
+
+DISTRO="${1:?usage: test-nvidia-integration.sh }"
+HERE="$(cd "$(dirname "$0")" && pwd)"
+REPO="$(cd "${HERE}/../.." && pwd)"
+OUT="${HERE}/out/last-out-${DISTRO}"
+
+msg()
+{
+ printf '\033[1;34m==>\033[0m %s\n' "$*" >&2
+}
+
+msg "building distrobox"
+make -C "${REPO}" build >&2
+
+work="$(mktemp -d)"
+trap 'rm -rf "${work}"' EXIT INT TERM
+
+# Read-only share into the VM: the built binary, the in-VM script (as run.sh) and
+# the host distro (nvidia-test.sh reads it from /mnt/share/distro).
+share="${work}/share"
+mkdir -p "${share}"
+install -m0755 "${REPO}/bin/distrobox" "${share}/distrobox"
+install -m0755 "${HERE}/nvidia-test.sh" "${share}/run.sh"
+printf '%s\n' "${DISTRO}" > "${share}/distro"
+
+rm -rf "${OUT}"
+mkdir -p "${OUT}"
+
+rc=0
+"${HERE}/vm-run.sh" "${DISTRO}" "${share}" "${OUT}" || rc=$?
+
+for ff in "${OUT}"/failures-*.txt; do
+ [ -s "${ff}" ] || continue
+ printf '\n\033[1m--- %s ---\033[0m\n' "${ff##*/}" >&2
+ cat "${ff}" >&2
+done
+msg "review: ${OUT}/ (package-files.txt, box-files-*.txt, failures-*.txt, serial.log)"
+exit "${rc}"
diff --git a/hack/test/vm-run.sh b/hack/test/vm-run.sh
new file mode 100755
index 00000000..b1b799d8
--- /dev/null
+++ b/hack/test/vm-run.sh
@@ -0,0 +1,164 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-only
+#
+# This file is part of the distrobox project:
+# https://github.com/89luca89/distrobox
+#
+# Copyright (C) 2021 distrobox contributors
+#
+# distrobox is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3
+# as published by the Free Software Foundation.
+#
+# distrobox is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with distrobox; if not, see .
+#
+set -eu
+#
+# Generic throwaway-VM runner for the hack/test suite.
+#
+# Boots a cloud image of with mounted read-only at
+# /mnt/share (9p) and read-write at /mnt/out, runs /mnt/share/run.sh,
+# and waits for it to print "=== RESULT: PASS|FAIL ===" on the serial console.
+# Every run boots a byte-fresh copy of the pristine (cached, read-only) base
+# image, so the payload cannot corrupt state between runs.
+#
+# usage: vm-run.sh
+# ( must contain an executable run.sh entry point)
+# exit: 0 = PASS, 1 = FAIL, 2 = infra error / no result before the timeout
+#
+
+DISTRO="${1:?usage: vm-run.sh }"
+SHARE="${2:?share dir required}"
+OUT="${3:?out dir required}"
+
+case "${DISTRO}" in
+ ubuntu) IMG_URL="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img" ;;
+ fedora) IMG_URL="https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2" ;;
+ arch) IMG_URL="https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2" ;;
+ *)
+ printf 'unknown distro: %s\n' "${DISTRO}" >&2
+ exit 2
+ ;;
+esac
+
+MEM=4096
+CPUS=4
+TIMEOUT=2400
+CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/distrobox-vmtest"
+
+msg()
+{
+ printf '\033[1;34m==>\033[0m %s\n' "$*" >&2
+}
+bail()
+{
+ printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2
+ exit 2
+}
+
+command -v cloud-localds > /dev/null || bail "need cloud-localds (from cloud-image-utils)"
+command -v curl > /dev/null || bail "need curl"
+command -v qemu-img > /dev/null || bail "need qemu-img"
+command -v qemu-system-x86_64 > /dev/null || bail "need qemu-system-x86_64"
+[ -f "${SHARE}/run.sh" ] || bail "share dir has no run.sh entry point: ${SHARE}"
+
+mkdir -p "${CACHE}"
+base="${CACHE}/${DISTRO}-${IMG_URL##*/}"
+if [ ! -f "${base}" ]; then
+ msg "downloading ${DISTRO} cloud image"
+ curl -fSL -o "${base}.part" "${IMG_URL}" || bail "download failed: ${IMG_URL}"
+ mv "${base}.part" "${base}"
+ chmod 0444 "${base}"
+fi
+
+work="$(mktemp -d)"
+pidfile="${work}/pid"
+serial="${work}/serial"
+tailpid=""
+
+# shellcheck disable=SC2317,SC2329 # cleanup runs via the EXIT/INT/TERM trap
+cleanup()
+{
+ if [ -n "${tailpid}" ]; then kill "${tailpid}" 2> /dev/null || true; fi
+ if [ -f "${pidfile}" ]; then kill "$(cat "${pidfile}")" 2> /dev/null || true; fi
+ if [ -f "${serial}" ]; then cp -f "${serial}" "${OUT}/serial.log" 2> /dev/null || true; fi
+ rm -rf "${work}"
+}
+trap cleanup EXIT INT TERM
+
+disk="${work}/disk.qcow2"
+cp --reflink=auto "${base}" "${disk}"
+chmod u+w "${disk}"
+qemu-img resize "${disk}" +20G > /dev/null
+
+# A fixed instance-id is fine: the disk is always a fresh copy of the pristine
+# base, so cloud-init sees a clean instance and re-provisions every boot.
+printf 'instance-id: dbxtest\nlocal-hostname: dbxtest\n' > "${work}/meta-data"
+cat > "${work}/user-data" << 'EOF'
+#cloud-config
+runcmd:
+ - [ sh, -c, "modprobe 9pnet_virtio 9p 2>/dev/null || true" ]
+ - [ mkdir, -p, /mnt/share, /mnt/out ]
+ - [ sh, -c, "mount -t 9p -o trans=virtio,version=9p2000.L,ro dbxshare /mnt/share" ]
+ - [ sh, -c, "mount -t 9p -o trans=virtio,version=9p2000.L,rw dbxout /mnt/out || true" ]
+ - [ sh, -c, "sh /mnt/share/run.sh || { echo '=== RESULT: FAIL ===' > /dev/ttyS0; poweroff -f; }" ]
+EOF
+
+cloud-localds "${work}/seed.img" "${work}/user-data" "${work}/meta-data"
+
+# Only the acceleration flags vary, so only they go through $@ (POSIX sh has no
+# arrays); the rest of the command is inline with every path quoted.
+if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
+ set -- -enable-kvm -cpu host
+else
+ set -- -cpu max
+fi
+
+msg "booting ${DISTRO} VM (timeout ${TIMEOUT}s)"
+qemu-system-x86_64 "$@" \
+ -m "${MEM}" -smp "${CPUS}" \
+ -drive "if=virtio,format=qcow2,file=${disk}" \
+ -drive "if=virtio,format=raw,file=${work}/seed.img" \
+ -fsdev "local,id=s,path=${SHARE},security_model=none,readonly=on" -device virtio-9p-pci,fsdev=s,mount_tag=dbxshare \
+ -fsdev "local,id=o,path=${OUT},security_model=none" -device virtio-9p-pci,fsdev=o,mount_tag=dbxout \
+ -netdev user,id=n -device virtio-net-pci,netdev=n \
+ -display none -serial "file:${serial}" -monitor none -no-reboot -pidfile "${pidfile}" -daemonize < /dev/null
+
+touch "${serial}"
+
+# Strip CSI escapes from the live console dump
+tail -f "${serial}" | sed -u 's/\x1b\[[0-9;?]*[[:alpha:]]//g' >&2 &
+tailpid=$!
+
+result=""
+elapsed=0
+while [ "${elapsed}" -lt "${TIMEOUT}" ]; do
+ if grep -q 'RESULT:' "${serial}" 2> /dev/null; then
+ result="$(grep -o 'RESULT: [A-Z]*' "${serial}" | tail -1 | awk '{print $2}')"
+ break
+ fi
+ if [ -f "${pidfile}" ] && ! kill -0 "$(cat "${pidfile}")" 2> /dev/null; then break; fi
+ sleep 5
+ elapsed=$((elapsed + 5))
+done
+kill "${tailpid}" 2> /dev/null || true
+tailpid=""
+if [ -z "${result}" ]; then result="$(grep -o 'RESULT: [A-Z]*' "${serial}" 2> /dev/null | tail -1 | awk '{print $2}')"; fi
+
+case "${result}" in
+ PASS)
+ msg "VM result: PASS (${DISTRO})"
+ exit 0
+ ;;
+ FAIL)
+ msg "VM result: FAIL (${DISTRO})"
+ exit 1
+ ;;
+ *) bail "no result after ${TIMEOUT}s (see ${OUT}/serial.log)" ;;
+esac