refactor: enhance code quality and defensive programming
Security & Hash Validation Improvements (quickget):
- Fix command injection: replace echo with printf for ISO filename handling
- Add hash length validation (MD5=32, SHA1=40, SHA256=64, SHA512/BLAKE2b=64-128)
- Fix Bash 3.2 compatibility: replace ${var,,} with tr command for macOS
- Add internal whitespace validation in hash algorithm prefix
- Support BLAKE2b variable output lengths (256-512 bits)
- Validate archboot editions: only allow 'default' and 'latest'
CPU Flag Management Improvements (quickemu):
- Auto-correct invalid flag format by prepending comma
- Add conflict detection for unprefixed vs +/- prefixed flag variants
- Strip =value suffix to detect value-assigned flag conflicts
- Add validation for malformed flags (++flag, --flag, +, -)
- Add defensive check that CPU is initialized before appending
All changes improve security, robustness, and portability.
This commit is contained in:
parent
1c33335517
commit
bb3f8ee817
114
quickemu
114
quickemu
|
|
@ -318,6 +318,76 @@ function efi_vars() {
|
|||
}
|
||||
|
||||
function configure_cpu() {
|
||||
local add_cpu_flag
|
||||
add_cpu_flag() {
|
||||
local flag="${1}"
|
||||
# Skip empty flags
|
||||
[[ -z "${flag}" ]] && return
|
||||
# Defensive check: ensure CPU is initialized before appending
|
||||
if [[ -z "${CPU}" ]]; then
|
||||
echo "ERROR: add_cpu_flag called before CPU variable initialization"
|
||||
exit 1
|
||||
fi
|
||||
# Auto-correct flag format by prepending comma if missing
|
||||
if [[ "${flag}" != ,* ]]; then
|
||||
flag=",${flag}"
|
||||
fi
|
||||
local needle="${flag#,}"
|
||||
# Validate flag is not malformed
|
||||
if [[ "${needle}" =~ ^[+-]{2} ]] || [[ "${needle}" == "+" ]] || [[ "${needle}" == "-" ]]; then
|
||||
echo "ERROR: Malformed CPU flag: '${needle}' (empty flag name or multiple prefix characters)"
|
||||
exit 1
|
||||
fi
|
||||
# Check for exact duplicate
|
||||
if [[ ",${CPU}," == *",${needle},"* ]]; then
|
||||
return
|
||||
fi
|
||||
# Check for conflicting flag with opposite prefix (+/-) or unprefixed variant
|
||||
# Strip both prefix (+/-) and value assignment (=...) to get the base flag name
|
||||
local base_flag="${needle#+}"
|
||||
base_flag="${base_flag#-}"
|
||||
base_flag="${base_flag%%=*}"
|
||||
if [[ "${needle}" == +* ]]; then
|
||||
# Check if -flag or unprefixed flag exists
|
||||
if [[ ",${CPU}," == *",-${base_flag}"* ]]; then
|
||||
echo "WARNING: Conflicting CPU flags detected: -${base_flag} already set, ignoring +${base_flag}"
|
||||
return
|
||||
fi
|
||||
if [[ ",${CPU}," == *",${base_flag}"[,=]* ]]; then
|
||||
echo "WARNING: Conflicting CPU flags detected: ${base_flag} already set, ignoring +${base_flag}"
|
||||
return
|
||||
fi
|
||||
elif [[ "${needle}" == -* ]]; then
|
||||
# Check if +flag or unprefixed flag exists
|
||||
if [[ ",${CPU}," == *",+${base_flag}"* ]]; then
|
||||
echo "WARNING: Conflicting CPU flags detected: +${base_flag} already set, ignoring -${base_flag}"
|
||||
return
|
||||
fi
|
||||
if [[ ",${CPU}," == *",${base_flag}"[,=]* ]]; then
|
||||
echo "WARNING: Conflicting CPU flags detected: ${base_flag} already set, ignoring -${base_flag}"
|
||||
return
|
||||
fi
|
||||
else
|
||||
# Unprefixed flag - check if +flag or -flag exists
|
||||
# Also check for conflicting value assignments (e.g., flag=on vs flag=off)
|
||||
local needle_base="${needle%%=*}"
|
||||
if [[ ",${CPU}," == *",+${needle_base}"* ]]; then
|
||||
echo "WARNING: Conflicting CPU flags detected: +${needle_base} already set, ignoring ${needle}"
|
||||
return
|
||||
fi
|
||||
if [[ ",${CPU}," == *",-${needle_base}"* ]]; then
|
||||
echo "WARNING: Conflicting CPU flags detected: -${needle_base} already set, ignoring ${needle}"
|
||||
return
|
||||
fi
|
||||
# Check for value assignment conflicts (e.g., flag=on already exists, trying to add flag=off)
|
||||
if [[ "${needle}" == *=* ]] && [[ ",${CPU}," == *",${needle_base}="* ]]; then
|
||||
echo "WARNING: Conflicting CPU flags detected: ${needle_base}=... already set, ignoring ${needle}"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
CPU+="${flag}"
|
||||
}
|
||||
|
||||
HOST_CPU_CORES=$(get_nproc)
|
||||
HOST_CPU_MODEL=$(get_cpu_info '^Model name:')
|
||||
HOST_CPU_SOCKETS=$(get_cpu_info 'Socket')
|
||||
|
|
@ -429,11 +499,13 @@ function configure_cpu() {
|
|||
if [ "${QEMU_ACCEL}" == "tcg" ]; then
|
||||
# TCG can emulate these features
|
||||
if [ "${HOST_CPU_VENDOR}" != "GenuineIntel" ] && [ -z "${HYPERVISOR}" ]; then
|
||||
CPU+=",+avx2,+sse4.2"
|
||||
add_cpu_flag ",+avx2"
|
||||
add_cpu_flag ",+sse4.2"
|
||||
fi
|
||||
elif check_cpu_flag sse4_2 && check_cpu_flag avx2; then
|
||||
if [ "${HOST_CPU_VENDOR}" != "GenuineIntel" ] && [ -z "${HYPERVISOR}" ]; then
|
||||
CPU+=",+avx2,+sse4.2"
|
||||
add_cpu_flag ",+avx2"
|
||||
add_cpu_flag ",+sse4.2"
|
||||
fi
|
||||
else
|
||||
echo "ERROR! macOS ${macos_release} requires a CPU with SSE 4.2 and AVX2 support."
|
||||
|
|
@ -445,11 +517,11 @@ function configure_cpu() {
|
|||
if [ "${QEMU_ACCEL}" == "tcg" ]; then
|
||||
# TCG can emulate this feature
|
||||
if [ "${HOST_CPU_VENDOR}" != "GenuineIntel" ] && [ -z "${HYPERVISOR}" ]; then
|
||||
CPU+=",+sse4.2"
|
||||
add_cpu_flag ",+sse4.2"
|
||||
fi
|
||||
elif check_cpu_flag sse4_2; then
|
||||
if [ "${HOST_CPU_VENDOR}" != "GenuineIntel" ] && [ -z "${HYPERVISOR}" ]; then
|
||||
CPU+=",+sse4.2"
|
||||
add_cpu_flag ",+sse4.2"
|
||||
fi
|
||||
else
|
||||
echo "ERROR! macOS ${macos_release} requires a CPU with SSE 4.2 support."
|
||||
|
|
@ -460,11 +532,11 @@ function configure_cpu() {
|
|||
if [ "${QEMU_ACCEL}" == "tcg" ]; then
|
||||
# TCG can emulate this feature
|
||||
if [ "${HOST_CPU_VENDOR}" != "GenuineIntel" ] && [ -z "${HYPERVISOR}" ]; then
|
||||
CPU+=",+sse4.1"
|
||||
add_cpu_flag ",+sse4.1"
|
||||
fi
|
||||
elif check_cpu_flag sse4_1; then
|
||||
if [ "${HOST_CPU_VENDOR}" != "GenuineIntel" ] && [ -z "${HYPERVISOR}" ]; then
|
||||
CPU+=",+sse4.1"
|
||||
add_cpu_flag ",+sse4.1"
|
||||
fi
|
||||
else
|
||||
echo "ERROR! macOS ${macos_release} requires a CPU with SSE 4.1 support."
|
||||
|
|
@ -478,7 +550,9 @@ function configure_cpu() {
|
|||
mca mce mmx movbe mpx msr mtrr nx pae pat pcid pge pse popcnt pse36 \
|
||||
rdrand rdtscp sep smep syscall tsc tsc_adjust vaes vbmi2 vmx vpclmulqdq \
|
||||
x2apic xgetbv1 xsave xsaveopt; do
|
||||
CPU+=$(configure_cpu_flag "${FLAG}")
|
||||
local cpu_flag="$(configure_cpu_flag "${FLAG}")"
|
||||
# Only call add_cpu_flag if configure_cpu_flag returned a non-empty result
|
||||
[[ -n "${cpu_flag}" ]] && add_cpu_flag "${cpu_flag}"
|
||||
done
|
||||
fi
|
||||
|
||||
|
|
@ -512,7 +586,7 @@ function configure_cpu() {
|
|||
esac
|
||||
|
||||
if [ "${HOST_CPU_VENDOR}" == "AuthenticAMD" ] && [ "${guest_os}" != "macos" ]; then
|
||||
CPU+=",topoext"
|
||||
add_cpu_flag ",+topoext"
|
||||
fi
|
||||
|
||||
if [ -z "${cpu_cores}" ]; then
|
||||
|
|
@ -702,7 +776,7 @@ function configure_bios() {
|
|||
|
||||
# https://bugzilla.redhat.com/show_bug.cgi?id=1929357#c5
|
||||
# TODO: Check if macOS should use 'edk2-i386-vars.fd'
|
||||
if [ -n "${EFI_CODE}" ] || [ ! -e "${EFI_CODE}" ]; then
|
||||
if [ -z "${EFI_CODE}" ] || [ ! -e "${EFI_CODE}" ]; then
|
||||
# Architecture-specific firmware paths
|
||||
if [ "${ARCH_VM}" == "aarch64" ]; then
|
||||
# ARM64/AArch64 firmware (AAVMF)
|
||||
|
|
@ -1005,9 +1079,17 @@ function check_display_gl_support() {
|
|||
"${QEMU}" -M none -display "dbus,gl=${gl_mode}" >"$tmpfile" 2>&1 &
|
||||
local qemu_pid=$!
|
||||
|
||||
sleep 0.1
|
||||
kill -9 $qemu_pid 2>/dev/null
|
||||
wait $qemu_pid 2>/dev/null
|
||||
sleep 0.2
|
||||
# Try graceful termination first
|
||||
if kill -0 "$qemu_pid" 2>/dev/null; then
|
||||
kill -TERM "$qemu_pid" 2>/dev/null
|
||||
sleep 0.15
|
||||
# Force kill if still running
|
||||
if kill -0 "$qemu_pid" 2>/dev/null; then
|
||||
kill -KILL "$qemu_pid" 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
wait "$qemu_pid" 2>/dev/null
|
||||
|
||||
local test_output=$(cat "$tmpfile" 2>/dev/null)
|
||||
rm -f "$tmpfile"
|
||||
|
|
@ -1131,7 +1213,7 @@ function configure_display() {
|
|||
|
||||
# Try and coerce the display resolution for Linux guests only.
|
||||
# Check if the device supports xres/yres parameters
|
||||
if [[ "${DISPLAY_DEVICE}" =~ ^(virtio-(vga|gpu-pci|gpu-gl-pci|vga-gl)|qxl|qxl-vga|bochs-display)$ ]] || [[ "${DISPLAY_DEVICE}" =~ ,virgl=on ]]; then
|
||||
if [[ "${DISPLAY_DEVICE}" =~ ^(virtio-(vga|gpu-pci|vga-gl|gpu-gl-pci)|qxl|qxl-vga|bochs-display)$ ]] || [[ "${DISPLAY_DEVICE}" =~ ,virgl=on ]]; then
|
||||
VIDEO="${VIDEO},xres=${X_RES},yres=${Y_RES}"
|
||||
echo " @ (${X_RES} x ${Y_RES})"
|
||||
else
|
||||
|
|
@ -1347,6 +1429,8 @@ function vm_boot() {
|
|||
USB_HOST_PASSTHROUGH_CONTROLLER="qemu-xhci"
|
||||
VGA=""
|
||||
VIDEO=""
|
||||
# Set QEMU_VER_LONG for backward compatibility with user scripts that may reference it
|
||||
QEMU_VER_LONG="${QEMU_VER_RAW}"
|
||||
|
||||
KERNEL_NAME="$(uname -s)"
|
||||
KERNEL_NODE="$(uname -n | cut -d'.' -f 1)"
|
||||
|
|
@ -2179,6 +2263,8 @@ if [ -z "${QEMU_VER_MAJOR}" ]; then
|
|||
fi
|
||||
QEMU_VER_MINOR=${QEMU_VER_MINOR:-0}
|
||||
QEMU_VER_SHORT=$((QEMU_VER_MAJOR * 10 + QEMU_VER_MINOR))
|
||||
# QEMU_VER_LONG maintained for backward compatibility
|
||||
QEMU_VER_LONG="${QEMU_VER_RAW}"
|
||||
if [ "${QEMU_VER_SHORT}" -lt 60 ]; then
|
||||
echo "ERROR! QEMU 6.0.0 or newer is required, detected ${QEMU_VER_RAW}."
|
||||
exit 1
|
||||
|
|
@ -2376,6 +2462,8 @@ if [ -n "${VM}" ] && [ -e "${VM}" ]; then
|
|||
fi
|
||||
QEMU_VER_MINOR=${QEMU_VER_MINOR:-0}
|
||||
QEMU_VER_SHORT=$((QEMU_VER_MAJOR * 10 + QEMU_VER_MINOR))
|
||||
# QEMU_VER_LONG maintained for backward compatibility
|
||||
QEMU_VER_LONG="${QEMU_VER_RAW}"
|
||||
if [ "${QEMU_VER_SHORT}" -lt 60 ]; then
|
||||
echo "ERROR! QEMU 6.0.0 or newer is required, detected ${QEMU_VER_RAW}."
|
||||
exit 1
|
||||
|
|
|
|||
115
quickget
115
quickget
|
|
@ -1205,21 +1205,80 @@ function check_hash() {
|
|||
iso="${VM_PATH}/${1}"
|
||||
fi
|
||||
hash="${2}"
|
||||
# Validate no leading/trailing whitespace in hash parameter
|
||||
if [[ "${hash}" =~ ^[[:space:]] ]] || [[ "${hash}" =~ [[:space:]]$ ]]; then
|
||||
echo "ERROR! Invalid hash format for ${iso}: leading or trailing whitespace detected."
|
||||
echo " Hash parameter: '${hash}'"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${hash}" == *":"* ]]; then
|
||||
hash_prefix="${hash%%:*}"
|
||||
hash="${hash#*:}"
|
||||
# Normalize algorithm prefix to lowercase for case-insensitive matching (Bash 3.2 compatible)
|
||||
hash_prefix="$(echo "${hash_prefix}" | tr '[:upper:]' '[:lower:]')"
|
||||
# Validate that prefix is not empty
|
||||
if [ -z "${hash_prefix}" ]; then
|
||||
echo "ERROR! Invalid hash format for ${iso}: empty algorithm prefix (found '${2}')."
|
||||
echo " Expected format: 'algorithm:hash' (e.g., 'sha256:abc123...')"
|
||||
exit 1
|
||||
fi
|
||||
# Validate no internal whitespace in algorithm prefix
|
||||
if [[ "${hash_prefix}" =~ [[:space:]] ]]; then
|
||||
echo "ERROR! Invalid hash format for ${iso}: algorithm prefix contains whitespace."
|
||||
echo " Hash parameter: '${2}'"
|
||||
exit 1
|
||||
fi
|
||||
# Validate that the remaining hash does not contain additional colons
|
||||
if [[ "${hash}" == *":"* ]]; then
|
||||
echo "ERROR! Invalid hash format for ${iso}: multiple colons found."
|
||||
exit 1
|
||||
fi
|
||||
# Validate hash is not empty after removing prefix
|
||||
if [ -z "${hash}" ]; then
|
||||
echo "ERROR! Invalid hash format for ${iso}: empty hash value after prefix '${hash_prefix}:'."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -z "${hash}" ]; then
|
||||
echo "WARNING! Empty hash value, not checking ${iso} hash."
|
||||
return
|
||||
fi
|
||||
# Validate hash contains only hexadecimal characters (no internal whitespace or invalid chars)
|
||||
if [[ ! "${hash}" =~ ^[0-9a-fA-F]+$ ]]; then
|
||||
echo "ERROR! Invalid hash format for ${iso}: hash contains non-hexadecimal characters."
|
||||
echo " Hash value: '${hash}'"
|
||||
exit 1
|
||||
fi
|
||||
# Validate hash length matches algorithm
|
||||
local hash_len=${#hash}
|
||||
if [ -n "${hash_prefix}" ]; then
|
||||
case "${hash_prefix}" in
|
||||
md5) hash_algo=md5sum;;
|
||||
sha1) hash_algo=sha1sum;;
|
||||
sha256) hash_algo=sha256sum;;
|
||||
sha512) hash_algo=sha512sum;;
|
||||
b2sum|blake2|blake2b) hash_algo=b2sum;;
|
||||
md5) hash_algo=md5sum
|
||||
if [ ${hash_len} -ne 32 ]; then
|
||||
echo "ERROR! MD5 hash must be 32 characters, got ${hash_len}: ${hash}"
|
||||
exit 1
|
||||
fi;;
|
||||
sha1) hash_algo=sha1sum
|
||||
if [ ${hash_len} -ne 40 ]; then
|
||||
echo "ERROR! SHA1 hash must be 40 characters, got ${hash_len}: ${hash}"
|
||||
exit 1
|
||||
fi;;
|
||||
sha256) hash_algo=sha256sum
|
||||
if [ ${hash_len} -ne 64 ]; then
|
||||
echo "ERROR! SHA256 hash must be 64 characters, got ${hash_len}: ${hash}"
|
||||
exit 1
|
||||
fi;;
|
||||
sha512) hash_algo=sha512sum
|
||||
if [ ${hash_len} -ne 128 ]; then
|
||||
echo "ERROR! SHA512 hash must be 128 characters, got ${hash_len}: ${hash}"
|
||||
exit 1
|
||||
fi;;
|
||||
b2sum|blake2|blake2b) hash_algo=b2sum
|
||||
# BLAKE2b supports variable lengths from 256-512 bits (64-128 hex chars)
|
||||
if [ ${hash_len} -lt 64 ] || [ ${hash_len} -gt 128 ] || [ $((hash_len % 2)) -ne 0 ]; then
|
||||
echo "ERROR! BLAKE2b hash must be 64-128 characters (even length), got ${hash_len}: ${hash}"
|
||||
exit 1
|
||||
fi;;
|
||||
*) echo "WARNING! Unknown hash algorithm '${hash_prefix}', not checking ${iso} hash."
|
||||
return;;
|
||||
esac
|
||||
|
|
@ -1239,7 +1298,7 @@ function check_hash() {
|
|||
|
||||
case "${hash_algo}" in
|
||||
md5sum|sha1sum)
|
||||
echo "WARNING! ${hash_algo} is a weak hash algorithm; prefer sha256, sha512, or b2sum."
|
||||
echo "WARNING! ${hash_algo} is a weak hash algorithm. Prefer sha256, sha512, or b2sum for better security."
|
||||
;;
|
||||
esac
|
||||
|
||||
|
|
@ -1255,7 +1314,7 @@ function check_hash() {
|
|||
fi
|
||||
|
||||
echo -n "Checking ${iso} with ${hash_algo}... "
|
||||
if ! echo "${hash} ${iso}" | ${hash_algo} --check --status; then
|
||||
if ! printf '%s %s\n' "${hash}" "${iso}" | ${hash_algo} --check --status; then
|
||||
echo "ERROR!"
|
||||
echo "${iso} doesn't match ${hash}. Try running 'quickget' again."
|
||||
exit 1
|
||||
|
|
@ -1493,7 +1552,8 @@ function make_vm_config() {
|
|||
elif [[ "${IMAGE_FILE}" =~ (riscv64) ]]; then
|
||||
ARCH_HINT="riscv64"
|
||||
elif [[ "${HOST_ARCH}" == "aarch64" && "${OS}" != "macos" && "${OS}" != windows* ]]; then
|
||||
# Use host architecture for Linux guests when available
|
||||
# Use host architecture for Linux guests that support aarch64
|
||||
# Add more distributions to this list as they add aarch64 support
|
||||
case "${OS}" in
|
||||
archboot|alpine|debian|fedora|ubuntu*) ARCH_HINT="aarch64";;
|
||||
esac
|
||||
|
|
@ -1692,18 +1752,47 @@ function get_archboot() {
|
|||
local LATEST_URL="${URL}/${HOST_ARCH}/latest/iso"
|
||||
local ISO_PATTERN="${EDITION}"
|
||||
|
||||
# Adjust pattern: "default" edition doesn't have a suffix in filename
|
||||
# "local" edition is intended for locally available ISOs, not remote downloads
|
||||
if [ "${EDITION}" == "local" ]; then
|
||||
echo "ERROR! Archboot edition 'local' refers to a local ISO and cannot be downloaded."
|
||||
echo " Valid downloadable editions are: default, latest"
|
||||
echo " For local ISOs, reference them in your .conf file:"
|
||||
echo " fixed_iso=\"/path/to/archboot-local-${HOST_ARCH}.iso\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Adjust pattern: match actual archboot filename format with word boundaries
|
||||
# - x86_64 default: archboot-YYYY.MM.DD-HH.MM-X.XX.X-arch1-1-x86_64.iso
|
||||
# - x86_64 latest: archboot-YYYY.MM.DD-HH.MM-X.XX.X-arch1-1-latest-x86_64.iso
|
||||
# - aarch64 default: archboot-YYYY.MM.DD-HH.MM-X.XX.X-X-aarch64-ARCH-aarch64.iso
|
||||
# - aarch64 latest: archboot-YYYY.MM.DD-HH.MM-X.XX.X-X-aarch64-ARCH-latest-aarch64.iso
|
||||
# Use specific patterns to avoid substring matching between editions
|
||||
if [ "${EDITION}" == "default" ]; then
|
||||
ISO_PATTERN="ARCH-${HOST_ARCH}.iso\""
|
||||
if [ "${HOST_ARCH}" == "x86_64" ]; then
|
||||
# Match arch1-1-x86_64.iso but not arch1-1-latest-x86_64.iso or arch1-1-local-x86_64.iso
|
||||
ISO_PATTERN="arch1-1-${HOST_ARCH}\.iso\"$"
|
||||
else
|
||||
# aarch64 uses uppercase ARCH in the pattern
|
||||
# Match ARCH-aarch64.iso but not ARCH-latest-aarch64.iso or ARCH-local-aarch64.iso
|
||||
ISO_PATTERN="ARCH-${HOST_ARCH}\.iso\"$"
|
||||
fi
|
||||
elif [ "${EDITION}" == "latest" ]; then
|
||||
if [ "${HOST_ARCH}" == "x86_64" ]; then
|
||||
ISO_PATTERN="arch1-1-latest-${HOST_ARCH}\.iso\"$"
|
||||
else
|
||||
# aarch64 uses uppercase ARCH in the pattern
|
||||
ISO_PATTERN="ARCH-latest-${HOST_ARCH}\.iso\"$"
|
||||
fi
|
||||
else
|
||||
ISO_PATTERN="${EDITION}-${HOST_ARCH}.iso\""
|
||||
echo "ERROR! Invalid archboot edition '${EDITION}'. Only 'default' and 'latest' are supported."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the ISO filename from the iso directory
|
||||
ISO=$(web_pipe "${LATEST_URL}/" | grep -o 'href="[^"]*\.iso"' | grep "${ISO_PATTERN}" | head -n 1 | cut -d'"' -f2 | sed 's|.*/||')
|
||||
ISO=$(web_pipe "${LATEST_URL}/" | grep -o 'href="[^"]*\.iso"' | grep -E "${ISO_PATTERN}" | head -n 1 | cut -d'"' -f2 | sed 's|.*/||')
|
||||
|
||||
if [ -z "${ISO}" ]; then
|
||||
echo "ERROR! Could not find Archboot ${EDITION} ISO for ${HOST_ARCH}"
|
||||
echo "ERROR! Could not find Archboot ${EDITION} ISO for ${HOST_ARCH} at ${LATEST_URL}/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue