]> git.proxmox.com Git - systemd.git/blobdiff - test/test-functions
New upstream version 252.5
[systemd.git] / test / test-functions
index 6667e0524fa29d14f645b5f456db4b0c558046de..ae0a99333749a146d4a1070456f4eed448aaa3d6 100644 (file)
-#!/bin/bash
+#!/usr/bin/env bash
 # -*- mode: shell-script; indent-tabs-mode: nil; sh-basic-offset: 4; -*-
-# ex: ts=8 sw=4 sts=4 et filetype=sh
+# SPDX-License-Identifier: LGPL-2.1-or-later
+#
+# shellcheck disable=SC2030,SC2031
+# ex: ts=8 sw=4 sts=4 et filetype=sh tw=180
+# Note: the shellcheck line above disables warning for variables which were
+#       modified in a subshell. In our case this behavior is expected, but
+#       `shellcheck` can't distinguish this because of poor variable tracking,
+#       which results in warning for every instance of such variable used
+#       throughout this file.
+#       See:
+#           * comment in function install_verity_minimal()
+#           * koalaman/shellcheck#280
+set -o pipefail
+
 PATH=/sbin:/bin:/usr/sbin:/usr/bin
 export PATH
 
-LOOKS_LIKE_DEBIAN=$(source /etc/os-release && [[ "$ID" = "debian" || "$ID_LIKE" = "debian" ]] && echo yes)
-KERNEL_VER=${KERNEL_VER-$(uname -r)}
-KERNEL_MODS="/lib/modules/$KERNEL_VER/"
+os_release=$(test -e /etc/os-release && echo /etc/os-release || echo /usr/lib/os-release)
+# shellcheck source=/dev/null
+source "$os_release"
+[[ "$ID" = "debian" || " $ID_LIKE " = *" debian "* ]] && LOOKS_LIKE_DEBIAN=yes || LOOKS_LIKE_DEBIAN=""
+[[ "$ID" = "arch" || " $ID_LIKE " = *" arch "* ]] && LOOKS_LIKE_ARCH=yes || LOOKS_LIKE_ARCH=""
+[[ " $ID_LIKE " = *" suse "* ]] && LOOKS_LIKE_SUSE=yes || LOOKS_LIKE_SUSE=""
+KERNEL_VER="${KERNEL_VER-$(uname -r)}"
+QEMU_TIMEOUT="${QEMU_TIMEOUT:-1800}"
+NSPAWN_TIMEOUT="${NSPAWN_TIMEOUT:-1800}"
+TIMED_OUT=  # will be 1 after run_* if *_TIMEOUT is set and test timed out
+[[ "$LOOKS_LIKE_SUSE" ]] && FSTYPE="${FSTYPE:-btrfs}" || FSTYPE="${FSTYPE:-ext4}"
+UNIFIED_CGROUP_HIERARCHY="${UNIFIED_CGROUP_HIERARCHY:-default}"
+EFI_MOUNT="${EFI_MOUNT:-$(bootctl -x 2>/dev/null || echo /boot)}"
+# Note that defining a different IMAGE_NAME in a test setup script will only result
+# in default.img being copied and renamed. It can then be extended by defining
+# a test_append_files() function. The $1 parameter will be the root directory.
+# To force creating a new image from scratch (eg: to encrypt it), also define
+# TEST_FORCE_NEWIMAGE=1 in the test setup script.
+IMAGE_NAME=${IMAGE_NAME:-default}
+STRIP_BINARIES="${STRIP_BINARIES:-yes}"
+TEST_REQUIRE_INSTALL_TESTS="${TEST_REQUIRE_INSTALL_TESTS:-1}"
+TEST_PARALLELIZE="${TEST_PARALLELIZE:-0}"
+TEST_SUPPORTING_SERVICES_SHOULD_BE_MASKED="${TEST_SUPPORTING_SERVICES_SHOULD_BE_MASKED:-1}"
+LOOPDEV=
+
+# Simple wrapper to unify boolean checks.
+# Note: this function needs to stay near the top of the file, so we can use it
+#       in code in the outermost scope.
+get_bool() {
+    # Make the value lowercase to make the regex matching simpler
+    local _bool="${1,,}"
+
+    # Consider empty value as "false"
+    if [[ -z "$_bool" || "$_bool" =~ ^(0|no|false)$ ]]; then
+        return 1
+    elif [[ "$_bool" =~ ^(1|yes|true)$ ]]; then
+        return 0
+    else
+        echo >&2 "Value '$_bool' is not a valid boolean value"
+        exit 1
+    fi
+}
+
+# Since in Bash we can have only one handler per signal, let's overcome this
+# limitation by having one global handler for the EXIT signal which executes
+# all registered handlers
+_AT_EXIT_HANDLERS=()
+_at_exit() {
+    set +e
+
+    # Run the EXIT handlers in reverse order
+    for ((i = ${#_AT_EXIT_HANDLERS[@]} - 1; i >= 0; i--)); do
+        ddebug "Running EXIT handler '${_AT_EXIT_HANDLERS[$i]}'"
+        "${_AT_EXIT_HANDLERS[$i]}"
+    done
+}
+
+trap _at_exit EXIT
+
+add_at_exit_handler() {
+    local handler="${1?}"
+
+    if [[ "$(type -t "$handler")" != "function" ]]; then
+        dfatal "'$handler' is not a function"
+        exit 1
+    fi
+
+    _AT_EXIT_HANDLERS+=("$handler")
+}
+
+# Decide if we can (and want to) run qemu with KVM acceleration.
+# Check if nested KVM is explicitly enabled (TEST_NESTED_KVM). If not,
+# check if it's not explicitly disabled (TEST_NO_KVM) and we're not already
+# running under KVM. If these conditions are met, enable KVM (and possibly
+# nested KVM), otherwise disable it.
+if get_bool "${TEST_NESTED_KVM:=}" || (! get_bool "${TEST_NO_KVM:=}" && ! systemd-detect-virt -qv); then
+    QEMU_KVM=yes
+else
+    QEMU_KVM=no
+fi
 
 if ! ROOTLIBDIR=$(pkg-config --variable=systemdutildir systemd); then
     echo "WARNING! Cannot determine rootlibdir from pkg-config, assuming /usr/lib/systemd" >&2
     ROOTLIBDIR=/usr/lib/systemd
 fi
 
-BASICTOOLS="sh bash setsid loadkeys setfont login sulogin gzip sleep echo mount umount cryptsetup date dmsetup modprobe sed cmp tee rm"
-DEBUGTOOLS="df free ls stty cat ps ln ip route dmesg dhclient mkdir cp ping dhclient strace less grep id tty touch du sort hostname find"
+# The calling test.sh scripts have TEST_BASE_DIR set via their Makefile, but we don't need them to provide it
+TEST_BASE_DIR=${TEST_BASE_DIR:-$(realpath "$(dirname "${BASH_SOURCE[0]}")")}
+TEST_UNITS_DIR="$(realpath "$TEST_BASE_DIR/units")"
+SOURCE_DIR=$(realpath "$TEST_BASE_DIR/..")
+TOOLS_DIR="$SOURCE_DIR/tools"
+# These variables are used by test scripts
+export TEST_BASE_DIR TEST_UNITS_DIR SOURCE_DIR TOOLS_DIR
+
+# note that find-build-dir.sh will return $BUILD_DIR if provided, else it will try to find it
+if get_bool "${NO_BUILD:=}"; then
+    BUILD_DIR="$SOURCE_DIR"
+elif ! BUILD_DIR="$("$TOOLS_DIR"/find-build-dir.sh)"; then
+    echo "ERROR: no build found, please set BUILD_DIR or use NO_BUILD" >&2
+    exit 1
+fi
 
-function find_qemu_bin() {
-    # SUSE and Red Hat call the binary qemu-kvm
-    # Debian and Gentoo call it kvm
-    [ "$QEMU_BIN" ] || QEMU_BIN=$(which -a kvm qemu-kvm 2>/dev/null | grep '^/' -m1)
+PATH_TO_INIT="$ROOTLIBDIR/systemd"
+SYSTEMD_JOURNALD="${SYSTEMD_JOURNALD:-$(command -v "$BUILD_DIR/systemd-journald" || command -v "$ROOTLIBDIR/systemd-journald")}"
+SYSTEMD_JOURNAL_REMOTE="${SYSTEMD_JOURNAL_REMOTE:-$(command -v "$BUILD_DIR/systemd-journal-remote" || command -v "$ROOTLIBDIR/systemd-journal-remote" || echo "")}"
+SYSTEMD="${SYSTEMD:-$(command -v "$BUILD_DIR/systemd" || command -v "$ROOTLIBDIR/systemd")}"
+SYSTEMD_NSPAWN="${SYSTEMD_NSPAWN:-$(command -v "$BUILD_DIR/systemd-nspawn" || command -v systemd-nspawn)}"
+JOURNALCTL="${JOURNALCTL:-$(command -v "$BUILD_DIR/journalctl" || command -v journalctl)}"
+SYSTEMCTL="${SYSTEMCTL:-$(command -v "$BUILD_DIR/systemctl" || command -v systemctl)}"
+
+TESTFILE="${BASH_SOURCE[1]}"
+if [ -z "$TESTFILE" ]; then
+    echo "ERROR: test-functions must be sourced from one of the TEST-*/test.sh scripts" >&2
+    exit 1
+fi
+TESTNAME="$(basename "$(dirname "$(realpath "$TESTFILE")")")"
+STATEDIR="$BUILD_DIR/test/$TESTNAME"
+STATEFILE="$STATEDIR/.testdir"
+IMAGESTATEDIR="$STATEDIR/.."
+TESTLOG="$STATEDIR/test.log"
+
+if ! [[ "$TESTNAME" =~ ^TEST\-([0-9]+)\-.+$ ]]; then
+    echo "ERROR: Test name '$TESTNAME' is not in the expected format: TEST-[0-9]+-*" >&2
+    exit 1
+fi
+TESTID="${BASH_REMATCH[1]:?}"
 
-    [ "$ARCH" ] || ARCH=$(uname -m)
+if [[ ! -f "$TEST_UNITS_DIR/testsuite-$TESTID.service" ]]; then
+    echo "ERROR: Test '$TESTNAME' is missing its service file '$TEST_UNITS_DIR/testsuite-$TESTID.service" >&2
+    exit 1
+fi
+
+BASICTOOLS=(
+    awk
+    base64
+    basename
+    bash
+    capsh
+    cat
+    chmod
+    chown
+    chroot
+    cmp
+    cryptsetup
+    cut
+    date
+    dd
+    diff
+    dirname
+    dmsetup
+    echo
+    env
+    false
+    flock
+    getconf
+    getent
+    getfacl
+    grep
+    gunzip
+    gzip
+    head
+    ionice
+    ip
+    jq
+    killall
+    ldd
+    ln
+    loadkeys
+    login
+    losetup
+    lz4cat
+    mkfifo
+    mktemp
+    modprobe
+    mount
+    mountpoint
+    mv
+    nc
+    nproc
+    pkill
+    readlink
+    rev
+    rm
+    rmdir
+    rmmod
+    sed
+    seq
+    setfattr
+    setfont
+    setsid
+    sfdisk
+    sh
+    sleep
+    stat
+    su
+    sulogin
+    sysctl
+    tail
+    tar
+    tee
+    test
+    timeout
+    touch
+    tr
+    true
+    truncate
+    umount
+    uname
+    unshare
+    useradd
+    userdel
+    wc
+    xargs
+    xzcat
+)
+
+DEBUGTOOLS=(
+    cp
+    df
+    dhclient
+    dmesg
+    du
+    find
+    free
+    grep
+    hostname
+    id
+    less
+    ln
+    ls
+    mkdir
+    ping
+    ps
+    route
+    sort
+    strace
+    stty
+    tty
+    vi
+    /usr/libexec/vi
+)
+
+is_built_with_asan() {
+    local _bin="${1:?}"
+
+    if ! type -P objdump >/dev/null; then
+        ddebug "Failed to find objdump. Assuming systemd hasn't been built with ASAN."
+        return 1
+    fi
+
+    # Borrowed from https://github.com/google/oss-fuzz/blob/cd9acd02f9d3f6e80011cc1e9549be526ce5f270/infra/base-images/base-runner/bad_build_check#L182
+    local _asan_calls
+    _asan_calls="$(objdump -dC "$_bin" | grep -E "(callq?|brasl?|bl)\s.+__asan" -c)"
+    if ((_asan_calls < 1000)); then
+        return 1
+    else
+        return 0
+    fi
+}
+
+is_built_with_coverage() {
+    if get_bool "${NO_BUILD:=}" || ! command -v meson >/dev/null; then
+        return 1
+    fi
+
+    meson configure "${BUILD_DIR:?}" | grep 'b_coverage' | awk '{ print $2 }' | grep -q 'true'
+}
+
+IS_BUILT_WITH_ASAN=$(is_built_with_asan "$SYSTEMD_JOURNALD" && echo yes || echo no)
+IS_BUILT_WITH_COVERAGE=$(is_built_with_coverage && echo yes || echo no)
+
+if get_bool "$IS_BUILT_WITH_ASAN"; then
+    STRIP_BINARIES=no
+    SKIP_INITRD="${SKIP_INITRD:-yes}"
+    PATH_TO_INIT=$ROOTLIBDIR/systemd-under-asan
+    QEMU_MEM="${QEMU_MEM:-2G}"
+    QEMU_SMP="${QEMU_SMP:-4}"
+
+    # We need to correctly distinguish between gcc's and clang's ASan DSOs.
+    if ASAN_RT_NAME="$(awk '/libasan.so/ {x=$1; exit} END {print x; exit x==""}' < <(ldd "$SYSTEMD"))"; then
+        ASAN_COMPILER=gcc
+        ASAN_RT_PATH="$(readlink -f "$(${CC:-gcc} --print-file-name "$ASAN_RT_NAME")")"
+    elif ASAN_RT_NAME="$(awk '/libclang_rt.asan/ {x=$1; exit} END {print x; exit x==""}' < <(ldd "$SYSTEMD"))"; then
+        ASAN_COMPILER=clang
+        ASAN_RT_PATH="$(readlink -f "$(${CC:-clang} --print-file-name "$ASAN_RT_NAME")")"
+
+        # As clang's ASan DSO is usually in a non-standard path, let's check if
+        # the environment is set accordingly. If not, warn the user and exit.
+        # We're not setting the LD_LIBRARY_PATH automagically here, because
+        # user should encounter (and fix) the same issue when running the unit
+        # tests (meson test)
+        if ldd "$SYSTEMD" | grep -q "libclang_rt.asan.*not found"; then
+            echo >&2 "clang's ASan DSO ($ASAN_RT_NAME) is not present in the runtime library path"
+            echo >&2 "Consider setting LD_LIBRARY_PATH=${ASAN_RT_PATH%/*}"
+            exit 1
+        fi
+    else
+        echo >&2 "systemd is not linked against the ASan DSO"
+        echo >&2 "gcc does this by default, for clang compile with -shared-libasan"
+        exit 1
+    fi
+
+    echo "Detected ASan RT '$ASAN_RT_NAME' located at '$ASAN_RT_PATH'"
+fi
+
+find_qemu_bin() {
+    QEMU_BIN="${QEMU_BIN:-""}"
+    # SUSE and Red Hat call the binary qemu-kvm. Debian and Gentoo call it kvm.
+    if get_bool "$QEMU_KVM"; then
+        [[ -n "$QEMU_BIN" ]] || QEMU_BIN="$(command -v kvm qemu-kvm 2>/dev/null | grep '^/' -m1)"
+    fi
+
+    [[ -n "$ARCH" ]] || ARCH="$(uname -m)"
     case $ARCH in
     x86_64)
         # QEMU's own build system calls it qemu-system-x86_64
-        [ "$QEMU_BIN" ] || QEMU_BIN=$(which -a qemu-system-x86_64 2>/dev/null | grep '^/' -m1)
+        [[ -n "$QEMU_BIN" ]] || QEMU_BIN="$(command -v qemu-system-x86_64 2>/dev/null | grep '^/' -m1)"
         ;;
     i*86)
         # new i386 version of QEMU
-        [ "$QEMU_BIN" ] || QEMU_BIN=$(which -a qemu-system-i386 2>/dev/null | grep '^/' -m1)
+        [[ -n "$QEMU_BIN" ]] || QEMU_BIN="$(command -v qemu-system-i386 2>/dev/null | grep '^/' -m1)"
 
         # i386 version of QEMU
-        [ "$QEMU_BIN" ] || QEMU_BIN=$(which -a qemu 2>/dev/null | grep '^/' -m1)
+        [[ -n "$QEMU_BIN" ]] || QEMU_BIN="$(command -v qemu 2>/dev/null | grep '^/' -m1)"
+        ;;
+    ppc64*)
+        [[ -n "$QEMU_BIN" ]] || QEMU_BIN="$(command -v qemu-system-ppc64 2>/dev/null | grep '^/' -m1)"
         ;;
     esac
 
-    if [ ! -e "$QEMU_BIN" ]; then
-        echo "Could not find a suitable QEMU binary" >&2
+    if [[ ! -e "$QEMU_BIN" ]]; then
+        echo "Could not find a suitable qemu binary" >&2
         return 1
     fi
 }
 
+# Compares argument #1=X.Y.Z (X&Y&Z = numeric) to the version of the installed qemu
+# returns 0 if newer or equal
+# returns 1 if older
+# returns 2 if failing
+qemu_min_version() {
+    find_qemu_bin || return 2
+
+    # get version from binary
+    local qemu_ver
+    qemu_ver="$("$QEMU_BIN" --version | awk '/^QEMU emulator version ([0-9]*\.[0-9]*\.[0-9]*)/ {print $4}')"
+
+    # Check version string format
+    echo "$qemu_ver" | grep -q '^[0-9]*\.[0-9]*\.[0-9]*$' || return 2
+    echo "$1" | grep -q '^[0-9]*\.[0-9]*\.[0-9]*$' || return 2
+
+    # compare as last command to return that value
+    printf "%s\n%s\n" "$1" "$qemu_ver" | sort -V -C
+}
+
+# Return 0 if qemu did run (then you must check the result state/logs for actual
+# success), or 1 if qemu is not available.
 run_qemu() {
+    # If the test provided its own initrd, use it (e.g. TEST-24)
+    if [[ -z "$INITRD" && -f "${TESTDIR:?}/initrd.img" ]]; then
+        INITRD="$TESTDIR/initrd.img"
+    fi
+
     if [ -f /etc/machine-id ]; then
-        read MACHINE_ID < /etc/machine-id
-        [ -z "$INITRD" ] && [ -e "/boot/$MACHINE_ID/$KERNEL_VER/initrd" ] \
-            && INITRD="/boot/$MACHINE_ID/$KERNEL_VER/initrd"
-        [ -z "$KERNEL_BIN" ] && [ -e "/boot/$MACHINE_ID/$KERNEL_VER/linux" ] \
-            && KERNEL_BIN="/boot/$MACHINE_ID/$KERNEL_VER/linux"
+        read -r MACHINE_ID </etc/machine-id
+        [ -z "$INITRD" ] && [ -e "$EFI_MOUNT/$MACHINE_ID/$KERNEL_VER/initrd" ] \
+            && INITRD="$EFI_MOUNT/$MACHINE_ID/$KERNEL_VER/initrd"
+        [ -z "$KERNEL_BIN" ] && [ -e "$EFI_MOUNT/$MACHINE_ID/$KERNEL_VER/linux" ] \
+            && KERNEL_BIN="$EFI_MOUNT/$MACHINE_ID/$KERNEL_VER/linux"
     fi
 
-    default_fedora_initrd=/boot/initramfs-${KERNEL_VER}.img
-    default_debian_initrd=/boot/initrd.img-${KERNEL_VER}
-    [ "$KERNEL_BIN" ] || KERNEL_BIN=/boot/vmlinuz-$KERNEL_VER
-    [ "$INITRD" ]     || { [ -e "$default_fedora_initrd" ] && INITRD=$default_fedora_initrd; }
-    [ "$INITRD" ]     || { [ "$LOOKS_LIKE_DEBIAN" ] && [ -e "$default_debian_initrd" ] && INITRD=$default_debian_initrd; }
-    [ "$QEMU_SMP" ]   || QEMU_SMP=1
+    local CONSOLE=ttyS0
+
+    rm -f "$initdir"/{testok,failed,skipped}
+    # make sure the initdir is not mounted to avoid concurrent access
+    cleanup_initdir
+    umount_loopback
+
+    if [[ ! "$KERNEL_BIN" ]]; then
+        if get_bool "$LOOKS_LIKE_ARCH"; then
+            KERNEL_BIN=/boot/vmlinuz-linux
+        else
+            [ "$ARCH" ] || ARCH=$(uname -m)
+            case $ARCH in
+                ppc64*)
+                    # Ubuntu ppc64* calls the kernel binary as vmlinux-*, RHEL/CentOS
+                    # uses the "standard" vmlinuz- prefix
+                    [[ -e "/boot/vmlinux-$KERNEL_VER" ]] && KERNEL_BIN="/boot/vmlinux-$KERNEL_VER" || KERNEL_BIN="/boot/vmlinuz-$KERNEL_VER"
+                    CONSOLE=hvc0
+                    ;;
+                *)
+                    KERNEL_BIN="/boot/vmlinuz-$KERNEL_VER"
+                    ;;
+            esac
+        fi
+    fi
+
+    local default_fedora_initrd="/boot/initramfs-${KERNEL_VER}.img"
+    local default_debian_initrd="/boot/initrd.img-${KERNEL_VER}"
+    local default_arch_initrd="/boot/initramfs-linux-fallback.img"
+    local default_suse_initrd="/boot/initrd-${KERNEL_VER}"
+    if [[ ! "$INITRD" ]]; then
+        if [[ -e "$default_fedora_initrd" ]]; then
+            INITRD="$default_fedora_initrd"
+        elif [[ "$LOOKS_LIKE_DEBIAN" && -e "$default_debian_initrd" ]]; then
+            INITRD="$default_debian_initrd"
+        elif [[ "$LOOKS_LIKE_ARCH" && -e "$default_arch_initrd" ]]; then
+            INITRD="$default_arch_initrd"
+        elif [[ "$LOOKS_LIKE_SUSE" && -e "$default_suse_initrd" ]]; then
+            INITRD="$default_suse_initrd"
+        fi
+    fi
+
+    # If QEMU_SMP was not explicitly set, try to determine the value 'dynamically'
+    # i.e. use the number of online CPUs on the host machine. If the nproc utility
+    # is not installed or there's some other error when calling it, fall back
+    # to the original value (QEMU_SMP=1).
+    if [[ -z "${QEMU_SMP:=}" ]]; then
+        if ! QEMU_SMP=$(nproc); then
+            dwarn "nproc utility is not installed, falling back to QEMU_SMP=1"
+            QEMU_SMP=1
+        fi
+    fi
 
     find_qemu_bin || return 1
 
-    KERNEL_APPEND="root=/dev/sda1 \
-systemd.log_level=debug \
-raid=noautodetect \
-loglevel=2 \
-init=$ROOTLIBDIR/systemd \
-ro \
-console=ttyS0 \
-selinux=0 \
-$KERNEL_APPEND \
-"
+    # Umount initdir to avoid concurrent access to the filesystem
+    _umount_dir "$initdir"
+
+    local kernel_params=()
+    local qemu_options=()
+    local qemu_cmd=("$QEMU_BIN")
+
+    if [[ "$UNIFIED_CGROUP_HIERARCHY" = "yes" ]]; then
+        kernel_params+=("systemd.unified_cgroup_hierarchy=yes")
+    elif [[ "$UNIFIED_CGROUP_HIERARCHY" = "no" ]]; then
+        kernel_params+=("systemd.unified_cgroup_hierarchy=no" "systemd.legacy_systemd_cgroup_controller=yes")
+    elif [[ "$UNIFIED_CGROUP_HIERARCHY" = "hybrid" ]]; then
+        kernel_params+=("systemd.unified_cgroup_hierarchy=no" "systemd.legacy_systemd_cgroup_controller=no")
+    elif [[ "$UNIFIED_CGROUP_HIERARCHY" != "default" ]]; then
+        dfatal "Unknown UNIFIED_CGROUP_HIERARCHY. Got $UNIFIED_CGROUP_HIERARCHY, expected [yes|no|hybrid|default]"
+        exit 1
+    fi
+
+    if get_bool "$LOOKS_LIKE_SUSE"; then
+        kernel_params+=("rd.hostonly=0")
+    fi
 
-    QEMU_OPTIONS="-smp $QEMU_SMP \
--net none \
--m 512M \
--nographic \
--kernel $KERNEL_BIN \
-"
+    kernel_params+=(
+        "root=LABEL=systemd_boot"
+        "rw"
+        "raid=noautodetect"
+        "rd.luks=0"
+        "loglevel=2"
+        "init=$PATH_TO_INIT"
+        "console=$CONSOLE"
+        "SYSTEMD_UNIT_PATH=/usr/lib/systemd/tests/testdata/testsuite-$1.units:/usr/lib/systemd/tests/testdata/units:"
+        "systemd.unit=testsuite.target"
+        "systemd.wants=testsuite-$1.service"
+    )
 
-    if [ "$INITRD" ]; then
-        QEMU_OPTIONS="$QEMU_OPTIONS -initrd $INITRD"
+    if ! get_bool "$INTERACTIVE_DEBUG"; then
+        kernel_params+=(
+            "oops=panic"
+            "panic=1"
+            "softlockup_panic=1"
+            "systemd.wants=end.service"
+        )
     fi
 
-    if [ -c /dev/kvm ]; then
-        QEMU_OPTIONS="$QEMU_OPTIONS -machine accel=kvm -enable-kvm -cpu host"
+    [ -e "$IMAGE_PRIVATE" ] && image="$IMAGE_PRIVATE" || image="$IMAGE_PUBLIC"
+    qemu_options+=(
+        -smp "$QEMU_SMP"
+        -net none
+        -m "${QEMU_MEM:-768M}"
+        -nographic
+        -kernel "$KERNEL_BIN"
+        -drive "format=raw,cache=unsafe,file=$image"
+        -device "virtio-rng-pci,max-bytes=1024,period=1000"
+    )
+
+    if [[ -n "${QEMU_OPTIONS:=}" ]]; then
+        local user_qemu_options
+        read -ra user_qemu_options <<< "$QEMU_OPTIONS"
+        qemu_options+=("${user_qemu_options[@]}")
     fi
 
-    ( set -x
-      $QEMU_BIN $QEMU_OPTIONS -append "$KERNEL_APPEND" $TESTDIR/rootdisk.img ) || return 1
+    if [[ -n "${KERNEL_APPEND:=}" ]]; then
+        local user_kernel_append
+        readarray user_kernel_append <<< "$KERNEL_APPEND"
+        kernel_params+=("${user_kernel_append[@]}")
+    fi
+
+    if [[ "$INITRD" ]] && ! get_bool "$SKIP_INITRD"; then
+        qemu_options+=(-initrd "$INITRD")
+    fi
+
+    # Let's use KVM if possible
+    if [[ -c /dev/kvm ]] && get_bool $QEMU_KVM; then
+        qemu_options+=(-machine "accel=kvm" -enable-kvm -cpu host)
+    fi
+
+    if [[ "$QEMU_TIMEOUT" != "infinity" ]]; then
+        qemu_cmd=(timeout --foreground "$QEMU_TIMEOUT" "$QEMU_BIN")
+    fi
+
+    (set -x; "${qemu_cmd[@]}" "${qemu_options[@]}" -append "${kernel_params[*]}")
+    rc=$?
+    if [ "$rc" -eq 124 ] && [ "$QEMU_TIMEOUT" != "infinity" ]; then
+        derror "Test timed out after ${QEMU_TIMEOUT}s"
+        TIMED_OUT=1
+    else
+        [ "$rc" != 0 ] && derror "qemu failed with exit code $rc"
+    fi
+    return 0
 }
 
+# Return 0 if nspawn did run (then you must check the result state/logs for actual
+# success), or 1 if nspawn is not available.
 run_nspawn() {
-    set -x
-    ../../systemd-nspawn --register=no --directory=$TESTDIR/nspawn-root $ROOTLIBDIR/systemd $KERNEL_APPEND
+    [[ -d /run/systemd/system ]] || return 1
+    rm -f "${initdir:?}"/{testok,failed,skipped}
+
+    local nspawn_cmd=()
+    local nspawn_options=(
+        "--register=no"
+        "--kill-signal=SIGKILL"
+        "--directory=${1:?}"
+        "--setenv=SYSTEMD_UNIT_PATH=/usr/lib/systemd/tests/testdata/testsuite-$2.units:/usr/lib/systemd/tests/testdata/units:"
+        "--machine=TEST-$TESTID"
+    )
+    local kernel_params=(
+        "$PATH_TO_INIT"
+        "systemd.unit=testsuite.target"
+        "systemd.wants=testsuite-$2.service"
+    )
+
+    if ! get_bool "$INTERACTIVE_DEBUG"; then
+        kernel_params+=("systemd.wants=end.service")
+    fi
+
+    if [[ -n "${NSPAWN_ARGUMENTS:=}" ]]; then
+        local user_nspawn_arguments
+        read -ra user_nspawn_arguments <<< "$NSPAWN_ARGUMENTS"
+        nspawn_options+=("${user_nspawn_arguments[@]}")
+    fi
+
+    if [[ -n "${KERNEL_APPEND:=}" ]]; then
+        local user_kernel_append
+        readarray user_kernel_append <<< "$KERNEL_APPEND"
+        kernel_params+=("${user_kernel_append[@]}")
+    fi
+
+    if [[ "$UNIFIED_CGROUP_HIERARCHY" = "hybrid" ]]; then
+        dwarn "nspawn doesn't support SYSTEMD_NSPAWN_UNIFIED_HIERARCHY=hybrid, skipping"
+        exit
+    elif [[ "$UNIFIED_CGROUP_HIERARCHY" = "yes" || "$UNIFIED_CGROUP_HIERARCHY" = "no" ]]; then
+        nspawn_cmd+=(env "SYSTEMD_NSPAWN_UNIFIED_HIERARCHY=$UNIFIED_CGROUP_HIERARCHY")
+    elif [[ "$UNIFIED_CGROUP_HIERARCHY" = "default" ]]; then
+        nspawn_cmd+=(env "--unset=UNIFIED_CGROUP_HIERARCHY" "--unset=SYSTEMD_NSPAWN_UNIFIED_HIERARCHY")
+    else
+        dfatal "Unknown UNIFIED_CGROUP_HIERARCHY. Got $UNIFIED_CGROUP_HIERARCHY, expected [yes|no|hybrid|default]"
+        exit 1
+    fi
+
+    if [[ "$NSPAWN_TIMEOUT" != "infinity" ]]; then
+        nspawn_cmd+=(timeout --foreground "$NSPAWN_TIMEOUT" "$SYSTEMD_NSPAWN")
+    else
+        nspawn_cmd+=("$SYSTEMD_NSPAWN")
+    fi
+
+    (set -x; "${nspawn_cmd[@]}" "${nspawn_options[@]}" "${kernel_params[@]}")
+    rc=$?
+    if [ "$rc" -eq 124 ] && [ "$NSPAWN_TIMEOUT" != "infinity" ]; then
+        derror "Test timed out after ${NSPAWN_TIMEOUT}s"
+        TIMED_OUT=1
+    else
+        [ "$rc" != 0 ] && derror "nspawn failed with exit code $rc"
+    fi
+    return 0
+}
+
+# Build two very minimal root images, with two units, one is the same and one is different across them
+install_verity_minimal() {
+    dinfo "Set up a set of minimal images for verity verification"
+    if [ -e "$initdir/usr/share/minimal.raw" ]; then
+        return
+    fi
+    if ! command -v mksquashfs >/dev/null 2>&1; then
+        dfatal "mksquashfs not found"
+        exit 1
+    fi
+    if ! command -v veritysetup >/dev/null 2>&1; then
+        dfatal "veritysetup not found"
+        exit 1
+    fi
+    # Local modifications of some global variables is intentional in this
+    # subshell (SC2030)
+    # shellcheck disable=SC2030
+    (
+        BASICTOOLS=(
+            bash
+            cat
+            grep
+            mount
+            sleep
+        )
+        oldinitdir="$initdir"
+        rm -rfv "$TESTDIR/minimal"
+        export initdir="$TESTDIR/minimal"
+        # app0 will use TemporaryFileSystem=/var/lib, app1 will need the mount point in the base image
+        mkdir -p "$initdir/usr/lib/systemd/system" "$initdir/usr/lib/extension-release.d" "$initdir/etc" "$initdir/var/tmp" "$initdir/opt" "$initdir/var/lib/app1"
+        setup_basic_dirs
+        install_basic_tools
+        install_ld_so_conf
+        # Shellcheck treats [[ -v VAR ]] as an assignment to avoid a different
+        # issue, thus falsely triggering SC2030 in this case
+        # See: koalaman/shellcheck#1409
+        if [[ -v ASAN_RT_PATH ]]; then
+            # If we're compiled with ASan, install the ASan RT (and its dependencies)
+            # into the verity images to get rid of the annoying errors about
+            # missing $LD_PRELOAD libraries.
+            inst_libs "$ASAN_RT_PATH"
+            inst_library "$ASAN_RT_PATH"
+        fi
+        cp "$os_release" "$initdir/usr/lib/os-release"
+        ln -s ../usr/lib/os-release "$initdir/etc/os-release"
+        touch "$initdir/etc/machine-id" "$initdir/etc/resolv.conf"
+        touch "$initdir/opt/some_file"
+        echo MARKER=1 >>"$initdir/usr/lib/os-release"
+        echo "PORTABLE_PREFIXES=app0 minimal minimal-app0" >>"$initdir/usr/lib/os-release"
+        cat >"$initdir/usr/lib/systemd/system/minimal-app0.service" <<EOF
+[Service]
+ExecStartPre=cat /usr/lib/os-release
+ExecStart=sleep 120
+EOF
+        cp "$initdir/usr/lib/systemd/system/minimal-app0.service" "$initdir/usr/lib/systemd/system/minimal-app0-foo.service"
+
+        mksquashfs "$initdir" "$oldinitdir/usr/share/minimal_0.raw" -noappend
+        veritysetup format "$oldinitdir/usr/share/minimal_0.raw" "$oldinitdir/usr/share/minimal_0.verity" | \
+            grep '^Root hash:' | cut -f2 | tr -d '\n' >"$oldinitdir/usr/share/minimal_0.roothash"
+
+        sed -i "s/MARKER=1/MARKER=2/g" "$initdir/usr/lib/os-release"
+        rm "$initdir/usr/lib/systemd/system/minimal-app0-foo.service"
+        cp "$initdir/usr/lib/systemd/system/minimal-app0.service" "$initdir/usr/lib/systemd/system/minimal-app0-bar.service"
+
+        mksquashfs "$initdir" "$oldinitdir/usr/share/minimal_1.raw" -noappend
+        veritysetup format "$oldinitdir/usr/share/minimal_1.raw" "$oldinitdir/usr/share/minimal_1.verity" | \
+            grep '^Root hash:' | cut -f2 | tr -d '\n' >"$oldinitdir/usr/share/minimal_1.roothash"
+
+        # Rolling distros like Arch do not set VERSION_ID
+        local version_id=""
+        if grep -q "^VERSION_ID=" "$os_release"; then
+            version_id="$(grep "^VERSION_ID=" "$os_release")"
+        fi
+
+        export initdir="$TESTDIR/app0"
+        mkdir -p "$initdir/usr/lib/extension-release.d" "$initdir/usr/lib/systemd/system" "$initdir/opt"
+        grep "^ID=" "$os_release" >"$initdir/usr/lib/extension-release.d/extension-release.app0"
+        echo "${version_id}" >>"$initdir/usr/lib/extension-release.d/extension-release.app0"
+        cat >"$initdir/usr/lib/systemd/system/app0.service" <<EOF
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart=/opt/script0.sh
+TemporaryFileSystem=/var/lib
+StateDirectory=app0
+RuntimeDirectory=app0
+EOF
+        cat >"$initdir/opt/script0.sh" <<EOF
+#!/bin/bash
+set -e
+test -e /usr/lib/os-release
+echo bar > \${STATE_DIRECTORY}/foo
+cat /usr/lib/extension-release.d/extension-release.app0
+EOF
+        chmod +x "$initdir/opt/script0.sh"
+        echo MARKER=1 >"$initdir/usr/lib/systemd/system/some_file"
+        mksquashfs "$initdir" "$oldinitdir/usr/share/app0.raw" -noappend
+
+        export initdir="$TESTDIR/app1"
+        mkdir -p "$initdir/usr/lib/extension-release.d" "$initdir/usr/lib/systemd/system" "$initdir/opt"
+        grep "^ID=" "$os_release" >"$initdir/usr/lib/extension-release.d/extension-release.app2"
+        ( echo "${version_id}"
+          echo "SYSEXT_SCOPE=portable"
+          echo "PORTABLE_PREFIXES=app1" ) >>"$initdir/usr/lib/extension-release.d/extension-release.app2"
+        setfattr -n user.extension-release.strict -v false "$initdir/usr/lib/extension-release.d/extension-release.app2"
+        cat >"$initdir/usr/lib/systemd/system/app1.service" <<EOF
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart=/opt/script1.sh
+StateDirectory=app1
+RuntimeDirectory=app1
+EOF
+        cat >"$initdir/opt/script1.sh" <<EOF
+#!/bin/bash
+set -e
+test -e /usr/lib/os-release
+echo baz > \${STATE_DIRECTORY}/foo
+cat /usr/lib/extension-release.d/extension-release.app2
+EOF
+        chmod +x "$initdir/opt/script1.sh"
+        echo MARKER=1 >"$initdir/usr/lib/systemd/system/other_file"
+        mksquashfs "$initdir" "$oldinitdir/usr/share/app1.raw" -noappend
+
+        export initdir="$TESTDIR/app-nodistro"
+        mkdir -p "$initdir/usr/lib/extension-release.d" "$initdir/usr/lib/systemd/system"
+        ( echo "ID=_any"
+          echo "ARCHITECTURE=_any" ) >"$initdir/usr/lib/extension-release.d/extension-release.app-nodistro"
+        echo MARKER=1 >"$initdir/usr/lib/systemd/system/some_file"
+        mksquashfs "$initdir" "$oldinitdir/usr/share/app-nodistro.raw" -noappend
+    )
 }
 
 setup_basic_environment() {
@@ -102,66 +739,65 @@ setup_basic_environment() {
     install_systemd
     install_missing_libraries
     install_config_files
+    install_zoneinfo
     create_rc_local
     install_basic_tools
     install_libnss
     install_pam
     install_dbus
     install_fonts
+    install_locales
     install_keymaps
+    install_x11_keymaps
     install_terminfo
     install_execs
-    install_fsck
+    install_fs_tools
+    install_modules
     install_plymouth
+    install_haveged
     install_debug_tools
     install_ld_so_conf
+    install_testuser
+    has_user_dbus_socket && install_user_dbus
     setup_selinux
     strip_binaries
+    instmods veth
     install_depmod_files
     generate_module_dependencies
+    if get_bool "$IS_BUILT_WITH_ASAN"; then
+        create_asan_wrapper
+    fi
+    if get_bool "$TEST_INSTALL_VERITY_MINIMAL"; then
+        install_verity_minimal
+    fi
 }
 
 setup_selinux() {
+    dinfo "Setup SELinux"
     # don't forget KERNEL_APPEND='... selinux=1 ...'
-    if [[ "$SETUP_SELINUX" != "yes" ]]; then
-        ddebug "Don't setup SELinux"
+    if ! get_bool "$SETUP_SELINUX"; then
+        dinfo "SETUP_SELINUX != yes, skipping SELinux configuration"
         return 0
     fi
-    ddebug "Setup SELinux"
-    local _conf_dir=/etc/selinux
-    local _fixfiles_tools="bash uname cat sort uniq awk grep egrep head expr find rm secon setfiles"
 
-    rm -rf $initdir/$_conf_dir
-    if ! cp -ar $_conf_dir $initdir/$_conf_dir; then
-        dfatal "Failed to copy $_conf_dir"
+    local conf_dir=/etc/selinux
+    local fixfiles_tools=(bash uname cat sort uniq awk grep egrep head expr find rm secon setfiles)
+
+    # Make sure the following statement can't expand to "/" to prevent
+    # a potential where-are-my-backups situation
+    rm -rf "${initdir:?}/$conf_dir"
+    if ! cp -ar "$conf_dir" "$initdir/$conf_dir"; then
+        dfatal "Failed to copy $conf_dir"
         exit 1
     fi
 
-    cat <<EOF >$initdir/etc/systemd/system/autorelabel.service
-[Unit]
-Description=Relabel all filesystems
-DefaultDependencies=no
-Requires=local-fs.target
-Conflicts=shutdown.target
-After=local-fs.target
-Before=sysinit.target shutdown.target
-ConditionSecurity=selinux
-ConditionPathExists=|/.autorelabel
-
-[Service]
-ExecStart=/bin/sh -x -c 'echo 0 >/sys/fs/selinux/enforce && fixfiles -f -F relabel && rm /.autorelabel && systemctl --force reboot'
-Type=oneshot
-TimeoutSec=0
-RemainAfterExit=yes
-EOF
-
-    touch $initdir/.autorelabel
-    mkdir -p $initdir/etc/systemd/system/basic.target.wants
-    ln -fs autorelabel.service $initdir/etc/systemd/system/basic.target.wants/autorelabel.service
+    touch "$initdir/.autorelabel"
+    mkdir -p "$initdir/usr/lib/systemd/tests/testdata/units/basic.target.wants"
+    ln -sf ../autorelabel.service "$initdir/usr/lib/systemd/tests/testdata/units/basic.target.wants/"
 
-    dracut_install $_fixfiles_tools
-    dracut_install fixfiles
-    dracut_install sestatus
+    image_install "${fixfiles_tools[@]}"
+    image_install fixfiles
+    image_install sestatus
 }
 
 install_valgrind() {
@@ -170,275 +806,1347 @@ install_valgrind() {
         exit 1
     fi
 
-    local _valgrind_bins=$(strace -e execve valgrind /bin/true 2>&1 >/dev/null | perl -lne 'print $1 if /^execve\("([^"]+)"/')
-    dracut_install $_valgrind_bins
+    local valgrind_bins valgrind_libs valgrind_dbg_and_supp
+
+    readarray -t valgrind_bins < <(strace -e execve valgrind /bin/true 2>&1 >/dev/null | perl -lne 'print $1 if /^execve\("([^"]+)"/')
+    image_install "${valgrind_bins[@]}"
 
-    local _valgrind_libs=$(LD_DEBUG=files valgrind /bin/true 2>&1 >/dev/null | perl -lne 'print $1 if m{calling init: (/.*vgpreload_.*)}')
-    dracut_install $_valgrind_libs
+    readarray -t valgrind_libs < <(LD_DEBUG=files valgrind /bin/true 2>&1 >/dev/null | perl -lne 'print $1 if m{calling init: (/.*vgpreload_.*)}')
+    image_install "${valgrind_libs[@]}"
 
-    local _valgrind_dbg_and_supp=$(
+    readarray -t valgrind_dbg_and_supp < <(
         strace -e open valgrind /bin/true 2>&1 >/dev/null |
         perl -lne 'if (my ($fname) = /^open\("([^"]+).*= (?!-)\d+/) { print $fname if $fname =~ /debug|\.supp$/ }'
     )
-    dracut_install $_valgrind_dbg_and_supp
+    image_install "${valgrind_dbg_and_supp[@]}"
 }
 
 create_valgrind_wrapper() {
-    local _valgrind_wrapper=$initdir/$ROOTLIBDIR/systemd-under-valgrind
-    ddebug "Create $_valgrind_wrapper"
-    cat >$_valgrind_wrapper <<EOF
-#!/bin/bash
+    local valgrind_wrapper="$initdir/$ROOTLIBDIR/systemd-under-valgrind"
+    ddebug "Create $valgrind_wrapper"
+    cat >"$valgrind_wrapper" <<EOF
+#!/usr/bin/env bash
 
-exec valgrind --leak-check=full --log-file=/valgrind.out $ROOTLIBDIR/systemd "\$@"
+mount -t proc proc /proc
+exec valgrind --leak-check=full --track-fds=yes --log-file=/valgrind.out $ROOTLIBDIR/systemd "\$@"
 EOF
-    chmod 0755 $_valgrind_wrapper
+    chmod 0755 "$valgrind_wrapper"
 }
 
-install_fsck() {
-    dracut_install /sbin/fsck*
-    dracut_install -o /bin/fsck*
+create_asan_wrapper() {
+    local asan_wrapper="$initdir/$ROOTLIBDIR/systemd-under-asan"
+    dinfo "Create ASan wrapper as '$asan_wrapper'"
+
+    [[ -z "$ASAN_RT_PATH" ]] && dfatal "ASAN_RT_PATH is empty, but it shouldn't be"
+
+    # clang: install llvm-symbolizer to generate useful reports
+    # See: https://clang.llvm.org/docs/AddressSanitizer.html#symbolizing-the-reports
+    [[ "$ASAN_COMPILER" == "clang" ]] && image_install "llvm-symbolizer"
+
+    cat >"$asan_wrapper" <<EOF
+#!/usr/bin/env bash
+
+set -x
+
+echo "ASan RT: $ASAN_RT_PATH"
+if [[ ! -e "$ASAN_RT_PATH" ]]; then
+    echo >&2 "Couldn't find ASan RT at '$ASAN_RT_PATH', can't continue"
+    exit 1
+fi
+
+DEFAULT_ASAN_OPTIONS=${ASAN_OPTIONS:-strict_string_checks=1:detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1}
+DEFAULT_UBSAN_OPTIONS=${UBSAN_OPTIONS:-print_stacktrace=1:print_summary=1:halt_on_error=1}
+DEFAULT_ENVIRONMENT="ASAN_OPTIONS=\$DEFAULT_ASAN_OPTIONS UBSAN_OPTIONS=\$DEFAULT_UBSAN_OPTIONS"
+
+# Create a simple environment file which can be included by systemd services
+# that need it (i.e. services that utilize DynamicUser=true and bash, etc.)
+cat >/usr/lib/systemd/systemd-asan-env <<INNER_EOF
+LD_PRELOAD=$ASAN_RT_PATH
+ASAN_OPTIONS=$DEFAULT_ASAN_OPTIONS
+LSAN_OPTIONS=detect_leaks=0
+UBSAN_OPTIONS=$DEFAULT_UBSAN_OPTIONS
+INNER_EOF
+
+# As right now bash is the PID 1, we can't expect PATH to have a sane value.
+# Let's make one to prevent unexpected "<bin> not found" issues in the future
+export PATH="/sbin:/bin:/usr/sbin:/usr/bin"
+
+mount -t proc proc /proc
+mount -t sysfs sysfs /sys
+mount -o remount,rw /
+
+DEFAULT_ENVIRONMENT="\$DEFAULT_ENVIRONMENT ASAN_RT_PATH=$ASAN_RT_PATH"
+
+if [[ "$ASAN_COMPILER" == "clang" ]]; then
+  # Let's add the ASan DSO's path to the dynamic linker's cache. This is pretty
+  # unnecessary for gcc & libasan, however, for clang this is crucial, as its
+  # runtime ASan DSO is in a non-standard (library) path.
+  echo "${ASAN_RT_PATH%/*}" >/etc/ld.so.conf.d/asan-path-override.conf
+  ldconfig
+fi
+echo DefaultEnvironment=\$DEFAULT_ENVIRONMENT >>/etc/systemd/system.conf
+echo DefaultTimeoutStartSec=180s >>/etc/systemd/system.conf
+echo DefaultStandardOutput=journal+console >>/etc/systemd/system.conf
+
+# ASAN and syscall filters aren't compatible with each other.
+find / -name '*.service' -type f | xargs sed -i 's/^\\(MemoryDeny\\|SystemCall\\)/#\\1/'
+
+# The redirection of ASAN reports to a file prevents them from ending up in /dev/null.
+# But, apparently, sometimes it doesn't work: https://github.com/google/sanitizers/issues/886.
+JOURNALD_CONF_DIR=/etc/systemd/system/systemd-journald.service.d
+mkdir -p "\$JOURNALD_CONF_DIR"
+printf "[Service]\nEnvironment=ASAN_OPTIONS=\$DEFAULT_ASAN_OPTIONS:log_path=/systemd-journald.asan.log UBSAN_OPTIONS=\$DEFAULT_UBSAN_OPTIONS:log_path=/systemd-journald.ubsan.log\n" >"\$JOURNALD_CONF_DIR/env.conf"
+
+# Sometimes UBSan sends its reports to stderr regardless of what is specified in log_path
+# Let's try to catch them by redirecting stderr (and stdout just in case) to a file
+# See https://github.com/systemd/systemd/pull/12524#issuecomment-491108821
+printf "[Service]\nStandardOutput=file:/systemd-journald.out\n" >"\$JOURNALD_CONF_DIR/out.conf"
+
+# 90s isn't enough for some services to finish when literally everything is run
+# under ASan+UBSan in containers, which, in turn, are run in VMs.
+# Let's limit which environments such services should be executed in.
+mkdir -p /etc/systemd/system/systemd-hwdb-update.service.d
+printf "[Unit]\nConditionVirtualization=container\n\n[Service]\nTimeoutSec=240s\n" >/etc/systemd/system/systemd-hwdb-update.service.d/env-override.conf
+
+# Let's override another hard-coded timeout that kicks in too early
+mkdir -p /etc/systemd/system/systemd-journal-flush.service.d
+printf "[Service]\nTimeoutSec=180s\n" >/etc/systemd/system/systemd-journal-flush.service.d/timeout.conf
+
+export ASAN_OPTIONS=\$DEFAULT_ASAN_OPTIONS:log_path=/systemd.asan.log UBSAN_OPTIONS=\$DEFAULT_UBSAN_OPTIONS
+exec "$ROOTLIBDIR/systemd" "\$@"
+EOF
+
+    chmod 0755 "$asan_wrapper"
+}
+
+create_strace_wrapper() {
+    local strace_wrapper="$initdir/$ROOTLIBDIR/systemd-under-strace"
+    ddebug "Create $strace_wrapper"
+    cat >"$strace_wrapper" <<EOF
+#!/usr/bin/env bash
+
+exec strace -f -D -o /strace.out "$ROOTLIBDIR/systemd" "\$@"
+EOF
+    chmod 0755 "$strace_wrapper"
+}
+
+install_fs_tools() {
+    dinfo "Install fsck"
+    image_install /sbin/fsck*
+    image_install -o /bin/fsck*
+
+    # fskc.reiserfs calls reiserfsck. so, install it
+    image_install -o reiserfsck
+
+    # we use mkfs in system-repart tests
+    image_install /sbin/mkfs.ext4
+    image_install /sbin/mkfs.vfat
+}
+
+install_modules() {
+    dinfo "Install modules"
+
+    instmods loop
+    instmods vfat
+    instmods nls_ascii =nls
+    instmods dummy
+    # for TEST-35-LOGIN
+    instmods scsi_debug uinput
+
+    if get_bool "$LOOKS_LIKE_SUSE"; then
+        instmods ext4
+    fi
 }
 
 install_dmevent() {
     instmods dm_crypt =crypto
-    type -P dmeventd >/dev/null && dracut_install dmeventd
-    inst_libdir_file "libdevmapper-event.so*"
-    if [[ "$LOOKS_LIKE_DEBIAN" ]]; then
+    inst_binary dmeventd
+    image_install "${ROOTLIBDIR:?}"/system/dm-event.{service,socket}
+    if get_bool "$LOOKS_LIKE_DEBIAN"; then
         # dmsetup installs 55-dm and 60-persistent-storage-dm on Debian/Ubuntu
-        # see https://anonscm.debian.org/cgit/pkg-lvm/lvm2.git/tree/debian/patches/0007-udev.patch
-        inst_rules 55-dm.rules 60-persistent-storage-dm.rules
+        # and since buster/bionic 95-dm-notify.rules
+        # see https://gitlab.com/debian-lvm/lvm2/blob/master/debian/patches/udev.patch
+        inst_rules 55-dm.rules 60-persistent-storage-dm.rules 95-dm-notify.rules
     else
         inst_rules 10-dm.rules 13-dm-disk.rules 95-dm-notify.rules
     fi
+    if get_bool "$LOOKS_LIKE_SUSE"; then
+        inst_rules 60-persistent-storage.rules 61-persistent-storage-compat.rules 99-systemd.rules
+    fi
+}
+
+install_multipath() {
+    instmods "=md" multipath
+    image_install kpartx /lib/udev/kpartx_id lsmod mpathpersist multipath multipathd partx
+    image_install "${ROOTLIBDIR:?}"/system/multipathd.{service,socket}
+    if get_bool "$LOOKS_LIKE_DEBIAN"; then
+        inst_rules 56-dm-parts.rules 56-dm-mpath.rules 60-multipath.rules 68-del-part-nodes.rules 95-kpartx.rules
+    else
+        inst_rules 11-dm-mpath.rules 11-dm-parts.rules 62-multipath.rules 66-kpartx.rules 68-del-part-nodes.rules
+    fi
+    mkdir -p "${initdir:?}/etc/multipath"
+
+    local file
+    while read -r file; do
+        # Install libraries required by the given library
+        inst_libs "$file"
+        # Install the library itself and create necessary symlinks
+        inst_library "$file"
+    done < <(find /lib*/multipath -type f)
+}
+
+install_lvm() {
+    image_install lvm
+    image_install "${ROOTLIBDIR:?}"/system/lvm2-lvmpolld.{service,socket}
+    image_install "${ROOTLIBDIR:?}"/system/{blk-availability,lvm2-monitor}.service
+    image_install -o "/lib/tmpfiles.d/lvm2.conf"
+    if get_bool "$LOOKS_LIKE_DEBIAN"; then
+        inst_rules 56-lvm.rules 69-lvm-metad.rules
+    else
+        # Support the new udev autoactivation introduced in lvm 2.03.14
+        # https://sourceware.org/git/?p=lvm2.git;a=commit;h=67722b312390cdab29c076c912e14bd739c5c0f6
+        # Static autoactivation (via lvm2-activation-generator) was dropped
+        # in lvm 2.03.15
+        # https://sourceware.org/git/?p=lvm2.git;a=commit;h=ee8fb0310c53ed003a43b324c99cdfd891dd1a7c
+        if [[ -f /lib/udev/rules.d/69-dm-lvm.rules ]]; then
+            inst_rules 11-dm-lvm.rules 69-dm-lvm.rules
+        else
+            image_install "${ROOTLIBDIR:?}"/system-generators/lvm2-activation-generator
+            image_install "${ROOTLIBDIR:?}"/system/lvm2-pvscan@.service
+            inst_rules 11-dm-lvm.rules 69-dm-lvm-metad.rules
+        fi
+    fi
+    mkdir -p "${initdir:?}/etc/lvm"
+}
+
+install_btrfs() {
+    instmods btrfs
+    # Not all utilities provided by btrfs-progs are listed here; extend the list
+    # if necessary
+    image_install btrfs btrfstune mkfs.btrfs
+    inst_rules 64-btrfs-dm.rules
+}
+
+install_iscsi() {
+    # Install both client and server side stuff by default
+    local inst="${1:-}"
+    local file
+
+    # Install client-side stuff ("initiator" in iSCSI jargon) - Open-iSCSI in this case
+    # (open-iscsi on Debian, iscsi-initiator-utils on Fedora, etc.)
+    if [[ -z "$inst" || "$inst" =~ (client|initiator) ]]; then
+        image_install iscsi-iname iscsiadm iscsid iscsistart
+        image_install -o "${ROOTLIBDIR:?}"/system/iscsi-{init,onboot,shutdown}.service
+        image_install "${ROOTLIBDIR:?}"/system/iscsid.{service,socket}
+        image_install "${ROOTLIBDIR:?}"/system/iscsi.service
+        mkdir -p "${initdir:?}"/var/lib/iscsi/{ifaces,isns,nodes,send_targets,slp,static}
+        mkdir -p "${initdir:?}/etc/iscsi"
+        echo "iscsid.startup = /bin/systemctl start iscsid.socket" >"${initdir:?}/etc/iscsi/iscsid.conf"
+        # Since open-iscsi 2.1.2 [0] the initiator name should be generated via
+        # a one-time service instead of distro package's post-install scripts.
+        # However, some distros still use this approach even after this patch,
+        # so prefer the already existing initiatorname.iscsi file if it exists.
+        #
+        # [0] https://github.com/open-iscsi/open-iscsi/commit/f37d5b653f9f251845db3f29b1a3dcb90ec89731
+        if [[ ! -e /etc/iscsi/initiatorname.iscsi ]]; then
+            image_install "${ROOTLIBDIR:?}"/system/iscsi-init.service
+            if get_bool "$IS_BUILT_WITH_ASAN"; then
+                # The iscsi-init.service calls `sh` which might, in certain circumstances,
+                # pull in instrumented systemd NSS modules causing `sh` to fail. Let's mitigate
+                # this by pulling in an env file crafted by `create_asan_wrapper()` that
+                # (among others) pre-loads ASan's DSO.
+                mkdir -p "${initdir:?}/etc/systemd/system/iscsi-init.service.d/"
+                printf "[Service]\nEnvironmentFile=/usr/lib/systemd/systemd-asan-env" >"${initdir:?}/etc/systemd/system/iscsi-init.service.d/asan-env.conf"
+            fi
+        else
+            inst_simple "/etc/iscsi/initiatorname.iscsi"
+        fi
+    fi
+
+    # Install server-side stuff ("target" in iSCSI jargon) - TGT in this case
+    # (tgt on Debian, scsi-target-utils on Fedora, etc.)
+    if [[ -z "$inst" || "$inst" =~ (server|target) ]]; then
+        image_install tgt-admin tgt-setup-lun tgtadm tgtd tgtimg
+        image_install -o /etc/sysconfig/tgtd
+        image_install "${ROOTLIBDIR:?}"/system/tgtd.service
+        mkdir -p "${initdir:?}/etc/tgt"
+        touch "${initdir:?}"/etc/tgt/{tgtd,targets}.conf
+        # Install perl modules required by tgt-admin
+        #
+        # Forgive me father for I have sinned. The monstrosity below appends
+        # a perl snippet to the `tgt-admin` perl script on the fly, which
+        # dumps a list of files (perl modules) required by `tgt-admin` at
+        # the runtime plus any DSOs loaded via DynaLoader. This list is then
+        # passed to `inst_simple` which installs the necessary files into the image
+        #
+        # shellcheck disable=SC2016
+        while read -r file; do
+            inst_simple "$file"
+        done < <(perl -- <(cat "$(command -v tgt-admin)" <(echo -e 'use DynaLoader; print map { "$_\n" } values %INC; print join("\n", @DynaLoader::dl_shared_objects)')) -p | awk '/^\// { print $1 }')
+    fi
+}
+
+install_mdadm() {
+    local unit
+    local mdadm_units=(
+        system/mdadm-grow-continue@.service
+        system/mdadm-last-resort@.service
+        system/mdadm-last-resort@.timer
+        system/mdmon@.service
+        system/mdmonitor-oneshot.service
+        system/mdmonitor-oneshot.timer
+        system/mdmonitor.service
+        system-shutdown/mdadm.shutdown
+    )
+
+    image_install mdadm mdmon
+    inst_rules 01-md-raid-creating.rules 63-md-raid-arrays.rules 64-md-raid-assembly.rules 69-md-clustered-confirm-device.rules
+    # Fedora/CentOS/RHEL ships this rule file
+    [[ -f /lib/udev/rules.d/65-md-incremental.rules ]] && inst_rules 65-md-incremental.rules
+
+    for unit in "${mdadm_units[@]}"; do
+        image_install "${ROOTLIBDIR:?}/$unit"
+    done
+}
+
+install_compiled_systemd() {
+    dinfo "Install compiled systemd"
+
+    local ninja_bin
+    ninja_bin="$(type -P ninja || type -P ninja-build)"
+    if [[ -z "$ninja_bin" ]]; then
+        dfatal "ninja was not found"
+        exit 1
+    fi
+    (set -x; DESTDIR="$initdir" "$ninja_bin" -C "$BUILD_DIR" install)
+
+    # If we are doing coverage runs, copy over the binary notes files, as lcov expects to
+    # find them in the same directory as the runtime data counts
+    if get_bool "$IS_BUILT_WITH_COVERAGE"; then
+        mkdir -p "${initdir}/${BUILD_DIR:?}/"
+        rsync -am --include='*/' --include='*.gcno' --exclude='*' "${BUILD_DIR:?}/" "${initdir}/${BUILD_DIR:?}/"
+        # Set effective & default ACLs for the build dir so unprivileged
+        # processes can write gcda files with coverage stats
+        setfacl -R -m 'd:o:rwX' -m 'o:rwX' "${initdir}/${BUILD_DIR:?}/"
+    fi
+}
+
+install_debian_systemd() {
+    dinfo "Install debian systemd"
+
+    local files
+
+    while read -r deb; do
+        files="$(dpkg-query -L "$deb" 2>/dev/null)" || continue
+        ddebug "Install debian files from package $deb"
+        for file in $files; do
+            [ -e "$file" ] || continue
+            [ -d "$file" ] && continue
+            inst "$file"
+        done
+    done < <(grep -E '^Package:' "${SOURCE_DIR}/debian/control" | cut -d ':' -f 2)
+}
+
+install_suse_systemd() {
+    local testsdir=/usr/lib/systemd/tests
+    local pkgs
+
+    dinfo "Install SUSE systemd"
+
+    pkgs=(
+        systemd
+        systemd-container
+        systemd-coredump
+        systemd-experimental
+        systemd-journal-remote
+        systemd-portable
+        udev
+    )
+
+    for p in "${pkgs[@]}"; do
+        rpm -q "$p" &>/dev/null || continue
+
+        ddebug "Install files from package $p"
+        while read -r f; do
+            [ -e "$f" ] || continue
+            [ -d "$f" ] && continue
+            inst "$f"
+        done < <(rpm -ql "$p")
+    done
+
+    # we only need testsdata dir as well as the unit tests (for
+    # TEST-02-UNITTESTS) in the image.
+    dinfo "Install unit tests and testdata directory"
+
+    mkdir -p "$initdir/$testsdir"
+    cp "$testsdir"/test-* "$initdir/$testsdir/"
+    cp -a "$testsdir/testdata" "$initdir/$testsdir/"
+
+    # On openSUSE, these dirs are not created at package install for now on.
+    mkdir -p "$initdir/var/log/journal/remote"
+}
+
+install_distro_systemd() {
+    dinfo "Install distro systemd"
+
+    if get_bool "$LOOKS_LIKE_DEBIAN"; then
+        install_debian_systemd
+    elif get_bool "$LOOKS_LIKE_SUSE"; then
+        install_suse_systemd
+    else
+        dfatal "NO_BUILD not supported for this distro"
+        exit 1
+    fi
 }
 
 install_systemd() {
-    # install compiled files
-    (cd $TEST_BASE_DIR/..; set -x; make DESTDIR=$initdir install)
+    dinfo "Install systemd"
+    if get_bool "$NO_BUILD"; then
+        install_distro_systemd
+    else
+        install_compiled_systemd
+    fi
+
     # remove unneeded documentation
-    rm -fr $initdir/usr/share/{man,doc}
-    # we strip binaries since debug symbols increase binaries size a lot
-    # and it could fill the available space
-    strip_binaries
+    rm -fr "${initdir:?}"/usr/share/{man,doc}
+
+    # enable debug logging in PID1
+    echo LogLevel=debug >>"$initdir/etc/systemd/system.conf"
+    if [[ -n "$TEST_SYSTEMD_LOG_LEVEL" ]]; then
+        echo DefaultEnvironment=SYSTEMD_LOG_LEVEL="$TEST_SYSTEMD_LOG_LEVEL" >>"$initdir/etc/systemd/system.conf"
+    fi
+    # store coredumps in journal
+    echo Storage=journal >>"$initdir/etc/systemd/coredump.conf"
+    # Propagate SYSTEMD_UNIT_PATH to user systemd managers
+    mkdir "$initdir/etc/systemd/system/user@.service.d/"
+    echo -e "[Service]\nPassEnvironment=SYSTEMD_UNIT_PATH\n" >"$initdir/etc/systemd/system/user@.service.d/override.conf"
+
+    # When built with gcov, disable ProtectSystem= and ProtectHome= in the test
+    # images, since it prevents gcov to write the coverage reports (*.gcda
+    # files)
+    if get_bool "$IS_BUILT_WITH_COVERAGE"; then
+        mkdir -p "$initdir/etc/systemd/system/service.d/"
+        echo -e "[Service]\nProtectSystem=no\nProtectHome=no\n" >"$initdir/etc/systemd/system/service.d/99-gcov-override.conf"
+        # Similarly, set ReadWritePaths= to the $BUILD_DIR in the test image
+        # to make the coverage work with units utilizing DynamicUser=yes. Do
+        # this only for services from TEST-20, as setting this system-wide
+        # has many undesirable side-effects
+        mkdir -p "$initdir/etc/systemd/system/test20-.service.d/"
+        echo -e "[Service]\nReadWritePaths=${BUILD_DIR:?}\n" >"$initdir/etc/systemd/system/test20-.service.d/99-gcov-rwpaths-override.conf"
+    fi
+
+    # If we're built with -Dportabled=false, tests with systemd-analyze
+    # --profile will fail. Since we need just the profile (text) files, let's
+    # copy them into the image if they don't exist there.
+    local portable_dir="${initdir:?}${ROOTLIBDIR:?}/portable"
+    if [[ ! -d "$portable_dir/profile/strict" ]]; then
+        dinfo "Couldn't find portable profiles in the test image"
+        dinfo "Copying them directly from the source tree"
+        mkdir -p "$portable_dir"
+        cp -frv "${SOURCE_DIR:?}/src/portable/profile" "$portable_dir"
+    fi
+}
+
+get_ldpath() {
+    local rpath
+    rpath="$(objdump -p "${1:?}" 2>/dev/null | awk "/R(UN)?PATH/ { print \"$initdir\" \$2 }" | paste -sd :)"
+
+    if [ -z "$rpath" ] ; then
+        echo "$BUILD_DIR"
+    else
+        echo "$rpath"
+    fi
 }
 
 install_missing_libraries() {
+    dinfo "Install missing libraries"
     # install possible missing libraries
-    for i in $initdir/{sbin,bin}/* $initdir/lib/systemd/*; do
-        inst_libs $i
+    for i in "${initdir:?}"{,/usr}/{sbin,bin}/* "$initdir"{,/usr}/lib/systemd/{,tests/{,manual/,unsafe/}}*; do
+        LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$(get_ldpath "$i")" inst_libs "$i"
     done
+
+    # Install libgcc_s.so if available, since it's dlopen()ed by libpthread
+    # and might cause unexpected failures during pthread_exit()/pthread_cancel()
+    # if not present
+    # See: https://github.com/systemd/systemd/pull/23858
+    while read -r libgcc_s; do
+        [[ -e "$libgcc_s" ]] && inst_library "$libgcc_s"
+    done < <(ldconfig -p | awk '/\/libgcc_s.so.1$/ { print $4 }')
+
+    local lib path
+    # A number of dependencies is now optional via dlopen, so the install
+    # script will not pick them up, since it looks at linkage.
+    for lib in libcryptsetup libidn libidn2 pwquality libqrencode tss2-esys tss2-rc tss2-mu tss2-tcti-device libfido2 libbpf libelf libdw xkbcommon; do
+        ddebug "Searching for $lib via pkg-config"
+        if pkg-config --exists "$lib"; then
+                path="$(pkg-config --variable=libdir "$lib")"
+                if [ -z "${path}" ]; then
+                    ddebug "$lib.pc does not contain a libdir variable, skipping"
+                    continue
+                fi
+
+                if ! [[ ${lib} =~ ^lib ]]; then
+                        lib="lib${lib}"
+                fi
+                # Some pkg-config files are broken and give out the wrong paths
+                # (eg: libcryptsetup), so just ignore them
+                inst_libs "${path}/${lib}.so" || true
+                inst_library "${path}/${lib}.so" || true
+
+                if [[ "$lib" == "libxkbcommon" ]]; then
+                    install_x11_keymaps full
+                fi
+        else
+            ddebug "$lib.pc not found, skipping"
+            continue
+        fi
+    done
+
+    # Install extra openssl 3 stuff
+    path="$(pkg-config --variable=libdir libcrypto)"
+    inst_simple "${path}/ossl-modules/legacy.so" || true
+    inst_simple "${path}/ossl-modules/fips.so" || true
+    inst_simple "${path}/engines-3/afalg.so" || true
+    inst_simple "${path}/engines-3/capi.so" || true
+    inst_simple "${path}/engines-3/loader_attic.so" || true
+    inst_simple "${path}/engines-3/padlock.so" || true
+}
+
+cleanup_loopdev() {
+    if [ -n "${LOOPDEV:=}" ]; then
+        ddebug "losetup -d $LOOPDEV"
+        losetup -d "${LOOPDEV}"
+        unset LOOPDEV
+    fi
 }
 
+add_at_exit_handler cleanup_loopdev
+
 create_empty_image() {
-    rm -f "$TESTDIR/rootdisk.img"
+    if [ -z "${IMAGE_NAME:=}" ]; then
+        echo "create_empty_image: \$IMAGE_NAME not set"
+        exit 1
+    fi
+
+    # Partition sizes are in MiBs
+    local root_size=1000
+    local data_size=50
+    if ! get_bool "$NO_BUILD"; then
+        if meson configure "${BUILD_DIR:?}" | grep 'static-lib\|standalone-binaries' | awk '{ print $2 }' | grep -q 'true'; then
+            root_size=$((root_size+=200))
+        fi
+        if meson configure "${BUILD_DIR:?}" | grep 'link-.*-shared' | awk '{ print $2 }' | grep -q 'false'; then
+            root_size=$((root_size+=200))
+        fi
+        if get_bool "$IS_BUILT_WITH_COVERAGE"; then
+            root_size=$((root_size+=250))
+        fi
+    fi
+    if ! get_bool "$STRIP_BINARIES"; then
+        root_size=$((4 * root_size))
+        data_size=$((2 * data_size))
+    fi
+
+    echo "Setting up ${IMAGE_PUBLIC:?} (${root_size} MB)"
+    rm -f "${IMAGE_PRIVATE:?}" "$IMAGE_PUBLIC"
+
     # Create the blank file to use as a root filesystem
-    dd if=/dev/null of="$TESTDIR/rootdisk.img" bs=1M seek=400
-    LOOPDEV=$(losetup --show -P -f $TESTDIR/rootdisk.img)
+    truncate -s "${root_size}M" "$IMAGE_PUBLIC"
+
+    LOOPDEV=$(losetup --show -P -f "$IMAGE_PUBLIC")
     [ -b "$LOOPDEV" ] || return 1
-    echo "LOOPDEV=$LOOPDEV" >> $STATEFILE
+    # Create two partitions - a root one and a data one (utilized by some tests)
     sfdisk "$LOOPDEV" <<EOF
-,390M
-,
+label: gpt
+type=0FC63DAF-8483-4772-8E79-3D69D8477DE4 name=root size=$((root_size - data_size))M bootable
+type=0FC63DAF-8483-4772-8E79-3D69D8477DE4 name=data
 EOF
 
-    mkfs.ext3 -L systemd "${LOOPDEV}p1"
+    udevadm settle
+
+    local label=(-L systemd_boot)
+    # mkfs.reiserfs doesn't know -L. so, use --label instead
+    [[ "$FSTYPE" == "reiserfs" ]] && label=(--label systemd_boot)
+    if ! mkfs -t "${FSTYPE}" "${label[@]}" "${LOOPDEV}p1" -q; then
+        dfatal "Failed to mkfs -t ${FSTYPE}"
+        exit 1
+    fi
+}
+
+mount_initdir() {
+    if [ -z "${LOOPDEV:=}" ]; then
+        [ -e "${IMAGE_PRIVATE:?}" ] && image="$IMAGE_PRIVATE" || image="${IMAGE_PUBLIC:?}"
+        LOOPDEV="$(losetup --show -P -f "$image")"
+        [ -b "$LOOPDEV" ] || return 1
+
+        udevadm settle
+    fi
+
+    if ! mountpoint -q "${initdir:?}"; then
+        mkdir -p "$initdir"
+        mount "${LOOPDEV}p1" "$initdir"
+        TEST_SETUP_CLEANUP_ROOTDIR=1
+    fi
+}
+
+cleanup_initdir() {
+    # only umount if create_empty_image_rootdir() was called to mount it
+    if get_bool "$TEST_SETUP_CLEANUP_ROOTDIR"; then
+        _umount_dir "${initdir:?}"
+    fi
+}
+
+umount_loopback() {
+    # unmount the loopback device from all places. Otherwise we risk file
+    # system corruption.
+    for device in $(losetup -l | awk '$6=="'"${IMAGE_PUBLIC:?}"'" {print $1}'); do
+        ddebug "Unmounting all uses of $device"
+        mount | awk '/^'"${device}"'p/{print $1}' | xargs --no-run-if-empty umount -v
+    done
+}
+
+create_empty_image_rootdir() {
+    create_empty_image
+    mount_initdir
+}
+
+check_asan_reports() {
+    local ret=0
+    local root="${1:?}"
+
+    if get_bool "$IS_BUILT_WITH_ASAN"; then
+        ls -l "$root"
+        if [[ -e "$root/systemd.asan.log.1" ]]; then
+            cat "$root/systemd.asan.log.1"
+            ret=$((ret+1))
+        fi
+
+        journald_report="$(find "$root" -name "systemd-journald.*san.log*" -exec cat {} \;)"
+        if [[ -n "$journald_report" ]]; then
+            printf "%s\n" "$journald_report"
+            cat "$root/systemd-journald.out" || :
+            ret=$((ret+1))
+        fi
+
+        pids="$(
+            "$JOURNALCTL" -D "$root/var/log/journal" | perl -alne '
+                 BEGIN {
+                     %services_to_ignore = (
+                         "dbus-daemon" => undef,
+                         "dbus-broker-launch" => undef,
+                     );
+                 }
+                 print $2 if /\s(\S*)\[(\d+)\]:\s*SUMMARY:\s+\w+Sanitizer/ && !exists $services_to_ignore{$1}'
+        )"
+        if [[ -n "$pids" ]]; then
+            ret=$((ret+1))
+            for pid in $pids; do
+                "$JOURNALCTL" -D "$root/var/log/journal" _PID="$pid" --no-pager
+            done
+        fi
+    fi
+
+    return $ret
+}
+
+check_coverage_reports() {
+    local root="${1:?}"
+
+    if get_bool "$NO_BUILD"; then
+        return 0
+    fi
+    if ! get_bool "$IS_BUILT_WITH_COVERAGE"; then
+        return 0
+    fi
+
+    if [ -n "${ARTIFACT_DIRECTORY}" ]; then
+        dest="${ARTIFACT_DIRECTORY}/${testname:?}.coverage-info"
+    else
+        dest="${TESTDIR:?}/coverage-info"
+    fi
+
+    # Create a coverage report that will later be uploaded. Remove info about
+    # system libraries/headers, as we don't really care about them.
+    if [[ -f "$dest" ]]; then
+        # If the destination report file already exists, don't overwrite it, but
+        # dump the new report in a temporary file and then merge it with the already
+        # present one - this usually happens when running both "parts" of a test
+        # in one run (the qemu and the nspawn part).
+        lcov --directory "${root}/${BUILD_DIR:?}" --capture --output-file "${dest}.new"
+        lcov --remove "${dest}.new" -o "${dest}.new" '/usr/include/*' '/usr/lib/*'
+        lcov --add-tracefile "${dest}" --add-tracefile "${dest}.new" -o "${dest}"
+        rm -f "${dest}.new"
+    else
+        lcov --directory "${root}/${BUILD_DIR:?}" --capture --output-file "${dest}"
+        lcov --remove "${dest}" -o "${dest}" '/usr/include/*' '/usr/lib/*'
+    fi
+
+    # If the test logs contain lines like:
+    #
+    # ...systemd-resolved[735885]: profiling:/systemd-meson-build/src/shared/libsystemd-shared-250.a.p/base-filesystem.c.gcda:Cannot open
+    #
+    # it means we're possibly missing some coverage since gcov can't write the stats,
+    # usually due to the sandbox being too restrictive (e.g. ProtectSystem=yes,
+    # ProtectHome=yes) or the $BUILD_DIR being inaccessible to non-root users - see
+    # `setfacl` stuff in install_compiled_systemd().
+    if ! get_bool "${IGNORE_MISSING_COVERAGE:=}" && \
+       "${JOURNALCTL:?}" -q --no-pager -D "${root:?}/var/log/journal" --grep "profiling:.+?gcda:[Cc]annot open"; then
+        derror "Detected possibly missing coverage, check the journal"
+        return 1
+    fi
+
+    return 0
+}
+
+save_journal() {
+    # Default to always saving journal
+    local save="yes"
+
+    if [ "${TEST_SAVE_JOURNAL}" = "no" ]; then
+        save="no"
+    elif [ "${TEST_SAVE_JOURNAL}" = "fail" ] && [ "$2" = "0" ]; then
+        save="no"
+    fi
+
+    if [ -n "${ARTIFACT_DIRECTORY}" ]; then
+        dest="${ARTIFACT_DIRECTORY}/${testname:?}.journal"
+    else
+        dest="${TESTDIR:?}/system.journal"
+    fi
+
+    for j in "${1:?}"/*; do
+        if get_bool "$save"; then
+            if [ "$SYSTEMD_JOURNAL_REMOTE" = "" ]; then
+                cp -a "$j" "$dest"
+            else
+                "$SYSTEMD_JOURNAL_REMOTE" -o "$dest" --getter="$JOURNALCTL -o export -D $j"
+            fi
+        fi
+
+        if [ -n "${TEST_SHOW_JOURNAL}" ]; then
+            echo "---- $j ----"
+            "$JOURNALCTL" --no-pager -o short-monotonic --no-hostname --priority="${TEST_SHOW_JOURNAL}" -D "$j"
+        fi
+
+        rm -r "$j"
+    done
+
+    if ! get_bool "$save"; then
+        return 0
+    fi
+
+    if [ -n "${SUDO_USER}" ]; then
+        setfacl -m "user:${SUDO_USER:?}:r-X" "$dest"*
+    fi
+
+    # we want to print this sometime later, so save this in a variable
+    JOURNAL_LIST="$(ls -l "$dest"*)"
+}
+
+check_result_common() {
+    local workspace="${1:?}"
+    local ret
+
+    if [ -s "$workspace/failed" ]; then
+        # Non-empty …/failed has highest priority
+        cp -a "$workspace/failed" "${TESTDIR:?}/"
+        if [ -n "${SUDO_USER}" ]; then
+            setfacl -m "user:${SUDO_USER:?}:r-X" "${TESTDIR:?}/"failed
+        fi
+        ret=1
+    elif get_bool "$TIMED_OUT"; then
+        echo "(timeout)" >"${TESTDIR:?}/failed"
+        ret=2
+    elif [ -e "$workspace/testok" ]; then
+        # …/testok always counts (but with lower priority than …/failed)
+        ret=0
+    elif [ -e "$workspace/skipped" ]; then
+        # …/skipped always counts (a message is expected)
+        echo "${TESTNAME:?} was skipped:"
+        cat "$workspace/skipped"
+        ret=0
+    else
+        echo "(failed; see logs)" >"${TESTDIR:?}/failed"
+        ret=3
+    fi
+
+    check_asan_reports "$workspace" || ret=4
+
+    check_coverage_reports "$workspace" || ret=5
+
+    save_journal "$workspace/var/log/journal" $ret
+
+    if [ -d "${ARTIFACT_DIRECTORY}" ] && [ -f "$workspace/strace.out" ]; then
+        cp "$workspace/strace.out" "${ARTIFACT_DIRECTORY}/"
+    fi
+
+    if [ ${ret:?} != 0 ] && [ -f "$TESTDIR/failed" ]; then
+        echo -n "${TESTNAME:?}: "
+        cat "$TESTDIR/failed"
+    fi
+    echo "${JOURNAL_LIST:-"No journals were saved"}"
+
+    return ${ret:?}
 }
 
 check_result_nspawn() {
-    ret=1
-    [[ -e $TESTDIR/nspawn-root/testok ]] && ret=0
-    [[ -f $TESTDIR/nspawn-root/failed ]] && cp -a $TESTDIR/nspawn-root/failed $TESTDIR
-    cp -a $TESTDIR/nspawn-root/var/log/journal $TESTDIR
-    [[ -f $TESTDIR/failed ]] && cat $TESTDIR/failed
-    ls -l $TESTDIR/journal/*/*.journal
-    test -s $TESTDIR/failed && ret=$(($ret+1))
+    local workspace="${1:?}"
+    local ret
+
+    # Run a test-specific checks if defined by check_result_nspawn_hook()
+    if declare -F check_result_nspawn_hook >/dev/null; then
+        if ! check_result_nspawn_hook "${workspace}"; then
+            derror "check_result_nspawn_hook() returned with EC > 0"
+            ret=4
+        fi
+    fi
+
+    check_result_common "${workspace}"
+    ret=$?
+
+    _umount_dir "${initdir:?}"
+
+    return $ret
+}
+
+# can be overridden in specific test
+check_result_qemu() {
+    local ret
+    mount_initdir
+
+    # Run a test-specific checks if defined by check_result_qemu_hook()
+    if declare -F check_result_qemu_hook >/dev/null; then
+        if ! check_result_qemu_hook "${initdir:?}"; then
+            derror "check_result_qemu_hook() returned with EC > 0"
+            ret=4
+        fi
+    fi
+
+    check_result_common "${initdir:?}"
+    ret=$?
+
+    _umount_dir "${initdir:?}"
+
+    return $ret
+}
+
+check_result_nspawn_unittests() {
+    local workspace="${1:?}"
+    local ret=1
+
+    [[ -e "$workspace/testok" ]] && ret=0
+
+    if [[ -s "$workspace/failed" ]]; then
+        ret=$((ret + 1))
+        echo "=== Failed test log ==="
+        cat "$workspace/failed"
+    else
+        if [[ -s "$workspace/skipped" ]]; then
+            echo "=== Skipped test log =="
+            cat "$workspace/skipped"
+            # We might have only skipped tests - that should not fail the job
+            ret=0
+        fi
+        if [[ -s "$workspace/testok" ]]; then
+            echo "=== Passed tests ==="
+            cat "$workspace/testok"
+        fi
+    fi
+
+    get_bool "${TIMED_OUT:=}" && ret=1
+    check_coverage_reports "$workspace" || ret=5
+
+    save_journal "$workspace/var/log/journal" $ret
+
+    _umount_dir "${initdir:?}"
+
+    return $ret
+}
+
+check_result_qemu_unittests() {
+    local ret=1
+
+    mount_initdir
+    [[ -e "${initdir:?}/testok" ]] && ret=0
+
+    if [[ -s "$initdir/failed" ]]; then
+        ret=$((ret + 1))
+        echo "=== Failed test log ==="
+        cat "$initdir/failed"
+    else
+        if [[ -s "$initdir/skipped" ]]; then
+            echo "=== Skipped test log =="
+            cat "$initdir/skipped"
+            # We might have only skipped tests - that should not fail the job
+            ret=0
+        fi
+        if [[ -s "$initdir/testok" ]]; then
+            echo "=== Passed tests ==="
+            cat "$initdir/testok"
+        fi
+    fi
+
+    get_bool "${TIMED_OUT:=}" && ret=1
+    check_coverage_reports "$initdir" || ret=5
+
+    save_journal "$initdir/var/log/journal" $ret
+
+    _umount_dir "$initdir"
+
     return $ret
 }
 
 strip_binaries() {
-    if [[ "$STRIP_BINARIES" = "no" ]]; then
-        ddebug "Don't strip binaries"
+    dinfo "Strip binaries"
+    if ! get_bool "$STRIP_BINARIES"; then
+        dinfo "STRIP_BINARIES == no, keeping binaries unstripped"
         return 0
     fi
-    ddebug "Strip binaries"
-    find "$initdir" -executable -not -path '*/lib/modules/*.ko' -type f | xargs strip --strip-unneeded | ddebug
+    while read -r bin; do
+        strip --strip-unneeded "$bin" |& grep -vi 'file format not recognized' | ddebug || :
+    done < <(find "${initdir:?}" -executable -not -path '*/lib/modules/*.ko' -type f)
 }
 
 create_rc_local() {
-    mkdir -p $initdir/etc/rc.d
-    cat >$initdir/etc/rc.d/rc.local <<EOF
-#!/bin/bash
+    dinfo "Create rc.local"
+    mkdir -p "${initdir:?}/etc/rc.d"
+    cat >"$initdir/etc/rc.d/rc.local" <<EOF
+#!/usr/bin/env bash
 exit 0
 EOF
-    chmod 0755 $initdir/etc/rc.d/rc.local
+    chmod 0755 "$initdir/etc/rc.d/rc.local"
 }
 
 install_execs() {
-    ddebug "install any Execs from the service files"
-    (
-    export PKG_CONFIG_PATH=$TEST_BASE_DIR/../src/core/
-    systemdsystemunitdir=$(pkg-config --variable=systemdsystemunitdir systemd)
-    systemduserunitdir=$(pkg-config --variable=systemduserunitdir systemd)
-    egrep -ho '^Exec[^ ]*=[^ ]+' $initdir/{$systemdsystemunitdir,$systemduserunitdir}/*.service \
-         | while read i; do
-         i=${i##Exec*=}; i=${i##-}
-         inst $i
-     done
-    )
+    ddebug "Install executables from the service files"
+
+    local pkg_config_path="${BUILD_DIR:?}/src/core/"
+    local systemunitdir userunitdir exe
+    systemunitdir="$(PKG_CONFIG_PATH="$pkg_config_path" pkg-config --variable=systemdsystemunitdir systemd)"
+    userunitdir="$(PKG_CONFIG_PATH="$pkg_config_path" pkg-config --variable=systemduserunitdir systemd)"
+    while read -r exe; do
+        # some {rc,halt}.local scripts and programs are okay to not exist, the rest should
+        # also, plymouth is pulled in by rescue.service, but even there the exit code
+        # is ignored; as it's not present on some distros, don't fail if it doesn't exist
+        dinfo "Attempting to install $exe (based on unit file reference)"
+        inst "$exe" || [ "${exe%.local}" != "$exe" ] || [ "${exe%systemd-update-done}" != "$exe" ] || [ "${exe##*/}" == "plymouth" ]
+    done < <(sed -r -n 's|^Exec[a-zA-Z]*=[@+!-]*([^ ]+).*|\1|gp' "${initdir:?}"/{"$systemunitdir","$userunitdir"}/*.service | sort -u)
 }
 
 generate_module_dependencies() {
-    if [[ -d $initdir/lib/modules/$KERNEL_VER ]] && \
-        ! depmod -a -b "$initdir" $KERNEL_VER; then
+    dinfo "Generate modules dependencies"
+    if [[ -d "${initdir:?}/lib/modules/${KERNEL_VER:?}" ]] && \
+        ! depmod -a -b "$initdir" "$KERNEL_VER"; then
             dfatal "\"depmod -a $KERNEL_VER\" failed."
             exit 1
     fi
 }
 
 install_depmod_files() {
-    inst /lib/modules/$KERNEL_VER/modules.order
-    inst /lib/modules/$KERNEL_VER/modules.builtin
+    dinfo "Install depmod files"
+    inst "/lib/modules/${KERNEL_VER:?}/modules.order"
+    inst "/lib/modules/$KERNEL_VER/modules.builtin"
 }
 
 install_plymouth() {
+    dinfo "Install plymouth"
     # install plymouth, if found... else remove plymouth service files
     # if [ -x /usr/libexec/plymouth/plymouth-populate-initrd ]; then
     #     PLYMOUTH_POPULATE_SOURCE_FUNCTIONS="$TEST_BASE_DIR/test-functions" \
     #         /usr/libexec/plymouth/plymouth-populate-initrd -t $initdir
-    #         dracut_install plymouth plymouthd
+    #         image_install plymouth plymouthd
     # else
-        rm -f $initdir/{usr/lib,etc}/systemd/system/plymouth* $initdir/{usr/lib,etc}/systemd/system/*/plymouth*
+        rm -f "${initdir:?}"/{usr/lib,lib,etc}/systemd/system/plymouth* "$initdir"/{usr/lib,lib,etc}/systemd/system/*/plymouth*
     # fi
 }
 
+install_haveged() {
+    # If haveged is installed, it's probably included in initrd and needs to be
+    # installed in the image too.
+    if [ -x /usr/sbin/haveged ]; then
+        dinfo "Install haveged files"
+        inst /usr/sbin/haveged
+        for u in /usr/lib/systemd/system/haveged*; do
+            inst "$u"
+        done
+    fi
+}
+
 install_ld_so_conf() {
-    cp -a /etc/ld.so.conf* $initdir/etc
+    dinfo "Install /etc/ld.so.conf*"
+    cp -a /etc/ld.so.conf* "${initdir:?}/etc"
     ldconfig -r "$initdir"
 }
 
+install_testuser() {
+    dinfo "Set up a test user"
+    # create unprivileged user for user manager tests
+    mkdir -p "${initdir:?}/etc/sysusers.d"
+    cat >"$initdir/etc/sysusers.d/testuser.conf" <<EOF
+u testuser    4711     "Test User" /home/testuser
+EOF
+
+    mkdir -p "$initdir/home/testuser"
+    chmod 0700 "$initdir/home/testuser"
+    chown 4711:4711 "$initdir/home/testuser"
+}
+
 install_config_files() {
-    inst /etc/sysconfig/init
+    dinfo "Install config files"
+    inst /etc/sysconfig/init || :
     inst /etc/passwd
     inst /etc/shadow
-    inst /etc/login.defs
+    inst_any /etc/login.defs /usr/etc/login.defs
     inst /etc/group
     inst /etc/shells
-    inst /etc/nsswitch.conf
-    inst /etc/pam.conf
-    inst /etc/securetty
-    inst /etc/os-release
+    inst_any /etc/nsswitch.conf /usr/etc/nsswitch.conf
+    inst /etc/pam.conf || :
+    inst_any /etc/os-release /usr/lib/os-release
     inst /etc/localtime
     # we want an empty environment
-    > $initdir/etc/environment
-    > $initdir/etc/machine-id
+    : >"${initdir:?}/etc/environment"
+    : >"$initdir/etc/machine-id"
+    : >"$initdir/etc/resolv.conf"
+
     # set the hostname
-    echo systemd-testsuite > $initdir/etc/hostname
-    # fstab
-    cat >$initdir/etc/fstab <<EOF
-LABEL=systemd           /       ext3    rw 0 1
-EOF
+    echo 'H' >"$initdir/etc/hostname"
+
+    # let's set up just one image with the traditional verbose output
+    if [ "${IMAGE_NAME:?}" != "basic" ]; then
+        mkdir -p "$initdir/etc/systemd/system.conf.d"
+        echo -e '[Manager]\nStatusUnitFormat=name' >"$initdir/etc/systemd/system.conf.d/status.conf"
+    fi
 }
 
 install_basic_tools() {
-    [[ $BASICTOOLS ]] && dracut_install $BASICTOOLS
-    dracut_install -o sushell
+    dinfo "Install basic tools"
+    image_install "${BASICTOOLS[@]}"
+    image_install -o sushell
     # in Debian ldconfig is just a shell script wrapper around ldconfig.real
-    dracut_install -o ldconfig.real
+    image_install -o ldconfig.real
 }
 
-install_debug_tools() {
-    [[ $DEBUGTOOLS ]] && dracut_install $DEBUGTOOLS
+install_debug_tools() {
+    dinfo "Install debug tools"
+    image_install -o "${DEBUGTOOLS[@]}"
+
+    if get_bool "$INTERACTIVE_DEBUG"; then
+        # Set default TERM from vt220 to linux, so at least basic key shortcuts work
+        local getty_override="${initdir:?}/etc/systemd/system/serial-getty@.service.d"
+        mkdir -p "$getty_override"
+        echo -e "[Service]\nEnvironment=TERM=linux" >"$getty_override/default-TERM.conf"
+        echo 'export TERM=linux' >>"$initdir/etc/profile"
+
+        if command -v resize >/dev/null; then
+            image_install resize
+            echo "resize" >>"$initdir/etc/profile"
+        fi
+
+        # Sometimes we might end up with plymouthd still running (especially
+        # with the initrd -> asan_wrapper -> systemd transition), which will eat
+        # our inputs and make debugging via tty impossible. Let's fix this by
+        # killing plymouthd explicitly for the interactive sessions.
+        # Note: we can't use pkill/pidof/etc. here due to a bug in libasan, see:
+        #   - https://github.com/llvm/llvm-project/issues/49223
+        #   - https://bugzilla.redhat.com/show_bug.cgi?id=2098125
+        local plymouth_unit="${initdir:?}/etc/systemd/system/kill-plymouth.service"
+        cat >"$plymouth_unit" <<EOF
+[Unit]
+After=multi-user.target
+
+[Service]
+ExecStart=sh -c 'killall --verbose plymouthd || :'
+
+[Install]
+WantedBy=multi-user.target
+EOF
+        "${SYSTEMCTL:?}" enable --root "${initdir:?}" kill-plymouth.service
+    fi
 }
 
 install_libnss() {
+    dinfo "Install libnss"
     # install libnss_files for login
-    NSS_LIBS=$(LD_DEBUG=files getent passwd 2>&1 >/dev/null |sed -n '/calling init: .*libnss_/ {s!^.* /!/!; p}')
-    dracut_install $NSS_LIBS
+    local NSS_LIBS
+    mapfile -t NSS_LIBS < <(LD_DEBUG=files getent passwd 2>&1 >/dev/null | sed -n '/calling init: .*libnss_/ {s!^.* /!/!; p}')
+    if [[ ${#NSS_LIBS[@]} -gt 0 ]]; then
+        image_install "${NSS_LIBS[@]}"
+    fi
 }
 
 install_dbus() {
-    inst $ROOTLIBDIR/system/dbus.socket
-    inst $ROOTLIBDIR/system/dbus.service
+    dinfo "Install dbus"
+    inst "${ROOTLIBDIR:?}/system/dbus.socket"
+
+    # Newer Fedora versions use dbus-broker by default. Let's install it if it's available.
+    if [ -f "$ROOTLIBDIR/system/dbus-broker.service" ]; then
+        inst "$ROOTLIBDIR/system/dbus-broker.service"
+        inst_symlink /etc/systemd/system/dbus.service
+        inst /usr/bin/dbus-broker
+        inst /usr/bin/dbus-broker-launch
+    elif [ -f "$ROOTLIBDIR/system/dbus-daemon.service" ]; then
+        # Fedora rawhide replaced dbus.service with dbus-daemon.service
+        inst "$ROOTLIBDIR/system/dbus-daemon.service"
+        # Alias symlink
+        inst_symlink /etc/systemd/system/dbus.service
+    else
+        inst "$ROOTLIBDIR/system/dbus.service"
+    fi
 
-    find \
-        /etc/dbus-1 /usr/share/dbus-1 -xtype f \
-        | while read file; do
-        inst $file
-    done
+    while read -r file; do
+        inst "$file"
+    done < <(find /etc/dbus-1 /usr/share/dbus-1 -xtype f 2>/dev/null)
+
+    # setup policy for Type=dbus test
+    mkdir -p "${initdir:?}/etc/dbus-1/system.d"
+    cat >"$initdir/etc/dbus-1/system.d/systemd.test.ExecStopPost.conf" <<EOF
+<?xml version="1.0"?>
+<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
+        "https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+<busconfig>
+    <policy user="root">
+        <allow own="systemd.test.ExecStopPost"/>
+    </policy>
+</busconfig>
+EOF
+
+    # If we run without KVM, bump the service start timeout
+    if ! get_bool "$QEMU_KVM"; then
+        cat >"$initdir/etc/dbus-1/system.d/service.timeout.conf" <<EOF
+<?xml version="1.0"?>
+<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
+        "https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
+<busconfig>
+    <limit name="service_start_timeout">60000</limit>
+</busconfig>
+EOF
+        # Bump the client-side timeout in sd-bus as well
+        mkdir -p "$initdir/etc/systemd/system.conf.d"
+        echo -e '[Manager]\nDefaultEnvironment=SYSTEMD_BUS_TIMEOUT=60' >"$initdir/etc/systemd/system.conf.d/bus-timeout.conf"
+    fi
+}
+
+install_user_dbus() {
+    dinfo "Install user dbus"
+    local userunitdir
+    if ! userunitdir="$(pkg-config --variable=systemduserunitdir systemd)"; then
+        dwarn "WARNING! Cannot determine userunitdir from pkg-config, assuming /usr/lib/systemd/user"
+        userunitdir=/usr/lib/systemd/user
+    fi
+
+    inst "$userunitdir/dbus.socket"
+    inst_symlink "$userunitdir/sockets.target.wants/dbus.socket" || inst_symlink /etc/systemd/user/sockets.target.wants/dbus.socket
+
+    # Append the After= dependency on dbus in case it isn't already set up
+    mkdir -p "${initdir:?}/etc/systemd/system/user@.service.d/"
+    cat >"$initdir/etc/systemd/system/user@.service.d/dbus.conf" <<EOF
+[Unit]
+After=dbus.service
+EOF
+
+    # Newer Fedora versions use dbus-broker by default. Let's install it if it's available.
+    if [ -f "$userunitdir/dbus-broker.service" ]; then
+        inst "$userunitdir/dbus-broker.service"
+        inst_symlink /etc/systemd/user/dbus.service
+    elif [ -f "${ROOTLIBDIR:?}/system/dbus-daemon.service" ]; then
+        # Fedora rawhide replaced dbus.service with dbus-daemon.service
+        inst "$userunitdir/dbus-daemon.service"
+        # Alias symlink
+        inst_symlink /etc/systemd/user/dbus.service
+    else
+        inst "$userunitdir/dbus.service"
+    fi
 }
 
 install_pam() {
-    (
-    [[ "$LOOKS_LIKE_DEBIAN" ]] && type -p dpkg-architecture &>/dev/null && find "/lib/$(dpkg-architecture -qDEB_HOST_MULTIARCH)/security" -xtype f
-    find \
-        /etc/pam.d \
-        /etc/security \
-        /lib64/security \
-        /lib/security -xtype f \
-    ) | while read file; do
-        inst $file
+    dinfo "Install PAM"
+    local paths=()
+
+    if get_bool "$LOOKS_LIKE_DEBIAN" && type -p dpkg-architecture &>/dev/null; then
+        paths+=("/lib/$(dpkg-architecture -qDEB_HOST_MULTIARCH)/security")
+    else
+        paths+=(/lib*/security)
+    fi
+
+    for d in /etc/pam.d /{usr/,}etc/security /usr/{etc,lib}/pam.d; do
+        [ -d "$d" ] && paths+=("$d")
     done
 
+    while read -r file; do
+        inst "$file"
+    done < <(find "${paths[@]}" -xtype f)
+
     # pam_unix depends on unix_chkpwd.
     # see http://www.linux-pam.org/Linux-PAM-html/sag-pam_unix.html
-    dracut_install -o unix_chkpwd
+    image_install -o unix_chkpwd
+
+    # set empty root password for easy debugging
+    sed -i 's/^root:x:/root::/' "${initdir:?}/etc/passwd"
+
+    # And make sure pam_unix will accept it by making sure that
+    # the PAM module has the nullok option.
+    for d in /etc/pam.d /usr/{etc,lib}/pam.d; do
+        [ -d "$initdir/$d" ] || continue
+        sed -i '/^auth.*pam_unix.so/s/$/ nullok/' "$initdir/$d"/*
+    done
+}
 
-    [[ "$LOOKS_LIKE_DEBIAN" ]] &&
-        cp /etc/pam.d/systemd-user $initdir/etc/pam.d/
+install_locales() {
+    # install only C.UTF-8 and English locales
+    dinfo "Install locales"
+
+    if command -v meson >/dev/null \
+            && (meson configure "${BUILD_DIR:?}" | grep 'localegen-path */') \
+            || get_bool "$LOOKS_LIKE_DEBIAN"; then
+        # locale-gen support
+        image_install -o locale-gen localedef
+        inst /etc/locale.gen || :
+        inst /usr/share/i18n/SUPPORTED || :
+        inst_recursive /usr/share/i18n/charmaps
+        inst_recursive /usr/share/i18n/locales
+        inst_recursive /usr/share/locale/en*
+        inst_recursive /usr/share/locale/de*
+        image_install /usr/share/locale/locale.alias
+        # locale-gen might either generate each locale separately or merge them
+        # into a single archive
+        if ! (inst_recursive /usr/lib/locale/C.*8 /usr/lib/locale/en_*8 ||
+              image_install /usr/lib/locale/locale-archive); then
+            dfatal "Failed to install required locales"
+            exit 1
+        fi
+    else
+        inst_recursive /usr/lib/locale/C.*8 /usr/lib/locale/en_*8
+    fi
 }
 
+# shellcheck disable=SC2120
 install_keymaps() {
-    for i in \
-        /usr/lib/kbd/keymaps/include/* \
-        /usr/lib/kbd/keymaps/i386/include/* \
-        /usr/lib/kbd/keymaps/i386/qwerty/us.*; do
-            [[ -f $i ]] || continue
-            inst $i
-    done
+    local i p
+    local -a prefix=(
+        "/usr/lib"
+        "/usr/share"
+    )
+
+    dinfo "Install console keymaps"
+
+    if command -v meson >/dev/null \
+            && [[ "$(meson configure "${BUILD_DIR:?}" | grep 'split-usr' | awk '{ print $2 }')" == "true" ]] \
+            || [[ ! -L /lib ]]; then
+        prefix+=(
+            "/lib"
+        )
+    fi
+
+    if (( $# == 0 )); then
+        for p in "${prefix[@]}"; do
+            # The first three paths may be deprecated.
+            # It seems now the last three paths are used by many distributions.
+            for i in \
+                "$p"/kbd/keymaps/include/* \
+                "$p"/kbd/keymaps/i386/include/* \
+                "$p"/kbd/keymaps/i386/qwerty/us.* \
+                "$p"/kbd/keymaps/legacy/include/* \
+                "$p"/kbd/keymaps/legacy/i386/qwerty/us.* \
+                "$p"/kbd/keymaps/xkb/us*; do
+                    [[ -f "$i" ]] || continue
+                    inst "$i"
+            done
+        done
+    else
+        # When it takes any argument, then install more keymaps.
+        for p in "${prefix[@]}"; do
+            for i in \
+                "$p"/kbd/keymaps/include/* \
+                "$p"/kbd/keymaps/i386/*/* \
+                "$p"/kbd/keymaps/legacy/i386/*/* \
+                "$p"/kbd/keymaps/xkb/*; do
+                    [[ -f "$i" ]] || continue
+                    inst "$i"
+            done
+        done
+    fi
+}
+
+install_x11_keymaps() {
+    dinfo "Install x11 keymaps"
+
+    if (( $# == 0 )); then
+        # Install only keymap list.
+        inst /usr/share/X11/xkb/rules/base.lst
+    else
+        # When it takes any argument, then install all keymaps.
+        inst_recursive /usr/share/X11/xkb
+    fi
+}
+
+install_zoneinfo() {
+    dinfo "Install time zones"
+    inst_any /usr/share/zoneinfo/Asia/Seoul
+    inst_any /usr/share/zoneinfo/Asia/Vladivostok
+    inst_any /usr/share/zoneinfo/Australia/Sydney
+    inst_any /usr/share/zoneinfo/Europe/Berlin
+    inst_any /usr/share/zoneinfo/Europe/Dublin
+    inst_any /usr/share/zoneinfo/Europe/Kiev
+    inst_any /usr/share/zoneinfo/Pacific/Auckland
+    inst_any /usr/share/zoneinfo/Pacific/Honolulu
+    inst_any /usr/share/zoneinfo/CET
+    inst_any /usr/share/zoneinfo/EET
+    inst_any /usr/share/zoneinfo/UTC
 }
 
 install_fonts() {
+    dinfo "Install system fonts"
     for i in \
         /usr/lib/kbd/consolefonts/eurlatgr* \
         /usr/lib/kbd/consolefonts/latarcyrheb-sun16*; do
-            [[ -f $i ]] || continue
-            inst $i
+            [[ -f "$i" ]] || continue
+            inst "$i"
     done
 }
 
 install_terminfo() {
-    for _terminfodir in /lib/terminfo /etc/terminfo /usr/share/terminfo; do
-        [ -f ${_terminfodir}/l/linux ] && break
+    dinfo "Install terminfo files"
+    local terminfodir
+    for terminfodir in /lib/terminfo /etc/terminfo /usr/share/terminfo; do
+        [ -f "${terminfodir}/l/linux" ] && break
     done
-    dracut_install -o ${_terminfodir}/l/linux
+    image_install -o "${terminfodir}/l/linux"
 }
 
-setup_testsuite() {
-    cp $TEST_BASE_DIR/testsuite.target $initdir/etc/systemd/system/
-    cp $TEST_BASE_DIR/end.service $initdir/etc/systemd/system/
-
-    mkdir -p $initdir/etc/systemd/system/testsuite.target.wants
-    ln -fs $TEST_BASE_DIR/testsuite.service $initdir/etc/systemd/system/testsuite.target.wants/testsuite.service
-    ln -fs $TEST_BASE_DIR/end.service $initdir/etc/systemd/system/testsuite.target.wants/end.service
-
-    # make the testsuite the default target
-    ln -fs testsuite.target $initdir/etc/systemd/system/default.target
+has_user_dbus_socket() {
+    if [ -f /usr/lib/systemd/user/dbus.socket ] || [ -f /etc/systemd/user/dbus.socket ]; then
+        return 0
+    else
+        echo "Per-user instances are not supported. Skipping..."
+        return 1
+    fi
 }
 
+setup_nspawn_root_hook() { :;}
+
 setup_nspawn_root() {
-    rm -fr $TESTDIR/nspawn-root
-    ddebug "cp -ar $initdir $TESTDIR/nspawn-root"
-    cp -ar $initdir $TESTDIR/nspawn-root
-    # we don't mount in the nspawn root
-    rm -f $TESTDIR/nspawn-root/etc/fstab
+    if [ -z "${initdir}" ]; then
+        dfatal "\$initdir not defined"
+        exit 1
+    fi
+
+    rm -rf "${TESTDIR:?}/unprivileged-nspawn-root"
+
+    if get_bool "$RUN_IN_UNPRIVILEGED_CONTAINER"; then
+        ddebug "cp -ar $initdir $TESTDIR/unprivileged-nspawn-root"
+        cp -ar "$initdir" "$TESTDIR/unprivileged-nspawn-root"
+    fi
+
+    setup_nspawn_root_hook
 }
 
 setup_basic_dirs() {
-    mkdir -p $initdir/run
-    mkdir -p $initdir/etc/systemd/system
-    mkdir -p $initdir/var/log/journal
+    mkdir -p "${initdir:?}/run"
+    mkdir -p "$initdir/etc/systemd/system"
+    mkdir -p "$initdir/var/log/journal"
+
 
-    for d in usr/bin usr/sbin bin etc lib "$libdir" sbin tmp usr var var/log dev proc sys sysroot root run run/lock run/initramfs; do
+    for d in usr/bin usr/sbin bin etc lib "${libdir:?}" sbin tmp usr var var/log var/tmp dev proc sys sysroot root run run/lock run/initramfs; do
         if [ -L "/$d" ]; then
             inst_symlink "/$d"
         else
@@ -450,46 +2158,91 @@ setup_basic_dirs() {
     ln -sfn /run/lock "$initdir/var/lock"
 }
 
-inst_libs() {
-    local _bin=$1
-    local _so_regex='([^ ]*/lib[^/]*/[^ ]*\.so[^ ]*)'
-    local _file _line
-
-    LC_ALL=C ldd "$_bin" 2>/dev/null | while read _line; do
-        [[ $_line = 'not a dynamic executable' ]] && break
+mask_supporting_services() {
+    # mask some services that we do not want to run in these tests
+    ln -fsv /dev/null "${initdir:?}/etc/systemd/system/systemd-hwdb-update.service"
+    ln -fsv /dev/null "$initdir/etc/systemd/system/systemd-journal-catalog-update.service"
+    ln -fsv /dev/null "$initdir/etc/systemd/system/systemd-networkd.service"
+    ln -fsv /dev/null "$initdir/etc/systemd/system/systemd-networkd.socket"
+    ln -fsv /dev/null "$initdir/etc/systemd/system/systemd-resolved.service"
+}
 
-        if [[ $_line =~ $_so_regex ]]; then
-            _file=${BASH_REMATCH[1]}
-            [[ -e ${initdir}/$_file ]] && continue
-            inst_library "$_file"
+inst_libs() {
+    local bin="${1:?}"
+    local so_regex='([^ ]*/lib[^/]*/[^ ]*\.so[^ ]*)'
+    local file line
+
+    while read -r line; do
+        [[ "$line" = 'not a dynamic executable' ]] && break
+        # Ignore errors about our own stuff missing. This is most likely caused
+        # by ldd attempting to use the unprefixed RPATH.
+        [[ "$line" =~ libsystemd.*\ not\ found ]] && continue
+
+        if [[ "$line" =~ $so_regex ]]; then
+            file="${BASH_REMATCH[1]}"
+            [[ -e "${initdir:?}/$file" ]] && continue
+            inst_library "$file"
             continue
         fi
 
-        if [[ $_line =~ not\ found ]]; then
-            dfatal "Missing a shared library required by $_bin."
-            dfatal "Run \"ldd $_bin\" to find out what it is."
-            dfatal "$_line"
-            dfatal "dracut cannot create an initrd."
+        if [[ "$line" =~ not\ found ]]; then
+            dfatal "Missing a shared library required by $bin."
+            dfatal "Run \"ldd $bin\" to find out what it is."
+            dfatal "$line"
+            dfatal "Cannot create a test image."
             exit 1
         fi
-    done
+    done < <(LC_ALL=C ldd "$bin" 2>/dev/null)
 }
 
 import_testdir() {
-    STATEFILE=".testdir"
-    [[ -e $STATEFILE ]] && . $STATEFILE
-    if [[ -z "$TESTDIR" ]] || [[ ! -d "$TESTDIR" ]]; then
-        TESTDIR=$(mktemp --tmpdir=/var/tmp -d -t systemd-test.XXXXXX)
-        echo "TESTDIR=\"$TESTDIR\"" > $STATEFILE
+    # make sure we don't get a stale LOOPDEV value from old times
+    local _LOOPDEV="${LOOPDEV:=}"
+    # We don't want shellcheck to follow & check the $STATEFILE
+    # shellcheck source=/dev/null
+    [[ -e "$STATEFILE" ]] && . "$STATEFILE"
+    LOOPDEV="$_LOOPDEV"
+    if [[ ! -d "$TESTDIR" ]]; then
+        if [[ -z "$TESTDIR" ]]; then
+            TESTDIR="$(mktemp --tmpdir=/var/tmp -d -t systemd-test.XXXXXX)"
+        else
+            mkdir -p "$TESTDIR"
+        fi
+
+        cat >"$STATEFILE" <<EOF
+TESTDIR="$TESTDIR"
+EOF
         export TESTDIR
     fi
+
+    IMAGE_PRIVATE="${TESTDIR}/${IMAGE_NAME:?}.img"
+    IMAGE_PUBLIC="${IMAGESTATEDIR:?}/${IMAGE_NAME}.img"
 }
 
 import_initdir() {
-    initdir=$TESTDIR/root
+    initdir="${TESTDIR:?}/root"
+    mkdir -p "$initdir"
     export initdir
 }
 
+get_cgroup_hierarchy() {
+    case "$(stat -c '%T' -f /sys/fs/cgroup)" in
+        cgroup2fs)
+            echo "unified"
+            ;;
+        tmpfs)
+            if [[ -d /sys/fs/cgroup/unified && "$(stat -c '%T' -f /sys/fs/cgroup/unified)" == cgroup2fs ]]; then
+                echo "hybrid"
+            else
+                echo "legacy"
+            fi
+            ;;
+        *)
+            dfatal "Failed to determine host's cgroup hierarchy"
+            exit 1
+    esac
+}
+
 ## @brief Converts numeric logging level to the first letter of level name.
 #
 # @param lvl Numeric logging level in range from 1 to 6.
@@ -524,18 +2277,20 @@ _lvl2char() {
 # This enables:
 # dwarn "This is a warning"
 # echo "This is a warning" | dwarn
-LOG_LEVEL=4
+LOG_LEVEL="${LOG_LEVEL:-4}"
 
 dlog() {
+    local lvl lvlc
+
     [ -z "$LOG_LEVEL" ] && return 0
-    [ $1 -le $LOG_LEVEL ] || return 0
-    local lvl="$1"; shift
-    local lvlc=$(_lvl2char "$lvl") || return 0
+    lvl="${1:?}"; shift
+    [ "$lvl" -le "$LOG_LEVEL" ] || return 0
+    lvlc="$(_lvl2char "$lvl")" || return 0
 
     if [ $# -ge 1 ]; then
         echo "$lvlc: $*"
     else
-        while read line; do
+        while read -r line; do
             echo "$lvlc: " "$line"
         done
     fi
@@ -548,7 +2303,9 @@ dlog() {
 dtrace() {
     set +x
     dlog 6 "$@"
-    [ -n "$debug" ] && set -x || :
+    if get_bool "${debug:=}"; then
+        set -x
+    fi
 }
 
 ## @brief Logs message at DEBUG level (5)
@@ -556,9 +2313,7 @@ dtrace() {
 # @param msg Message.
 # @retval 0 It's always returned, even if logging failed.
 ddebug() {
-#    set +x
     dlog 5 "$@"
-#    [ -n "$debug" ] && set -x || :
 }
 
 ## @brief Logs message at INFO level (4)
@@ -568,7 +2323,9 @@ ddebug() {
 dinfo() {
     set +x
     dlog 4 "$@"
-    [ -n "$debug" ] && set -x || :
+    if get_bool "${debug:=}"; then
+        set -x
+    fi
 }
 
 ## @brief Logs message at WARN level (3)
@@ -578,7 +2335,9 @@ dinfo() {
 dwarn() {
     set +x
     dlog 3 "$@"
-    [ -n "$debug" ] && set -x || :
+    if get_bool "${debug:=}"; then
+        set -x
+    fi
 }
 
 ## @brief Logs message at ERROR level (2)
@@ -586,9 +2345,7 @@ dwarn() {
 # @param msg Message.
 # @retval 0 It's always returned, even if logging failed.
 derror() {
-#    set +x
     dlog 2 "$@"
-#    [ -n "$debug" ] && set -x || :
 }
 
 ## @brief Logs message at FATAL level (1)
@@ -598,12 +2355,14 @@ derror() {
 dfatal() {
     set +x
     dlog 1 "$@"
-    [ -n "$debug" ] && set -x || :
+    if get_bool "${debug:=}"; then
+        set -x
+    fi
 }
 
 
 # Generic substring function.  If $2 is in $1, return 0.
-strstr() { [ "${1#*$2*}" != "$1" ]; }
+strstr() { [ "${1#*"$2"*}" != "$1" ]; }
 
 # normalize_path <path>
 # Prints the normalized path, where it removes any duplicated
@@ -628,7 +2387,7 @@ convert_abs_rel() {
     local __current __absolute __abssize __cursize __newpath
     local -i __i __level
 
-    set -- "$(normalize_path "$1")" "$(normalize_path "$2")"
+    set -- "$(normalize_path "${1:?}")" "$(normalize_path "${2:?}")"
 
     # corner case #1 - self looping link
     [[ "$1" == "$2" ]] && { echo "${1##*/}"; return; }
@@ -636,14 +2395,13 @@ convert_abs_rel() {
     # corner case #2 - own dir link
     [[ "${1%/*}" == "$2" ]] && { echo "."; return; }
 
-    IFS="/" __current=($1)
-    IFS="/" __absolute=($2)
+    IFS="/" read -ra __current <<< "$1"
+    IFS="/" read -ra __absolute <<< "$2"
 
     __abssize=${#__absolute[@]}
     __cursize=${#__current[@]}
 
-    while [[ ${__absolute[__level]} == ${__current[__level]} ]]
-    do
+    while [[ "${__absolute[__level]}" == "${__current[__level]}" ]]; do
         (( __level++ ))
         if (( __level > __abssize || __level > __cursize ))
         then
@@ -651,8 +2409,7 @@ convert_abs_rel() {
         fi
     done
 
-    for ((__i = __level; __i < __cursize-1; __i++))
-    do
+    for ((__i = __level; __i < __cursize-1; __i++)); do
         if ((__i > __level))
         then
             __newpath=$__newpath"/"
@@ -660,8 +2417,7 @@ convert_abs_rel() {
         __newpath=$__newpath".."
     done
 
-    for ((__i = __level; __i < __abssize; __i++))
-    do
+    for ((__i = __level; __i < __abssize; __i++)); do
         if [[ -n $__newpath ]]
         then
             __newpath=$__newpath"/"
@@ -678,24 +2434,27 @@ convert_abs_rel() {
 # will create ${initdir}/lib64, ${initdir}/lib64/file,
 # and a symlink ${initdir}/lib -> lib64.
 inst_dir() {
-    [[ -e ${initdir}/"$1" ]] && return 0  # already there
+    local dir="${1:?}"
+    local part="${dir%/*}"
+    local file
+
+    [[ -e "${initdir:?}/${dir}" ]] && return 0  # already there
 
-    local _dir="$1" _part="${1%/*}" _file
-    while [[ "$_part" != "${_part%/*}" ]] && ! [[ -e "${initdir}/${_part}" ]]; do
-        _dir="$_part $_dir"
-        _part=${_part%/*}
+    while [[ "$part" != "${part%/*}" ]] && ! [[ -e "${initdir}/${part}" ]]; do
+        dir="$part $dir"
+        part="${part%/*}"
     done
 
     # iterate over parent directories
-    for _file in $_dir; do
-        [[ -e "${initdir}/$_file" ]] && continue
-        if [[ -L $_file ]]; then
-            inst_symlink "$_file"
+    for file in $dir; do
+        [[ -e "${initdir}/$file" ]] && continue
+        if [[ -L $file ]]; then
+            inst_symlink "$file"
         else
             # create directory
-            mkdir -m 0755 -p "${initdir}/$_file" || return 1
-            [[ -e "$_file" ]] && chmod --reference="$_file" "${initdir}/$_file"
-            chmod u+w "${initdir}/$_file"
+            mkdir -m 0755 "${initdir}/$file" || return 1
+            [[ -e "$file" ]] && chmod --reference="$file" "${initdir}/$file"
+            chmod u+w "${initdir}/$file"
         fi
     done
 }
@@ -705,21 +2464,22 @@ inst_dir() {
 # Location of the image dir is assumed to be $initdir
 # We never overwrite the target if it exists.
 inst_simple() {
-    [[ -f "$1" ]] || return 1
+    [[ -f "${1:?}" ]] || return 1
     strstr "$1" "/" || return 1
 
-    local _src=$1 target="${2:-$1}"
-    if ! [[ -d ${initdir}/$target ]]; then
+    local src="$1"
+    local target="${2:-$1}"
+    if ! [[ -d ${initdir:?}/$target ]]; then
         [[ -e ${initdir}/$target ]] && return 0
         [[ -L ${initdir}/$target ]] && return 0
         [[ -d "${initdir}/${target%/*}" ]] || inst_dir "${target%/*}"
     fi
     # install checksum files also
-    if [[ -e "${_src%/*}/.${_src##*/}.hmac" ]]; then
-        inst "${_src%/*}/.${_src##*/}.hmac" "${target%/*}/.${target##*/}.hmac"
+    if [[ -e "${src%/*}/.${src##*/}.hmac" ]]; then
+        inst "${src%/*}/.${src##*/}.hmac" "${target%/*}/.${target##*/}.hmac"
     fi
-    ddebug "Installing $_src"
-    cp --sparse=always -pfL "$_src" "${initdir}/$target"
+    ddebug "Installing $src"
+    cp --sparse=always -pfL "$src" "${initdir}/$target"
 }
 
 # find symlinks linked to given library file
@@ -733,15 +2493,16 @@ inst_simple() {
 # output: libfoo.so.8 libfoo.so
 # (Only if libfoo.so.8 and libfoo.so exists on host system.)
 rev_lib_symlinks() {
-    [[ ! $1 ]] && return 0
+    local fn="${1:?}"
+    local links=""
+    local orig
+    orig="$(readlink -f "$1")"
 
-    local fn="$1" orig="$(readlink -f "$1")" links=''
+    [[ "${fn}" =~ .*\.so\..* ]] || return 1
 
-    [[ ${fn} =~ .*\.so\..* ]] || return 1
-
-    until [[ ${fn##*.} == so ]]; do
+    until [[ "${fn##*.}" == so ]]; do
         fn="${fn%.*}"
-        [[ -L ${fn} && $(readlink -f "${fn}") == ${orig} ]] && links+=" ${fn}"
+        [[ -L "${fn}" && "$(readlink -f "${fn}")" == "${orig}" ]] && links+=" ${fn}"
     done
 
     echo "${links}"
@@ -751,177 +2512,254 @@ rev_lib_symlinks() {
 # It handles making symlinks according to how the original library
 # is referenced.
 inst_library() {
-    local _src="$1" _dest=${2:-$1} _lib _reallib _symlink
+    local src="${1:?}"
+    local dest="${2:-$1}"
+    local reallib symlink
+
     strstr "$1" "/" || return 1
-    [[ -e $initdir/$_dest ]] && return 0
-    if [[ -L $_src ]]; then
+    [[ -e ${initdir:?}/$dest ]] && return 0
+    if [[ -L $src ]]; then
         # install checksum files also
-        if [[ -e "${_src%/*}/.${_src##*/}.hmac" ]]; then
-            inst "${_src%/*}/.${_src##*/}.hmac" "${_dest%/*}/.${_dest##*/}.hmac"
+        if [[ -e "${src%/*}/.${src##*/}.hmac" ]]; then
+            inst "${src%/*}/.${src##*/}.hmac" "${dest%/*}/.${dest##*/}.hmac"
         fi
-        _reallib=$(readlink -f "$_src")
-        inst_simple "$_reallib" "$_reallib"
-        inst_dir "${_dest%/*}"
-        [[ -d "${_dest%/*}" ]] && _dest=$(readlink -f "${_dest%/*}")/${_dest##*/}
-        ln -sfn $(convert_abs_rel "${_dest}" "${_reallib}") "${initdir}/${_dest}"
+        reallib="$(readlink -f "$src")"
+        inst_simple "$reallib" "$reallib"
+        inst_dir "${dest%/*}"
+        [[ -d "${dest%/*}" ]] && dest="$(readlink -f "${dest%/*}")/${dest##*/}"
+        ddebug "Creating symlink $reallib -> $dest"
+        ln -sfn -- "$(convert_abs_rel "${dest}" "${reallib}")" "${initdir}/${dest}"
     else
-        inst_simple "$_src" "$_dest"
+        inst_simple "$src" "$dest"
     fi
 
     # Create additional symlinks.  See rev_symlinks description.
-    for _symlink in $(rev_lib_symlinks $_src) $(rev_lib_symlinks $_reallib); do
-        [[ ! -e $initdir/$_symlink ]] && {
-            ddebug "Creating extra symlink: $_symlink"
-            inst_symlink $_symlink
-        }
+    for symlink in $(rev_lib_symlinks "$src") ${reallib:+$(rev_lib_symlinks "$reallib")}; do
+        if [[ ! -e "$initdir/$symlink" ]]; then
+            ddebug "Creating extra symlink: $symlink"
+            inst_symlink "$symlink"
+        fi
     done
 }
 
 # find a binary.  If we were not passed the full path directly,
 # search in the usual places to find the binary.
 find_binary() {
-    if [[ -z ${1##/*} ]]; then
-        if [[ -x $1 ]] || { strstr "$1" ".so" && ldd $1 &>/dev/null; };  then
-            echo $1
+    local bin="${1:?}"
+    if [[ -z ${bin##/*} ]]; then
+        if [[ -x "$bin" ]] || { strstr "$bin" ".so" && ldd "$bin" &>/dev/null; }; then
+            echo "$bin"
             return 0
         fi
     fi
 
-    type -P $1
+    type -P "$bin"
 }
 
 # Same as above, but specialized to install binary executables.
 # Install binary executable, and all shared library dependencies, if any.
 inst_binary() {
-    local _bin _target
-    _bin=$(find_binary "$1") || return 1
-    _target=${2:-$_bin}
-    [[ -e $initdir/$_target ]] && return 0
-    [[ -L $_bin ]] && inst_symlink $_bin $_target && return 0
-    local _file _line
-    local _so_regex='([^ ]*/lib[^/]*/[^ ]*\.so[^ ]*)'
+    local bin="${1:?}"
+    local path target
+
+    # In certain cases we might attempt to install a binary which is already
+    # present in the test image, yet it's missing from the host system.
+    # In such cases, let's check if the binary indeed exists in the image
+    # before doing any other checks. If it does, immediately return with
+    # success.
+    if [[ $# -eq 1 ]]; then
+        for path in "" bin sbin usr/bin usr/sbin; do
+            [[ -e "${initdir:?}${path:+/$path}/${bin}" ]] && return 0
+        done
+    fi
+
+    bin="$(find_binary "$bin")" || return 1
+    target="${2:-$bin}"
+    [[ -e "${initdir:?}/$target" ]] && return 0
+    [[ -L "$bin" ]] && inst_symlink "$bin" "$target" && return 0
+
+    local file line
+    local so_regex='([^ ]*/lib[^/]*/[^ ]*\.so[^ ]*)'
+    # DSOs provided by systemd
+    local systemd_so_regex='/(libudev|libsystemd.*|.+[\-_]systemd([\-_].+)?|libnss_(mymachines|myhostname|resolve)).so'
+    local wrap_binary=0
     # I love bash!
-    LC_ALL=C ldd "$_bin" 2>/dev/null | while read _line; do
-        [[ $_line = 'not a dynamic executable' ]] && break
+    while read -r line; do
+        [[ "$line" = 'not a dynamic executable' ]] && break
+
+        # Ignore errors about our own stuff missing. This is most likely caused
+        # by ldd attempting to use the unprefixed RPATH.
+        [[ "$line" =~ libsystemd.*\ not\ found ]] && continue
+
+        # We're built with ASan and the target binary loads one of the systemd's
+        # DSOs, so we need to tweak the environment before executing the binary
+        if get_bool "$IS_BUILT_WITH_ASAN" && [[ "$line" =~ $systemd_so_regex ]]; then
+            wrap_binary=1
+        fi
 
-        if [[ $_line =~ $_so_regex ]]; then
-            _file=${BASH_REMATCH[1]}
-            [[ -e ${initdir}/$_file ]] && continue
-            inst_library "$_file"
+        if [[ "$line" =~ $so_regex ]]; then
+            file="${BASH_REMATCH[1]}"
+            [[ -e "${initdir}/$file" ]] && continue
+            inst_library "$file"
             continue
         fi
 
-        if [[ $_line =~ not\ found ]]; then
-            dfatal "Missing a shared library required by $_bin."
-            dfatal "Run \"ldd $_bin\" to find out what it is."
-            dfatal "$_line"
-            dfatal "dracut cannot create an initrd."
+        if [[ "$line" =~ not\ found ]]; then
+            dfatal "Missing a shared library required by $bin."
+            dfatal "Run \"ldd $bin\" to find out what it is."
+            dfatal "$line"
+            dfatal "Cannot create a test image."
             exit 1
         fi
-    done
-    inst_simple "$_bin" "$_target"
+    done < <(LC_ALL=C ldd "$bin" 2>/dev/null)
+
+    # Same as above, but we need to wrap certain libraries unconditionally
+    #
+    # chown, getent, login, su, useradd, userdel - dlopen()s (not only) systemd's PAM modules
+    # ls, stat - pulls in nss_systemd with certain options (like ls -l) when
+    #            nsswitch.conf uses [SUCCESS=merge] (like on Arch Linux)
+    # tar - called by machinectl in TEST-25
+    if get_bool "$IS_BUILT_WITH_ASAN" && [[ "$bin" =~ /(chown|getent|login|ls|stat|su|tar|useradd|userdel)$ ]]; then
+        wrap_binary=1
+    fi
+
+    # If the target binary is built with ASan support, we don't need to wrap
+    # it, as it should handle everything by itself
+    if get_bool "$wrap_binary" && ! is_built_with_asan "$bin"; then
+        dinfo "Creating ASan-compatible wrapper for binary '$target'"
+        # Install the target binary with a ".orig" suffix
+        inst_simple "$bin" "${target}.orig"
+        # Create a simple shell wrapper in place of the target binary, which
+        # sets necessary ASan-related env variables and then exec()s the
+        # suffixed target binary
+        cat >"$initdir/$target" <<EOF
+#!/bin/bash
+# Preload the ASan runtime DSO, otherwise ASAn will complain
+export LD_PRELOAD="$ASAN_RT_PATH"
+# Disable LSan to speed things up, since we don't care about leak reports
+# from 'external' binaries
+export ASAN_OPTIONS=detect_leaks=0
+# Set argv[0] to the original binary name without the ".orig" suffix
+exec -a "\$0" -- "${target}.orig" "\$@"
+EOF
+        chmod +x "$initdir/$target"
+    else
+        inst_simple "$bin" "$target"
+    fi
 }
 
 # same as above, except for shell scripts.
 # If your shell script does not start with shebang, it is not a shell script.
 inst_script() {
-    local _bin
-    _bin=$(find_binary "$1") || return 1
+    local bin line shebang_regex
+    bin="$(find_binary "${1:?}")" || return 1
     shift
-    local _line _shebang_regex
-    read -r -n 80 _line <"$_bin"
+
+    read -r -n 80 line <"$bin"
     # If debug is set, clean unprintable chars to prevent messing up the term
-    [[ $debug ]] && _line=$(echo -n "$_line" | tr -c -d '[:print:][:space:]')
-    _shebang_regex='(#! *)(/[^ ]+).*'
-    [[ $_line =~ $_shebang_regex ]] || return 1
-    inst "${BASH_REMATCH[2]}" && inst_simple "$_bin" "$@"
+    get_bool "${debug:=}" && line="$(echo -n "$line" | tr -c -d '[:print:][:space:]')"
+    shebang_regex='(#! *)(/[^ ]+).*'
+    [[ "$line" =~ $shebang_regex ]] || return 1
+    inst "${BASH_REMATCH[2]}" && inst_simple "$bin" "$@"
 }
 
 # same as above, but specialized for symlinks
 inst_symlink() {
-    local _src=$1 _target=${2:-$1} _realsrc
-    strstr "$1" "/" || return 1
-    [[ -L $1 ]] || return 1
-    [[ -L $initdir/$_target ]] && return 0
-    _realsrc=$(readlink -f "$_src")
-    if ! [[ -e $initdir/$_realsrc ]]; then
-        if [[ -d $_realsrc ]]; then
-            inst_dir "$_realsrc"
+    local src="${1:?}"
+    local target="${2:-$src}"
+    local realsrc
+
+    strstr "$src" "/" || return 1
+    [[ -L "$src" ]] || return 1
+    [[ -L "${initdir:?}/$target" ]] && return 0
+    realsrc="$(readlink -f "$src")"
+    if ! [[ -e "$initdir/$realsrc" ]]; then
+        if [[ -d "$realsrc" ]]; then
+            inst_dir "$realsrc"
         else
-            inst "$_realsrc"
+            inst "$realsrc"
         fi
     fi
-    [[ ! -e $initdir/${_target%/*} ]] && inst_dir "${_target%/*}"
-    [[ -d ${_target%/*} ]] && _target=$(readlink -f ${_target%/*})/${_target##*/}
-    ln -sfn $(convert_abs_rel "${_target}" "${_realsrc}") "$initdir/$_target"
+    [[ ! -e "$initdir/${target%/*}" ]] && inst_dir "${target%/*}"
+    [[ -d "${target%/*}" ]] && target="$(readlink -f "${target%/*}")/${target##*/}"
+    ln -sfn -- "$(convert_abs_rel "${target}" "${realsrc}")" "$initdir/$target"
 }
 
 # attempt to install any programs specified in a udev rule
 inst_rule_programs() {
-    local _prog _bin
+    local rule="${1:?}"
+    local prog bin
 
-    if grep -qE 'PROGRAM==?"[^ "]+' "$1"; then
-        for _prog in $(grep -E 'PROGRAM==?"[^ "]+' "$1" | sed -r 's/.*PROGRAM==?"([^ "]+).*/\1/'); do
-            if [ -x /lib/udev/$_prog ]; then
-                _bin=/lib/udev/$_prog
-            else
-                _bin=$(find_binary "$_prog") || {
-                    dinfo "Skipping program $_prog using in udev rule $(basename $1) as it cannot be found"
-                    continue;
-                }
+    sed -rn 's/^.*?PROGRAM==?"([^ "]+).*$/\1/p' "$rule" | while read -r prog; do
+        if [ -x "/lib/udev/$prog" ]; then
+            bin="/lib/udev/$prog"
+        else
+            if ! bin="$(find_binary "$prog")"; then
+                dinfo "Skipping program $prog used in udev rule $(basename "$rule") as it cannot be found"
+                continue
             fi
+        fi
 
-            #dinfo "Installing $_bin due to it's use in the udev rule $(basename $1)"
-            dracut_install "$_bin"
-        done
-    fi
+        #dinfo "Installing $_bin due to it's use in the udev rule $(basename $1)"
+        image_install "$bin"
+    done
 }
 
 # udev rules always get installed in the same place, so
 # create a function to install them to make life simpler.
 inst_rules() {
-    local _target=/etc/udev/rules.d _rule _found
+    local target=/etc/udev/rules.d
+    local found rule
 
     inst_dir "/lib/udev/rules.d"
-    inst_dir "$_target"
-    for _rule in "$@"; do
+    inst_dir "$target"
+    for rule in "$@"; do
         if [ "${rule#/}" = "$rule" ]; then
             for r in /lib/udev/rules.d /etc/udev/rules.d; do
-                if [[ -f $r/$_rule ]]; then
-                    _found="$r/$_rule"
-                    inst_simple "$_found"
-                    inst_rule_programs "$_found"
+                if [[ -f "$r/$rule" ]]; then
+                    found="$r/$rule"
+                    inst_simple "$found"
+                    inst_rule_programs "$found"
                 fi
             done
         fi
-        for r in '' ./ $dracutbasedir/rules.d/; do
-            if [[ -f ${r}$_rule ]]; then
-                _found="${r}$_rule"
-                inst_simple "$_found" "$_target/${_found##*/}"
-                inst_rule_programs "$_found"
+        for r in '' ./; do
+            if [[ -f "${r}${rule}" ]]; then
+                found="${r}${rule}"
+                inst_simple "$found" "$target/${found##*/}"
+                inst_rule_programs "$found"
             fi
         done
-        [[ $_found ]] || dinfo "Skipping udev rule: $_rule"
+        [[ $found ]] || dinfo "Skipping udev rule: $rule"
+        found=
     done
 }
 
 # general purpose installation function
 # Same args as above.
 inst() {
-    local _x
-
     case $# in
         1) ;;
-        2) [[ ! $initdir && -d $2 ]] && export initdir=$2
-            [[ $initdir = $2 ]] && set $1;;
-        3) [[ -z $initdir ]] && export initdir=$2
-            set $1 $3;;
-        *) dfatal "inst only takes 1 or 2 or 3 arguments"
-            exit 1;;
+        2)
+            [[ ! "$initdir" && -d "$2" ]] && export initdir="$2"
+            [[ "$initdir" = "$2" ]] && set "$1"
+            ;;
+        3)
+            [[ -z "$initdir" ]] && export initdir="$2"
+            set "$1" "$3"
+            ;;
+        *)
+            dfatal "inst only takes 1 or 2 or 3 arguments"
+            exit 1
+            ;;
     esac
-    for _x in inst_symlink inst_script inst_binary inst_simple; do
-        $_x "$@" && return 0
+
+    local fun
+    for fun in inst_symlink inst_script inst_binary inst_simple; do
+        "$fun" "$@" && return 0
     done
+
+    dwarn "Failed to install '$1'"
     return 1
 }
 
@@ -937,95 +2775,99 @@ inst() {
 # inst_any -d /bin/foo /bin/bar /bin/baz
 #
 # Lets assume that /bin/baz exists, so it will be installed as /bin/foo in
-# initramfs.
+# initrd.
 inst_any() {
-    local to f
+    local dest file
 
-    [[ $1 = '-d' ]] && to="$2" && shift 2
+    [[ "${1:?}" = '-d' ]] && dest="${2:?}" && shift 2
 
-    for f in "$@"; do
-        if [[ -e $f ]]; then
-            [[ $to ]] && inst "$f" "$to" && return 0
-            inst "$f" && return 0
+    for file in "$@"; do
+        if [[ -e "$file" ]]; then
+            [[ -n "$dest" ]] && inst "$file" "$dest" && return 0
+            inst "$file" && return 0
         fi
     done
 
     return 1
 }
 
-# dracut_install [-o ] <file> [<file> ... ]
-# Install <file> to the initramfs image
+inst_recursive() {
+    local p item
+
+    for p in "$@"; do
+        # Make sure the source exists, as the process substitution below
+        # suppresses errors
+        stat "$p" >/dev/null || return 1
+
+        while read -r item; do
+            if [[ -d "$item" ]]; then
+                inst_dir "$item"
+            elif [[ -f "$item" ]]; then
+                inst_simple "$item"
+            fi
+        done < <(find "$p" 2>/dev/null)
+    done
+}
+
+# image_install [-o ] <file> [<file> ... ]
+# Install <file> to the test image
 # -o optionally install the <file> and don't fail, if it is not there
-dracut_install() {
-    local _optional=no
-    if [[ $1 = '-o' ]]; then
-        _optional=yes
+image_install() {
+    local optional=no
+    local prog="${1:?}"
+
+    if [[ "$prog" = '-o' ]]; then
+        optional=yes
         shift
     fi
-    while (($# > 0)); do
-        if ! inst "$1" ; then
-            if [[ $_optional = yes ]]; then
-                dinfo "Skipping program $1 as it cannot be found and is" \
+
+    for prog in "$@"; do
+        if ! inst "$prog" ; then
+            if get_bool "$optional"; then
+                dinfo "Skipping program $prog as it cannot be found and is" \
                     "flagged to be optional"
             else
-                dfatal "Failed to install $1"
+                dfatal "Failed to install $prog"
                 exit 1
             fi
         fi
-        shift
     done
 }
 
 # Install a single kernel module along with any firmware it may require.
 # $1 = full path to kernel module to install
 install_kmod_with_fw() {
+    local module="${1:?}"
     # no need to go further if the module is already installed
+    [[ -e "${initdir:?}/lib/modules/${KERNEL_VER:?}/${module##*"/lib/modules/$KERNEL_VER/"}" ]] && return 0
+    [[ -e "$initdir/.kernelmodseen/${module##*/}" ]] && return 0
 
-    [[ -e "${initdir}/lib/modules/$KERNEL_VER/${1##*/lib/modules/$KERNEL_VER/}" ]] \
-        && return 0
-
-    [[ -e "$initdir/.kernelmodseen/${1##*/}" ]] && return 0
-
-    if [[ $omit_drivers ]]; then
-        local _kmod=${1##*/}
-        _kmod=${_kmod%.ko}
-        _kmod=${_kmod/-/_}
-        if [[ "$_kmod" =~ $omit_drivers ]]; then
-            dinfo "Omitting driver $_kmod"
-            return 1
-        fi
-        if [[ "${1##*/lib/modules/$KERNEL_VER/}" =~ $omit_drivers ]]; then
-            dinfo "Omitting driver $_kmod"
-            return 1
-        fi
-    fi
+    [ -d "$initdir/.kernelmodseen" ] && : >"$initdir/.kernelmodseen/${module##*/}"
 
-    [ -d "$initdir/.kernelmodseen" ] && \
-        > "$initdir/.kernelmodseen/${1##*/}"
+    inst_simple "$module" "/lib/modules/$KERNEL_VER/${module##*"/lib/modules/$KERNEL_VER/"}" || return $?
 
-    inst_simple "$1" "/lib/modules/$KERNEL_VER/${1##*/lib/modules/$KERNEL_VER/}" \
-        || return $?
+    local modname="${module##*/}"
+    local fwdir found fw
+    modname="${modname%.ko*}"
 
-    local _modname=${1##*/} _fwdir _found _fw
-    _modname=${_modname%.ko*}
-    for _fw in $(modinfo -k $KERNEL_VER -F firmware $1 2>/dev/null); do
-        _found=''
-        for _fwdir in $fw_dir; do
-            if [[ -d $_fwdir && -f $_fwdir/$_fw ]]; then
-                inst_simple "$_fwdir/$_fw" "/lib/firmware/$_fw"
-                _found=yes
+    while read -r fw; do
+        found=
+        for fwdir in /lib/firmware/updates /lib/firmware; do
+            if [[ -d "$fwdir" && -f "$fwdir/$fw" ]]; then
+                inst_simple "$fwdir/$fw" "/lib/firmware/$fw"
+                found=yes
             fi
         done
-        if [[ $_found != yes ]]; then
-            if ! grep -qe "\<${_modname//-/_}\>" /proc/modules; then
-                dinfo "Possible missing firmware \"${_fw}\" for kernel module" \
-                    "\"${_modname}.ko\""
+        if ! get_bool "$found"; then
+            if ! grep -qe "\<${modname//-/_}\>" /proc/modules; then
+                dinfo "Possible missing firmware \"${fw}\" for kernel module" \
+                    "\"${modname}.ko\""
             else
-                dwarn "Possible missing firmware \"${_fw}\" for kernel module" \
-                    "\"${_modname}.ko\""
+                dwarn "Possible missing firmware \"${fw}\" for kernel module" \
+                    "\"${modname}.ko\""
             fi
         fi
-    done
+    done < <(modinfo -k "$KERNEL_VER" -F firmware "$module" 2>/dev/null)
     return 0
 }
 
@@ -1035,200 +2877,256 @@ install_kmod_with_fw() {
 #      It will be passed the full path to the found kernel module
 # $2 = module to get dependencies for
 # rest of args = arguments to modprobe
-# _fderr specifies FD passed from surrounding scope
 for_each_kmod_dep() {
-    local _func=$1 _kmod=$2 _cmd _modpath _options _found=0
+    local func="${1:?}"
+    local kmod="${2:?}"
+    local found=0
+    local cmd modpath
     shift 2
-    modprobe "$@" --ignore-install --show-depends $_kmod 2>&${_fderr} | (
-        while read _cmd _modpath _options; do
-            [[ $_cmd = insmod ]] || continue
-            $_func ${_modpath} || exit $?
-            _found=1
-        done
-        [[ $_found -eq 0 ]] && exit 1
-        exit 0
-    )
-}
-
-# filter kernel modules to install certain modules that meet specific
-# requirements.
-# $1 = search only in subdirectory of /kernel/$1
-# $2 = function to call with module name to filter.
-#      This function will be passed the full path to the module to test.
-# The behavior of this function can vary depending on whether $hostonly is set.
-# If it is, we will only look at modules that are already in memory.
-# If it is not, we will look at all kernel modules
-# This function returns the full filenames of modules that match $1
-filter_kernel_modules_by_path () (
-    local _modname _filtercmd
-    if ! [[ $hostonly ]]; then
-        _filtercmd='find "$KERNEL_MODS/kernel/$1" "$KERNEL_MODS/extra"'
-        _filtercmd+=' "$KERNEL_MODS/weak-updates" -name "*.ko" -o -name "*.ko.gz"'
-        _filtercmd+=' -o -name "*.ko.xz"'
-        _filtercmd+=' 2>/dev/null'
-    else
-        _filtercmd='cut -d " " -f 1 </proc/modules|xargs modinfo -F filename '
-        _filtercmd+='-k $KERNEL_VER 2>/dev/null'
-    fi
-    for _modname in $(eval $_filtercmd); do
-        case $_modname in
-            *.ko) "$2" "$_modname" && echo "$_modname";;
-            *.ko.gz) gzip -dc "$_modname" > $initdir/$$.ko
-                $2 $initdir/$$.ko && echo "$_modname"
-                rm -f $initdir/$$.ko
-                ;;
-            *.ko.xz) xz -dc "$_modname" > $initdir/$$.ko
-                $2 $initdir/$$.ko && echo "$_modname"
-                rm -f $initdir/$$.ko
-                ;;
-        esac
-    done
-)
-find_kernel_modules_by_path () (
-    if ! [[ $hostonly ]]; then
-        find "$KERNEL_MODS/kernel/$1" "$KERNEL_MODS/extra" "$KERNEL_MODS/weak-updates" \
-          -name "*.ko" -o -name "*.ko.gz" -o -name "*.ko.xz" 2>/dev/null
-    else
-        cut -d " " -f 1 </proc/modules \
-        | xargs modinfo -F filename -k $KERNEL_VER 2>/dev/null
-    fi
-)
 
-filter_kernel_modules () {
-    filter_kernel_modules_by_path  drivers  "$1"
-}
+    while read -r cmd modpath _; do
+        [[ "$cmd" = insmod ]] || continue
+        "$func" "$modpath" || return $?
+        found=1
+    done < <(modprobe "$@" --ignore-install --show-depends "$kmod")
 
-find_kernel_modules () {
-    find_kernel_modules_by_path  drivers
+    ! get_bool "$found" && return 1
+    return 0
 }
 
 # instmods [-c] <kernel module> [<kernel module> ... ]
 # instmods [-c] <kernel subsystem>
 # install kernel modules along with all their dependencies.
 # <kernel subsystem> can be e.g. "=block" or "=drivers/usb/storage"
+# FIXME(?): dracutdevs/dracut@f4e38c0da8d6bf3764c1ad753d9d52aef63050e5
 instmods() {
-    [[ $no_kernel = yes ]] && return
-    # called [sub]functions inherit _fderr
-    local _fderr=9
-    local _check=no
-    if [[ $1 = '-c' ]]; then
-        _check=yes
+    local check=no
+    if [[ $# -ge 0 && "$1" = '-c' ]]; then
+        check=yes
         shift
     fi
 
-    function inst1mod() {
-        local _ret=0 _mod="$1"
-        case $_mod in
+    inst1mod() {
+        local mod="${1:?}"
+        local ret=0
+        local mod_dir="/lib/modules/${KERNEL_VER:?}/"
+
+        case "$mod" in
             =*)
-                if [ -f $KERNEL_MODS/modules.${_mod#=} ]; then
-                    ( [[ "$_mpargs" ]] && echo $_mpargs
-                      cat "${KERNEL_MODS}/modules.${_mod#=}" ) \
-                    | instmods
+                if [ -f "${mod_dir}/modules.${mod#=}" ]; then
+                    (
+                        [[ "$mpargs" ]] && echo "$mpargs"
+                        cat "${mod_dir}/modules.${mod#=}"
+                    ) | instmods
                 else
-                    ( [[ "$_mpargs" ]] && echo $_mpargs
-                      find "$KERNEL_MODS" -path "*/${_mod#=}/*" -printf '%f\n' ) \
-                    | instmods
+                    (
+                        [[ "$mpargs" ]] && echo "$mpargs"
+                        find "$mod_dir" -path "*/${mod#=}/*" -name "*.ko*" -type f -printf '%f\n'
+                    ) | instmods
                 fi
                 ;;
-            --*) _mpargs+=" $_mod" ;;
-            i2o_scsi) return ;; # Do not load this diagnostic-only module
+            --*)
+                mpargs+=" $mod"
+                ;;
+            i2o_scsi)
+                # Do not load this diagnostic-only module
+                return
+                ;;
             *)
-                _mod=${_mod##*/}
+                mod=${mod##*/}
                 # if we are already installed, skip this module and go on
                 # to the next one.
-                [[ -f "$initdir/.kernelmodseen/${_mod%.ko}.ko" ]] && return
-
-                if [[ $omit_drivers ]] && [[ "$1" =~ $omit_drivers ]]; then
-                    dinfo "Omitting driver ${_mod##$KERNEL_MODS}"
-                    return
-                fi
-                # If we are building a host-specific initramfs and this
-                # module is not already loaded, move on to the next one.
-                [[ $hostonly ]] && ! grep -qe "\<${_mod//-/_}\>" /proc/modules \
-                    && ! echo $add_drivers | grep -qe "\<${_mod}\>" \
-                    && return
+                [[ -f "${initdir:?}/.kernelmodseen/${mod%.ko}.ko" ]] && return
 
                 # We use '-d' option in modprobe only if modules prefix path
                 # differs from default '/'.  This allows us to use Dracut with
                 # old version of modprobe which doesn't have '-d' option.
-                local _moddirname=${KERNEL_MODS%%/lib/modules/*}
-                [[ -n ${_moddirname} ]] && _moddirname="-d ${_moddirname}/"
+                local mod_dirname=${mod_dir%%/lib/modules/*}
+                [[ -n ${mod_dirname} ]] && mod_dirname="-d ${mod_dirname}/"
 
                 # ok, load the module, all its dependencies, and any firmware
                 # it may require
-                for_each_kmod_dep install_kmod_with_fw $_mod \
-                    --set-version $KERNEL_VER ${_moddirname} $_mpargs
-                ((_ret+=$?))
+                for_each_kmod_dep install_kmod_with_fw "$mod" \
+                    --set-version "$KERNEL_VER" \
+                    ${mod_dirname:+"$mod_dirname"} \
+                    ${mpargs:+"$mpargs"}
+                ((ret+=$?))
                 ;;
         esac
-        return $_ret
+        return "$ret"
     }
 
-    function instmods_1() {
-        local _mod _mpargs
-        if (($# == 0)); then  # filenames from stdin
-            while read _mod; do
-                inst1mod "${_mod%.ko*}" || {
-                    if [ "$_check" = "yes" ]; then
-                        dfatal "Failed to install $_mod"
-                        return 1
-                    fi
-                }
-            done
-        fi
-        while (($# > 0)); do  # filenames as arguments
-            inst1mod ${1%.ko*} || {
-                if [ "$_check" = "yes" ]; then
-                    dfatal "Failed to install $1"
-                    return 1
-                fi
-            }
-            shift
-        done
-        return 0
-    }
+    local mod mpargs
 
-    local _ret _filter_not_found='FATAL: Module .* not found.'
-    set -o pipefail
-    # Capture all stderr from modprobe to _fderr. We could use {var}>...
-    # redirections, but that would make dracut require bash4 at least.
-    eval "( instmods_1 \"\$@\" ) ${_fderr}>&1" \
-    | while read line; do [[ "$line" =~ $_filter_not_found ]] && echo $line || echo $line >&2 ;done | derror
-    _ret=$?
-    set +o pipefail
-    return $_ret
-}
-
-# inst_libdir_file [-n <pattern>] <file> [<file>...]
-# Install a <file> located on a lib directory to the initramfs image
-# -n <pattern> install non-matching files
-inst_libdir_file() {
-    if [[ "$1" == "-n" ]]; then
-        local _pattern=$1
-        shift 2
-        for _dir in $libdirs; do
-            for _i in "$@"; do
-                for _f in "$_dir"/$_i; do
-                    [[ "$_i" =~ $_pattern ]] || continue
-                    [[ -e "$_i" ]] && dracut_install "$_i"
-                done
-            done
-        done
-    else
-        for _dir in $libdirs; do
-            for _i in "$@"; do
-                for _f in "$_dir"/$_i; do
-                    [[ -e "$_f" ]] && dracut_install "$_f"
-                done
-            done
+    if [[ $# -eq 0 ]]; then  # filenames from stdin
+        while read -r mod; do
+            if ! inst1mod "${mod%.ko*}" && [ "$check" = "yes" ]; then
+                dfatal "Failed to install $mod"
+                return 1
+            fi
         done
     fi
+
+    for mod in "$@"; do # filenames as arguments
+        if ! inst1mod "${mod%.ko*}" && [ "$check" = "yes" ]; then
+            dfatal "Failed to install $mod"
+            return 1
+        fi
+    done
+
+    return 0
+}
+
+_umount_dir() {
+    local mountpoint="${1:?}"
+    if mountpoint -q "$mountpoint"; then
+        ddebug "umount $mountpoint"
+        umount "$mountpoint"
+    fi
+}
+
+# can be overridden in specific test
+test_setup_cleanup() {
+    cleanup_initdir
+}
+
+_test_cleanup() {
+    # (post-test) cleanup should always ignore failure and cleanup as much as possible
+    (
+        set +e
+        [[ -n "$initdir" ]] && _umount_dir "$initdir"
+        [[ -n "$IMAGE_PUBLIC" ]] && rm -vf "$IMAGE_PUBLIC"
+        # If multiple setups/cleans are ran in parallel, this can cause a race
+        if [[ -n "$IMAGESTATEDIR" && $TEST_PARALLELIZE -ne 1 ]]; then
+            rm -vf "${IMAGESTATEDIR}/default.img"
+        fi
+        [[ -n "$TESTDIR" ]] && rm -vfr "$TESTDIR"
+        [[ -n "$STATEFILE" ]] && rm -vf "$STATEFILE"
+    ) || :
+}
+
+# can be overridden in specific test
+test_cleanup() {
+    _test_cleanup
+}
+
+test_cleanup_again() {
+    [ -n "$TESTDIR" ] || return
+    rm -rf "$TESTDIR/unprivileged-nspawn-root"
+    [[ -n "$initdir" ]] && _umount_dir "$initdir"
 }
 
-check_nspawn() {
-    [[ -d /run/systemd/system ]]
+test_create_image() {
+    create_empty_image_rootdir
+
+    # Create what will eventually be our root filesystem onto an overlay
+    (
+        LOG_LEVEL=5
+        setup_basic_environment
+    )
+}
+
+test_setup() {
+    if get_bool "${TEST_REQUIRE_INSTALL_TESTS:?}" && \
+            command -v meson >/dev/null && \
+            [[ "$(meson configure "${BUILD_DIR:?}" | grep install-tests | awk '{ print $2 }')" != "true" ]]; then
+        dfatal "$BUILD_DIR needs to be built with -Dinstall-tests=true"
+        exit 1
+    fi
+
+    if [ -e "${IMAGE_PRIVATE:?}" ]; then
+        echo "Reusing existing image $IMAGE_PRIVATE → $(realpath "$IMAGE_PRIVATE")"
+        mount_initdir
+    else
+        if [ ! -e "${IMAGE_PUBLIC:?}" ]; then
+            # default.img is the base that every test uses and optionally appends to
+            if [ ! -e "${IMAGESTATEDIR:?}/default.img" ] || [ -n "${TEST_FORCE_NEWIMAGE:=}" ]; then
+                # Create the backing public image, but then completely unmount
+                # it and drop the loopback device responsible for it, since we're
+                # going to symlink/copy the image and mount it again from
+                # elsewhere.
+                local image_old="${IMAGE_PUBLIC}"
+                if [ -z "${TEST_FORCE_NEWIMAGE}" ]; then
+                    IMAGE_PUBLIC="${IMAGESTATEDIR}/default.img"
+                fi
+                test_create_image
+                test_setup_cleanup
+                umount_loopback
+                cleanup_loopdev
+                IMAGE_PUBLIC="${image_old}"
+            fi
+            if [ "${IMAGE_NAME:?}" != "default" ] && ! get_bool "${TEST_FORCE_NEWIMAGE}"; then
+                cp -v "$(realpath "${IMAGESTATEDIR}/default.img")" "$IMAGE_PUBLIC"
+            fi
+        fi
+
+        local hook_defined
+        declare -f -F test_append_files >/dev/null && hook_defined=yes || hook_defined=no
+
+        echo "Reusing existing cached image $IMAGE_PUBLIC → $(realpath "$IMAGE_PUBLIC")"
+        if get_bool "$TEST_PARALLELIZE" || get_bool "$hook_defined"; then
+            cp -v -- "$(realpath "$IMAGE_PUBLIC")" "$IMAGE_PRIVATE"
+        else
+            ln -sv -- "$(realpath "$IMAGE_PUBLIC")" "$IMAGE_PRIVATE"
+        fi
+
+        mount_initdir
+
+        if get_bool "${TEST_SUPPORTING_SERVICES_SHOULD_BE_MASKED}"; then
+            dinfo "Masking supporting services"
+            mask_supporting_services
+        fi
+
+        # Send stdout/stderr of testsuite-*.service units to both journal and
+        # console to make debugging in CIs easier
+        # Note: we can't use a dropin for `testsuite-.service`, since that also
+        #       overrides 'sub-units' of some tests that already use a specific
+        #       value for Standard(Output|Error)=
+        #       (e.g. test/units/testsuite-66-deviceisolation.service)
+        if ! get_bool "$INTERACTIVE_DEBUG"; then
+            local dropin_dir="${initdir:?}/etc/systemd/system/testsuite-${TESTID:?}.service.d"
+            mkdir -p "$dropin_dir"
+            printf '[Service]\nStandardOutput=journal+console\nStandardError=journal+console' >"$dropin_dir/99-stdout.conf"
+        fi
+
+        if get_bool "$hook_defined"; then
+            test_append_files "${initdir:?}"
+        fi
+    fi
+
+    setup_nspawn_root
 }
 
+test_run() {
+    local test_id="${1:?}"
+    mount_initdir
+
+    if ! get_bool "${TEST_NO_QEMU:=}"; then
+        if run_qemu "$test_id"; then
+            check_result_qemu || { echo "qemu test failed"; return 1; }
+        else
+            dwarn "can't run qemu, skipping"
+        fi
+    fi
+    if ! get_bool "${TEST_NO_NSPAWN:=}"; then
+        mount_initdir
+        if run_nspawn "${initdir:?}" "$test_id"; then
+            check_result_nspawn "$initdir" || { echo "nspawn-root test failed"; return 1; }
+        else
+            dwarn "can't run systemd-nspawn, skipping"
+        fi
+
+        if get_bool "${RUN_IN_UNPRIVILEGED_CONTAINER:=}"; then
+            dir="$TESTDIR/unprivileged-nspawn-root"
+            if NSPAWN_ARGUMENTS="-U --private-network ${NSPAWN_ARGUMENTS:-}" run_nspawn "$dir" "$test_id"; then
+                check_result_nspawn "$dir" || { echo "unprivileged-nspawn-root test failed"; return 1; }
+            else
+                dwarn "can't run systemd-nspawn, skipping"
+            fi
+        fi
+    fi
+    return 0
+}
 
 do_test() {
     if [[ $UID != "0" ]]; then
@@ -1236,60 +3134,92 @@ do_test() {
         exit 0
     fi
 
-# Detect lib paths
-    [[ $libdir ]] || for libdir in /lib64 /lib; do
+    if get_bool "${TEST_NO_QEMU:=}" && get_bool "${TEST_NO_NSPAWN:=}"; then
+        echo "TEST: $TEST_DESCRIPTION [SKIPPED]: both qemu and nspawn disabled" >&2
+        exit 0
+    fi
+
+    if get_bool "${TEST_QEMU_ONLY:=}" && ! get_bool "$TEST_NO_NSPAWN"; then
+        echo "TEST: $TEST_DESCRIPTION [SKIPPED]: qemu-only tests requested" >&2
+        exit 0
+    fi
+
+    if get_bool "${TEST_PREFER_NSPAWN:=}" && ! get_bool "$TEST_NO_NSPAWN"; then
+        TEST_NO_QEMU=1
+    fi
+
+    # Detect lib paths
+    [[ "$libdir" ]] || for libdir in /lib64 /lib; do
         [[ -d $libdir ]] && libdirs+=" $libdir" && break
     done
 
-    [[ $usrlibdir ]] || for usrlibdir in /usr/lib64 /usr/lib; do
+    [[ "$usrlibdir" ]] || for usrlibdir in /usr/lib64 /usr/lib; do
         [[ -d $usrlibdir ]] && libdirs+=" $usrlibdir" && break
     done
 
+    mkdir -p "$STATEDIR"
+
     import_testdir
     import_initdir
 
+    if [ -n "${SUDO_USER}" ]; then
+        ddebug "Making ${TESTDIR:?} readable for ${SUDO_USER} (acquired from sudo)"
+        setfacl -m "user:${SUDO_USER:?}:r-X" "${TESTDIR:?}"
+    fi
+
+    testname="$(basename "$PWD")"
+
     while (($# > 0)); do
         case $1 in
             --run)
-                echo "TEST RUN: $TEST_DESCRIPTION"
-                test_run
+                echo "${testname} RUN: $TEST_DESCRIPTION"
+                test_run "$TESTID"
                 ret=$?
                 if [ $ret -eq 0 ]; then
-                    echo "TEST RUN: $TEST_DESCRIPTION [OK]"
+                    echo "${testname} RUN: $TEST_DESCRIPTION [OK]"
                 else
-                    echo "TEST RUN: $TEST_DESCRIPTION [FAILED]"
+                    echo "${testname} RUN: $TEST_DESCRIPTION [FAILED]"
                 fi
-                exit $ret;;
+                exit $ret
+                ;;
             --setup)
-                echo "TEST SETUP: $TEST_DESCRIPTION"
+                echo "${testname} SETUP: $TEST_DESCRIPTION"
                 test_setup
-                exit $?;;
+                test_setup_cleanup
+                ;;
             --clean)
-                echo "TEST CLEANUP: $TEST_DESCRIPTION"
+                echo "${testname} CLEANUP: $TEST_DESCRIPTION"
                 test_cleanup
-                rm -fr "$TESTDIR"
-                rm -f .testdir
-                exit $?;;
+                ;;
+            --clean-again)
+                echo "${testname} CLEANUP AGAIN: $TEST_DESCRIPTION"
+                test_cleanup_again
+                ;;
             --all)
-                echo -n "TEST: $TEST_DESCRIPTION ";
-                (
-                    test_setup && test_run
-                    ret=$?
-                    test_cleanup
-                    rm -fr "$TESTDIR"
-                    rm -f .testdir
-                    exit $ret
-                ) </dev/null >test.log 2>&1
-                ret=$?
+                ret=0
+                echo -n "${testname}: $TEST_DESCRIPTION "
+                # Do not use a subshell, otherwise cleanup variables (LOOPDEV) will be lost
+                # and loop devices will leak
+                test_setup </dev/null >"$TESTLOG" 2>&1 || ret=$?
                 if [ $ret -eq 0 ]; then
-                    rm test.log
+                    test_setup_cleanup </dev/null >>"$TESTLOG" 2>&1 || ret=$?
+                fi
+                if [ $ret -eq 0 ]; then
+                    test_run "$TESTID" </dev/null >>"$TESTLOG" 2>&1 || ret=$?
+                fi
+                test_cleanup
+                if [ $ret -eq 0 ]; then
+                    rm "$TESTLOG"
                     echo "[OK]"
                 else
                     echo "[FAILED]"
-                    echo "see $(pwd)/test.log"
+                    echo "see $TESTLOG"
                 fi
-                exit $ret;;
-            *) break ;;
+                exit $ret
+                ;;
+            *)
+                break
+                ;;
         esac
         shift
     done