#!/usr/bin/env bash
# SPDX-License-Identifier: LGPL-2.1-or-later
# vi: ts=4 sw=4 tw=0 et:
#
# TODO:
#   * SW raid (mdadm)
#   * MD (mdadm) -> dm-crypt -> LVM
#   * iSCSI -> dm-crypt -> LVM
set -e

TEST_DESCRIPTION="systemd-udev storage tests"
IMAGE_NAME="default"
TEST_NO_NSPAWN=1
# Save only journals of failing test cases by default (to conserve space)
TEST_SAVE_JOURNAL="${TEST_SAVE_JOURNAL:-fail}"
QEMU_TIMEOUT="${QEMU_TIMEOUT:-600}"

# shellcheck source=test/test-functions
. "${TEST_BASE_DIR:?}/test-functions"

USER_QEMU_OPTIONS="${QEMU_OPTIONS:-}"
USER_KERNEL_APPEND="${KERNEL_APPEND:-}"

if ! get_bool "$QEMU_KVM"; then
    echo "This test requires KVM, skipping..."
    exit 0
fi

_host_has_feature() {(
    set -e

    case "${1:?}" in
        btrfs)
            modprobe -nv btrfs && command -v mkfs.btrfs && command -v btrfs || return $?
            ;;
        iscsi)
            # Client/initiator (Open-iSCSI)
            command -v iscsiadm && command -v iscsid || return $?
            # Server/target (TGT)
            command -v tgtadm && command -v tgtd || return $?
            ;;
        lvm)
            command -v lvm || return $?
            ;;
        multipath)
            command -v multipath && command -v multipathd || return $?
            ;;
        *)
            echo >&2 "ERROR: Unknown feature '$1'"
            # Make this a hard error to distinguish an invalid feature from
            # a missing feature
            exit 1
    esac
)}

test_append_files() {(
    local feature
    # An associative array of requested (but optional) features and their
    # respective "handlers" from test/test-functions
    #
    # Note: we install cryptsetup unconditionally, hence it's not explicitly
    # checked for here
    local -A features=(
        [btrfs]=install_btrfs
        [iscsi]=install_iscsi
        [lvm]=install_lvm
        [multipath]=install_multipath
    )

    instmods "=block" "=md" "=nvme" "=scsi"
    install_dmevent
    image_install lsblk swapoff swapon wc wipefs

    # Install the optional features if the host has the respective tooling
    for feature in "${!features[@]}"; do
        if _host_has_feature "$feature"; then
            "${features[$feature]}"
        fi
    done

    generate_module_dependencies

    for i in {0..127}; do
        dd if=/dev/zero of="${TESTDIR:?}/disk$i.img" bs=1M count=1
        echo "device$i" >"${TESTDIR:?}/disk$i.img"
    done
)}

_image_cleanup() {
    mount_initdir
    # Clean up certain "problematic" files which may be left over by failing tests
    : >"${initdir:?}/etc/fstab"
    : >"${initdir:?}/etc/crypttab"
}

test_run_one() {
    local test_id="${1:?}"

    if run_qemu "$test_id"; then
        check_result_qemu || { echo "QEMU test failed"; return 1; }
    fi

    return 0
}

test_run() {
    local test_id="${1:?}"
    local passed=()
    local failed=()
    local skipped=()
    local ec state

    mount_initdir

    if get_bool "${TEST_NO_QEMU:=}" || ! find_qemu_bin; then
        dwarn "can't run QEMU, skipping"
        return 0
    fi

    # Execute each currently defined function starting with "testcase_"
    for testcase in "${TESTCASES[@]}"; do
        _image_cleanup
        echo "------ $testcase: BEGIN ------"
        # Note for my future frustrated self: `fun && xxx` (as well as ||, if, while,
        # until, etc.) _DISABLES_ the `set -e` behavior in _ALL_ nested function
        # calls made from `fun()`, i.e. the function _CONTINUES_ even when a called
        # command returned non-zero EC. That may unexpectedly hide failing commands
        # if not handled properly. See: bash(1) man page, `set -e` section.
        #
        # So, be careful when adding clean up snippets in the testcase_*() functions -
        # if the `test_run_one()` function isn't the last command, you have propagate
        # the exit code correctly (e.g. `test_run_one() || return $?`, see below).
        ec=0
        "$testcase" "$test_id" || ec=$?
        case $ec in
            0)
                passed+=("$testcase")
                state="PASS"
                ;;
            77)
                skipped+=("$testcase")
                state="SKIP"
                ;;
            *)
                failed+=("$testcase")
                state="FAIL"
        esac
        echo "------ $testcase: END ($state) ------"
    done

    echo "Passed tests: ${#passed[@]}"
    printf "    * %s\n" "${passed[@]}"
    echo "Skipped tests: ${#skipped[@]}"
    printf "    * %s\n" "${skipped[@]}"
    echo "Failed tests: ${#failed[@]}"
    printf "    * %s\n" "${failed[@]}"

    [[ ${#failed[@]} -eq 0 ]] || return 1

    return 0
}

testcase_megasas2_basic() {
    if ! "${QEMU_BIN:?}" -device help | grep 'name "megasas-gen2"'; then
        echo "megasas-gen2 device driver is not available, skipping test..."
        return 77
    fi

    local i
    local qemu_opts=(
        "-device megasas-gen2,id=scsi0"
        "-device megasas-gen2,id=scsi1"
        "-device megasas-gen2,id=scsi2"
        "-device megasas-gen2,id=scsi3"
    )

    for i in {0..127}; do
        # Add 128 drives, 32 per bus
        qemu_opts+=(
            "-device scsi-hd,drive=drive$i,bus=scsi$((i / 32)).0,channel=0,scsi-id=$((i % 32)),lun=0"
            "-drive format=raw,cache=unsafe,file=${TESTDIR:?}/disk$i.img,if=none,id=drive$i"
        )
    done

    KERNEL_APPEND="systemd.setenv=TEST_FUNCTION_NAME=${FUNCNAME[0]} ${USER_KERNEL_APPEND:-}"
    QEMU_OPTIONS="${qemu_opts[*]} ${USER_QEMU_OPTIONS:-}"
    test_run_one "${1:?}"
}

testcase_nvme_basic() {
    if ! "${QEMU_BIN:?}" -device help | grep 'name "nvme"'; then
        echo "nvme device driver is not available, skipping test..."
        return 77
    fi

    local i
    local qemu_opts=()

    for i in {0..27}; do
        qemu_opts+=(
            "-device nvme,drive=nvme$i,serial=deadbeef$i,num_queues=8"
            "-drive format=raw,cache=unsafe,file=${TESTDIR:?}/disk$i.img,if=none,id=nvme$i"
        )
    done

    KERNEL_APPEND="systemd.setenv=TEST_FUNCTION_NAME=${FUNCNAME[0]} ${USER_KERNEL_APPEND:-}"
    QEMU_OPTIONS="${qemu_opts[*]} ${USER_QEMU_OPTIONS:-}"
    test_run_one "${1:?}"
}

# Test for issue https://github.com/systemd/systemd/issues/20212
testcase_virtio_scsi_identically_named_partitions() {
    if ! "${QEMU_BIN:?}" -device help | grep 'name "virtio-scsi-pci"'; then
        echo "virtio-scsi-pci device driver is not available, skipping test..."
        return 77
    fi

    # Create 16 disks, with 8 partitions per disk (all identically named)
    # and attach them to a virtio-scsi controller
    local qemu_opts=("-device virtio-scsi-pci,id=scsi0,num_queues=4")
    local diskpath="${TESTDIR:?}/namedpart0.img"
    local i lodev qemu_timeout

    dd if=/dev/zero of="$diskpath" bs=1M count=18
    lodev="$(losetup --show -f -P "$diskpath")"
    sfdisk "${lodev:?}" <<EOF
label: gpt

name="Hello world", size=2M
name="Hello world", size=2M
name="Hello world", size=2M
name="Hello world", size=2M
name="Hello world", size=2M
name="Hello world", size=2M
name="Hello world", size=2M
name="Hello world", size=2M
EOF
    losetup -d "$lodev"

    for i in {0..15}; do
        diskpath="${TESTDIR:?}/namedpart$i.img"
        if [[ $i -gt 0 ]]; then
            cp -uv "${TESTDIR:?}/namedpart0.img" "$diskpath"
        fi

        qemu_opts+=(
            "-device scsi-hd,drive=drive$i,bus=scsi0.0,channel=0,scsi-id=0,lun=$i"
            "-drive format=raw,cache=unsafe,file=$diskpath,if=none,id=drive$i"
        )
    done

    # Bump the timeout when collecting test coverage, since the test is a bit
    # slower in that case
    is_built_with_coverage && qemu_timeout=120 || qemu_timeout=60

    KERNEL_APPEND="systemd.setenv=TEST_FUNCTION_NAME=${FUNCNAME[0]} ${USER_KERNEL_APPEND:-}"
    # Limit the number of VCPUs and set a timeout to make sure we trigger the issue
    QEMU_OPTIONS="${qemu_opts[*]} ${USER_QEMU_OPTIONS:-}"
    QEMU_SMP=1 QEMU_TIMEOUT=$qemu_timeout test_run_one "${1:?}" || return $?

    rm -f "${TESTDIR:?}"/namedpart*.img
}

testcase_multipath_basic_failover() {
    if ! _host_has_feature "multipath"; then
        echo "Missing multipath tools, skipping the test..."
        return 77
    fi

    local qemu_opts=("-device virtio-scsi-pci,id=scsi")
    local partdisk="${TESTDIR:?}/multipathpartitioned.img"
    local image lodev nback ndisk wwn

    dd if=/dev/zero of="$partdisk" bs=1M count=16
    lodev="$(losetup --show -f -P "$partdisk")"
    sfdisk "${lodev:?}" <<EOF
label: gpt

name="first_partition", size=5M
uuid="deadbeef-dead-dead-beef-000000000000", name="failover_part", size=5M
EOF
    udevadm settle
    mkfs.ext4 -U "deadbeef-dead-dead-beef-111111111111" -L "failover_vol" "${lodev}p2"
    losetup -d "$lodev"

    # Add 64 multipath devices, each backed by 4 paths
    for ndisk in {0..63}; do
        wwn="0xDEADDEADBEEF$(printf "%.4d" "$ndisk")"
        # Use a partitioned disk for the first device to test failover
        [[ $ndisk -eq 0 ]] && image="$partdisk" || image="${TESTDIR:?}/disk$ndisk.img"

        for nback in {0..3}; do
            qemu_opts+=(
                "-device scsi-hd,drive=drive${ndisk}x${nback},serial=MPIO$ndisk,wwn=$wwn"
                "-drive format=raw,cache=unsafe,file=$image,file.locking=off,if=none,id=drive${ndisk}x${nback}"
            )
        done
    done

    KERNEL_APPEND="systemd.setenv=TEST_FUNCTION_NAME=${FUNCNAME[0]} ${USER_KERNEL_APPEND:-}"
    QEMU_OPTIONS="${qemu_opts[*]} ${USER_QEMU_OPTIONS:-}"
    test_run_one "${1:?}" || return $?

    rm -f "$partdisk"
}

# Test case for issue https://github.com/systemd/systemd/issues/19946
testcase_simultaneous_events() {
    local qemu_opts=("-device virtio-scsi-pci,id=scsi")
    local partdisk="${TESTDIR:?}/simultaneousevents.img"

    dd if=/dev/zero of="$partdisk" bs=1M count=110
    qemu_opts+=(
        "-device scsi-hd,drive=drive1,serial=deadbeeftest"
        "-drive format=raw,cache=unsafe,file=$partdisk,if=none,id=drive1"
    )

    KERNEL_APPEND="systemd.setenv=TEST_FUNCTION_NAME=${FUNCNAME[0]} ${USER_KERNEL_APPEND:-}"
    QEMU_OPTIONS="${qemu_opts[*]} ${USER_QEMU_OPTIONS:-}"
    test_run_one "${1:?}" || return $?

    rm -f "$partdisk"
}

testcase_lvm_basic() {
    if ! _host_has_feature "lvm"; then
        echo "Missing lvm tools, skipping the test..."
        return 77
    fi

    local qemu_opts=("-device ahci,id=ahci0")
    local diskpath i

    # Attach 4 SATA disks to the VM (and set their model and serial fields
    # to something predictable, so we can refer to them later)
    for i in {0..3}; do
        diskpath="${TESTDIR:?}/lvmbasic${i}.img"
        dd if=/dev/zero of="$diskpath" bs=1M count=32
        qemu_opts+=(
            "-device ide-hd,bus=ahci0.$i,drive=drive$i,model=foobar,serial=deadbeeflvm$i"
            "-drive format=raw,cache=unsafe,file=$diskpath,if=none,id=drive$i"
        )
    done

    KERNEL_APPEND="systemd.setenv=TEST_FUNCTION_NAME=${FUNCNAME[0]} ${USER_KERNEL_APPEND:-}"
    QEMU_OPTIONS="${qemu_opts[*]} ${USER_QEMU_OPTIONS:-}"
    test_run_one "${1:?}" || return $?

    rm -f "${TESTDIR:?}"/lvmbasic*.img
}

testcase_btrfs_basic() {
    if ! _host_has_feature "btrfs"; then
        echo "Missing btrfs tools/modules, skipping the test..."
        return 77
    fi

    local qemu_opts=("-device ahci,id=ahci0")
    local diskpath i size

    for i in {0..3}; do
        diskpath="${TESTDIR:?}/btrfsbasic${i}.img"
        # Make the first disk larger for multi-partition tests
        [[ $i -eq 0 ]] && size=350 || size=128

        dd if=/dev/zero of="$diskpath" bs=1M count="$size"
        qemu_opts+=(
            "-device ide-hd,bus=ahci0.$i,drive=drive$i,model=foobar,serial=deadbeefbtrfs$i"
            "-drive format=raw,cache=unsafe,file=$diskpath,if=none,id=drive$i"
        )
    done

    KERNEL_APPEND="systemd.setenv=TEST_FUNCTION_NAME=${FUNCNAME[0]} ${USER_KERNEL_APPEND:-}"
    QEMU_OPTIONS="${qemu_opts[*]} ${USER_QEMU_OPTIONS:-}"
    test_run_one "${1:?}" || return $?

    rm -f "${TESTDIR:?}"/btrfsbasic*.img
}

testcase_iscsi_lvm() {
    if ! _host_has_feature "iscsi" || ! _host_has_feature "lvm"; then
        echo "Missing iSCSI client/server tools (Open-iSCSI/TGT) or LVM utilities, skipping the test..."
        return 77
    fi

    local qemu_opts=("-device ahci,id=ahci0")
    local diskpath i size

    for i in {0..3}; do
        diskpath="${TESTDIR:?}/iscsibasic${i}.img"
        # Make the first disk larger for multi-partition tests
        [[ $i -eq 0 ]] && size=150 || size=64
        # Make the first disk larger for multi-partition tests

        dd if=/dev/zero of="$diskpath" bs=1M count="$size"
        qemu_opts+=(
            "-device ide-hd,bus=ahci0.$i,drive=drive$i,model=foobar,serial=deadbeefiscsi$i"
            "-drive format=raw,cache=unsafe,file=$diskpath,if=none,id=drive$i"
        )
    done

    KERNEL_APPEND="systemd.setenv=TEST_FUNCTION_NAME=${FUNCNAME[0]} ${USER_KERNEL_APPEND:-}"
    QEMU_OPTIONS="${qemu_opts[*]} ${USER_QEMU_OPTIONS:-}"
    test_run_one "${1:?}" || return $?

    rm -f "${TESTDIR:?}"/iscsibasic*.img
}

testcase_long_sysfs_path() {
    local brid
    local testdisk="${TESTDIR:?}/longsysfspath.img"
    local qemu_opts=(
        "-drive if=none,id=drive0,format=raw,cache=unsafe,file=$testdisk"
        "-device pci-bridge,id=pci_bridge0,bus=pci.0,chassis_nr=64"
    )

    dd if=/dev/zero of="$testdisk" bs=1M count=64
    lodev="$(losetup --show -f -P "$testdisk")"
    sfdisk "${lodev:?}" <<EOF
label: gpt

name="test_swap", size=32M
uuid="deadbeef-dead-dead-beef-000000000000", name="test_part", size=5M
EOF
    udevadm settle
    mkswap -U "deadbeef-dead-dead-beef-111111111111" -L "swap_vol" "${lodev}p1"
    mkfs.ext4 -U "deadbeef-dead-dead-beef-222222222222" -L "data_vol" "${lodev}p2"
    losetup -d "$lodev"

    # Create 25 additional PCI bridges, each one connected to the previous one
    # (basically a really long extension cable), and attach a virtio drive to
    # the last one. This should force udev into attempting to create a device
    # unit with a _really_ long name.
    for brid in {1..25}; do
        qemu_opts+=("-device pci-bridge,id=pci_bridge$brid,bus=pci_bridge$((brid-1)),chassis_nr=$((64+brid))")
    done

    qemu_opts+=("-device virtio-blk-pci,drive=drive0,scsi=off,bus=pci_bridge$brid")

    KERNEL_APPEND="systemd.setenv=TEST_FUNCTION_NAME=${FUNCNAME[0]} ${USER_KERNEL_APPEND:-}"
    QEMU_OPTIONS="${qemu_opts[*]} ${USER_QEMU_OPTIONS:-}"
    test_run_one "${1:?}" || return $?

    rm -f "${testdisk:?}"
}

# Allow overriding which tests should be run from the "outside", useful for manual
# testing (make -C test/... TESTCASES="testcase1 testcase2")
if [[ -v "TESTCASES" && -n "$TESTCASES" ]]; then
    read -ra TESTCASES <<< "$TESTCASES"
else
    # This must run after all functions were defined, otherwise `declare -F` won't
    # see them
    mapfile -t TESTCASES < <(declare -F | awk '$3 ~ /^testcase_/ {print $3;}')
fi

do_test "$@"
