diff --git a/install.sh b/install.sh index 29ac231..5595082 100755 --- a/install.sh +++ b/install.sh @@ -26,12 +26,15 @@ print_success() { echo -e "\e[1;32m[SUCCESS]\e[0m $1" } +# Diagnostics go to stderr, not stdout: `exec > >(tee ...) 2>&1` keeps them in the +# log and on screen either way, but stdout is what every $( ) captures. Writing +# them there let the ERR trap's own message land inside a captured value. print_error() { - echo -e "\e[1;31m[ERROR]\e[0m $1" + echo -e "\e[1;31m[ERROR]\e[0m $1" >&2 } print_warning() { - echo -e "\e[1;33m[WARNING]\e[0m $1" + echo -e "\e[1;33m[WARNING]\e[0m $1" >&2 } command_exists() { @@ -272,6 +275,7 @@ GPU_NAME="" GPU_MEMORY="" HAS_VULKAN="no" VULKAN_DEVICE="" +VULKAN_SOFTWARE_DEVICE="" # Initialize mode/state variables that are set later by flags or prompts so # that every read under `set -u` is well-defined in every code path. INSTALL_TAG="" @@ -865,25 +869,52 @@ get_cuda_cmake_args() { printf '%s\n' "$CUDA_ARGS" } -# Detect Vulkan support for whisper.cpp -detect_vulkan() { - # Check for vulkaninfo command - if command -v vulkaninfo >/dev/null 2>&1; then - # (|| true guards against pipefail aborts: head closes the pipe early - # and grep exits 1 when there is no match) - local vulkan_output - vulkan_output=$(vulkaninfo --summary 2>/dev/null | head -20 || true) - if [ -n "$vulkan_output" ]; then - HAS_VULKAN="yes" - # Try to extract GPU name - VULKAN_DEVICE=$(echo "$vulkan_output" | grep -i "deviceName" | head -1 | cut -d'=' -f2 | xargs || true) - if [ -z "$VULKAN_DEVICE" ]; then - VULKAN_DEVICE="Vulkan-compatible GPU" - fi +# CPU implementations of Vulkan. whisper.cpp on these is slower than its own CPU +# backend, so they must never be reported as a GPU. One list, read by both +# detect_vulkan and check_vulkan_gpu_compatibility. +VULKAN_SOFTWARE_PATTERNS=(llvmpipe swiftshader lavapipe zink virtio venus) + +is_software_renderer() { + local name="$1" pattern + for pattern in "${VULKAN_SOFTWARE_PATTERNS[@]}"; do + if printf '%s' "$name" | grep -iq "$pattern"; then return 0 fi - fi + done + return 1 +} + +# List the Vulkan devices vulkaninfo reports, one per line. +vulkan_device_names() { + command -v vulkaninfo >/dev/null 2>&1 || return 1 + # (|| true: no match must not abort pipefail.) + vulkaninfo --summary 2>/dev/null | awk -F'=' '/deviceName/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); if ($2 != "") print $2}' || true +} + +# Detect Vulkan support for whisper.cpp +detect_vulkan() { + # Only a hardware deviceName counts. The loader prints a header even when no + # ICD resolves, and a software renderer is not a GPU -- reporting either as + # one is what made Step 1 promise Vulkan performance the machine cannot give. HAS_VULKAN="no" + VULKAN_DEVICE="" + VULKAN_SOFTWARE_DEVICE="" + + local devices name + devices=$(vulkan_device_names) || return 1 + [ -n "$devices" ] || return 1 + + while IFS= read -r name; do + [ -z "$name" ] && continue + if is_software_renderer "$name"; then + [ -n "$VULKAN_SOFTWARE_DEVICE" ] || VULKAN_SOFTWARE_DEVICE="$name" + continue + fi + HAS_VULKAN="yes" + VULKAN_DEVICE="$name" + return 0 + done <<< "$devices" + return 1 } @@ -912,17 +943,6 @@ check_vulkan_gpu_compatibility() { "SNB" ) - # Software renderers and virtual devices to skip (not real hardware GPUs) - # These are CPU-based implementations that shouldn't affect compatibility detection - local SOFTWARE_RENDERER_PATTERNS=( - "llvmpipe" - "swiftshader" - "lavapipe" - "zink" - "virtio" - "venus" - ) - # Check if vulkaninfo is available if ! command -v vulkaninfo >/dev/null 2>&1; then echo "unknown:vulkaninfo not available" @@ -931,29 +951,19 @@ check_vulkan_gpu_compatibility() { # Get all device names from vulkaninfo local DEVICE_NAMES_RAW - DEVICE_NAMES_RAW=$(vulkaninfo --summary 2>/dev/null | awk -F'=' '/deviceName/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); if ($2 != "") print $2}' || true) + DEVICE_NAMES_RAW=$(vulkan_device_names || true) # Separate hardware GPUs from software renderers local HARDWARE_GPUS="" - local HARDWARE_GPU_COUNT=0 while IFS= read -r device_name; do [ -z "$device_name" ] && continue - - local is_software=false - for pattern in "${SOFTWARE_RENDERER_PATTERNS[@]}"; do - if echo "$device_name" | grep -iq "$pattern"; then - is_software=true - break - fi - done - - if [ "$is_software" = false ]; then - if [ -n "$HARDWARE_GPUS" ]; then - HARDWARE_GPUS="${HARDWARE_GPUS}, ${device_name}" - else - HARDWARE_GPUS="$device_name" - fi - ((HARDWARE_GPU_COUNT++)) + if is_software_renderer "$device_name"; then + continue + fi + if [ -n "$HARDWARE_GPUS" ]; then + HARDWARE_GPUS="${HARDWARE_GPUS}, ${device_name}" + else + HARDWARE_GPUS="$device_name" fi done <<< "$DEVICE_NAMES_RAW" @@ -966,7 +976,8 @@ check_vulkan_gpu_compatibility() { # Get Vulkan features and check for VK_KHR_16bit_storage # Modern GPUs (AMD, Intel Gen8+, NVIDIA) all support this extension local FEATURES_OUTPUT - FEATURES_OUTPUT=$(vulkaninfo --features 2>/dev/null) + # Exits non-zero on drivers that still print usable output; the text is what matters. + FEATURES_OUTPUT=$(vulkaninfo --features 2>/dev/null) || true if [ -n "$FEATURES_OUTPUT" ]; then # Check for VK_KHR_16bit_storage extension or equivalent features @@ -990,15 +1001,9 @@ check_vulkan_gpu_compatibility() { while IFS= read -r device_name; do [ -z "$device_name" ] && continue - # Skip software renderers - local is_software=false - for pattern in "${SOFTWARE_RENDERER_PATTERNS[@]}"; do - if echo "$device_name" | grep -iq "$pattern"; then - is_software=true - break - fi - done - [ "$is_software" = true ] && continue + if is_software_renderer "$device_name"; then + continue + fi # Check against known incompatible patterns local is_incompatible=false @@ -1058,7 +1063,8 @@ detect_whispercpp_backends() { local VULKAN_COMPAT_REASON="" if [[ "$HAS_VULKAN" == "yes" && "$HAS_NVIDIA_GPU" != "yes" ]]; then local COMPAT_RESULT - COMPAT_RESULT=$(check_vulkan_gpu_compatibility) + # Returns non-zero for "unknown"/"incompatible", which are answers, not errors. + COMPAT_RESULT=$(check_vulkan_gpu_compatibility) || true VULKAN_COMPATIBLE=$(echo "$COMPAT_RESULT" | cut -d':' -f1) VULKAN_COMPAT_REASON=$(echo "$COMPAT_RESULT" | cut -d':' -f2-) elif [[ "$HAS_NVIDIA_GPU" == "yes" ]]; then @@ -1125,6 +1131,8 @@ get_engine_recommendation() { elif [[ "$HAS_VULKAN" == "yes" ]]; then # Non-NVIDIA GPU with Vulkan support echo "whisper_cpp:✓:$VULKAN_DEVICE detected - Great performance with whisper.cpp Vulkan" + elif [ -n "$VULKAN_SOFTWARE_DEVICE" ]; then + echo "whisper_cpp:✓:Software Vulkan only ($VULKAN_SOFTWARE_DEVICE) - whisper.cpp CPU mode" elif [ "$TOTAL_RAM_GB" -ge 8 ]; then # No GPU but decent RAM echo "whisper_cpp:✓:No GPU detected, but ${TOTAL_RAM_GB}GB RAM - whisper.cpp CPU mode" @@ -3842,6 +3850,12 @@ whispercpp_pinned_revision() { awk '/^#[[:space:]]*whispercpp-revision:/ {print $3; exit}' "$MODEL_CHECKSUMS_FILE" } +# False when the cloned release predates the manifest. That app has no runtime +# verification either, so refusing to pre-download would protect nothing. +model_verification_available() { + [ -f "$MODEL_CHECKSUMS_FILE" ] +} + # Function to download and install Whisper tiny model install_whisper_model() { print_info "Installing Whisper tiny model (~75MB)..." @@ -3950,6 +3964,13 @@ install_vosk_models() { print_warning "The existing VOSK model carries no matching verification stamp; re-downloading it." fi + # Refuse before spending the download, not after verification rejects it. + if [ -z "$EXPECTED_ZIP_DIGEST" ]; then + print_error "No checksum is pinned for $SMALL_MODEL_ARCHIVE in $MODEL_CHECKSUMS_FILE." + print_warning "VOSK model will be downloaded and verified on first application run." + return 1 + fi + # Check internet connectivity if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then print_warning "Neither wget nor curl found. Cannot download VOSK models." @@ -4308,15 +4329,27 @@ install_resources_to_venv || print_warning "Venv resource installation failed" # Install models based on selected engine # whisper.cpp is now the default engine if [ "$SKIP_MODELS" = "no" ]; then + # Once, rather than once per engine. + if ! model_verification_available; then + print_warning "This release pins no model checksums, so the installer cannot verify" + print_warning "model downloads. The application will download and verify them on" + print_warning "first run instead." + print_warning "(The installer is newer than ${INSTALL_TAG:-the checked-out revision}.)" + fi + # Check which engines are installed and download appropriate models # Install whisper.cpp model (default engine) if is_pywhispercpp_installed; then - print_info "whisper.cpp is installed - downloading tiny model (default engine)..." - install_whispercpp_model || print_warning "whisper.cpp model download failed - model will be downloaded on first run" + if model_verification_available; then + print_info "whisper.cpp is installed - downloading tiny model (default engine)..." + install_whispercpp_model || print_warning "whisper.cpp model download failed - model will be downloaded on first run" + else + print_info "Leaving the whisper.cpp model to the first application run." + fi fi - # Install OpenAI Whisper model if whisper engine is installed + # Needs no manifest: the sha256 is a path segment of its own URL. if "$VENV_DIR/bin/python" -c "import whisper" 2>/dev/null; then print_info "Whisper (OpenAI) is installed - downloading tiny model..." install_whisper_model || print_warning "Whisper model download failed - model will be downloaded on first run" @@ -4328,7 +4361,11 @@ fi # Install VOSK models (always useful as fallback) if [ "$SKIP_MODELS" = "no" ]; then - install_vosk_models || print_warning "VOSK model installation failed - models will be downloaded on first run" + if model_verification_available; then + install_vosk_models || print_warning "VOSK model installation failed - models will be downloaded on first run" + else + print_info "Leaving the VOSK model to the first application run." + fi else print_info "Skipping VOSK model installation (--skip-models specified)" print_info "Models will be downloaded automatically on first application run" diff --git a/pyproject.toml b/pyproject.toml index 0f7d597..005aac6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [build-system] -requires = ["setuptools>=61,<82"] +# >=77 for the SPDX license string below. +requires = ["setuptools>=77,<82"] build-backend = "setuptools.build_meta" [project] @@ -7,7 +8,8 @@ name = "vocalinux" dynamic = ["version"] description = "A seamless voice dictation system for Linux" readme = "README.md" -license = {text = "AGPL-3.0"} +license = "AGPL-3.0-only" +license-files = ["LICENSE"] authors = [ {name = "Jatin K Malik", email = "jatinkrmalik@gmail.com"} ] @@ -23,7 +25,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: X11 Applications :: GTK", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU Affero General Public License v3", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", @@ -100,7 +101,20 @@ Changelog = "https://github.com/VocaHQ/vocalinux/releases" required-version = ">=0.12,<0.13" [tool.setuptools] -packages = ["vocalinux", "vocalinux.resources", "vocalinux.speech_recognition", "vocalinux.text_injection", "vocalinux.ui", "vocalinux.ui.keyboard_backends", "vocalinux.utils"] +# Resource dirs carry no .py files, but setuptools still treats them as packages. +packages = [ + "vocalinux", + "vocalinux.resources", + "vocalinux.resources.icons", + "vocalinux.resources.icons.scalable", + "vocalinux.resources.sounds", + "vocalinux.speech_recognition", + "vocalinux.speech_recognition.data", + "vocalinux.text_injection", + "vocalinux.ui", + "vocalinux.ui.keyboard_backends", + "vocalinux.utils", +] package-dir = {"" = "src"} [tool.setuptools.dynamic] diff --git a/src/vocalinux/version.py b/src/vocalinux/version.py index 46eeea6..f4d5233 100644 --- a/src/vocalinux/version.py +++ b/src/vocalinux/version.py @@ -6,7 +6,7 @@ __version__ = "0.16.0" __version_info__ = (0, 16, 0) __author__ = "Jatin K Malik" __email__ = "jatinkrmalik@gmail.com" -__license__ = "AGPL-3.0" +__license__ = "AGPL-3.0-only" __copyright__ = "Copyright 2023-2026 Jatin K Malik" __url__ = "https://github.com/VocaHQ/vocalinux" __description__ = "Free, offline voice dictation for Linux - speak and type anywhere" diff --git a/tests/test_installer_vulkan_detection.py b/tests/test_installer_vulkan_detection.py new file mode 100644 index 0000000..88d5cf7 --- /dev/null +++ b/tests/test_installer_vulkan_detection.py @@ -0,0 +1,326 @@ +"""Behavioural tests for the installer's Vulkan detection. + +`detect_vulkan` used to accept any non-empty `vulkaninfo --summary` as a GPU, and +read only the first 20 lines while `check_vulkan_gpu_compatibility` read all of +them -- so the wizard could report a GPU in Step 1 and none in Step 3. +""" + +import re +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +INSTALL_SH = Path(__file__).resolve().parents[1] / "install.sh" +SOURCE = INSTALL_SH.read_text() + +FUNCTIONS = ( + "is_software_renderer", + "vulkan_device_names", + "detect_vulkan", + "check_vulkan_gpu_compatibility", +) + +PRELUDE = """ +set -uo pipefail +HAS_VULKAN="no" +VULKAN_DEVICE="" +VULKAN_SOFTWARE_DEVICE="" +print_info() { echo "INFO: $*"; } +print_warning() { echo "WARNING: $*"; } +""" + +# The one list both detectors read; lifted from install.sh so it cannot drift. +PATTERNS = re.search(r"^VULKAN_SOFTWARE_PATTERNS=\([^)]*\)", SOURCE, re.M) +assert PATTERNS, "VULKAN_SOFTWARE_PATTERNS missing from install.sh" + +# A loader that resolved no driver: header, error, no device. +NO_DEVICES = """\ +ERROR: [Loader Message] Code 0 : setup_loader_term_phys_devs: Failed to detect any valid GPUs +========== +VULKANINFO +========== + +Vulkan Instance Version: 1.3.239 + +Instance Extensions: count = 19 +Layers: count = 0 + +Devices: +======== +""" + +# A real adapter listed past the old `head -20` cutoff. +DEVICE_PAST_LINE_20 = ( + "".join(f"instance preamble line {n}\n" for n in range(1, 25)) + + "\tdeviceName = AMD Radeon RX 7900 XTX (RADV NAVI31)\n" +) + +SOFTWARE_ONLY = """\ +========== +VULKANINFO +========== + +Devices: +======== +GPU0: +\tdeviceName = llvmpipe (LLVM 15.0.6, 256 bits) +""" + +# A real adapter the loader happens to list after the software one. +SOFTWARE_THEN_HARDWARE = ( + SOFTWARE_ONLY + "\tdeviceName = AMD Radeon RX 7900 XTX (RADV NAVI31)\n" +) + +DEVICE_EARLY = """\ +========== +VULKANINFO +========== + +Devices: +======== +GPU0: +\tdeviceName = Intel(R) Iris(R) Xe Graphics (TGL GT2) +""" + + +def _functions() -> str: + chunks = [PATTERNS.group(0)] + for name in FUNCTIONS: + match = re.search(rf"^{name}\(\) \{{.*?^\}}", SOURCE, re.M | re.S) + assert match, f"{name} not found in install.sh" + chunks.append(match.group(0)) + return "\n".join(chunks) + + +# PATH is restricted to these so a real vulkaninfo cannot answer for a stub. +NEEDED_TOOLS = ("awk", "head", "grep", "cut", "tr", "xargs", "sed", "cat") + + +def _run(tmp_path: Path, summary: str | None, script: str) -> subprocess.CompletedProcess: + """Run `script` with a stubbed vulkaninfo, or with none at all.""" + binary = tmp_path / "bin" + binary.mkdir(exist_ok=True) + for tool in NEEDED_TOOLS: + found = shutil.which(tool) + if found and not (binary / tool).exists(): + (binary / tool).symlink_to(found) + if summary is not None: + stub = binary / "vulkaninfo" + stub.write_text(textwrap.dedent("""\ + #!/bin/bash + case "$1" in + --summary) cat <<'SUMMARY' + {summary} + SUMMARY + ;; + --features) echo "" ;; + esac + """).format(summary=summary.rstrip("\n"))) + stub.chmod(0o755) + env_path = f'export PATH="{binary}"\n' + return subprocess.run( + ["bash", "-c", PRELUDE + env_path + _functions() + "\n" + script], + capture_output=True, + text=True, + timeout=30, + ) + + +class TestDetectVulkan: + def test_a_loader_without_devices_is_not_a_gpu(self, tmp_path): + result = _run( + tmp_path, NO_DEVICES, 'detect_vulkan; echo "RC=$? HAS=$HAS_VULKAN DEV=[$VULKAN_DEVICE]"' + ) + assert "RC=1 HAS=no DEV=[]" in result.stdout, result.stdout + result.stderr + + def test_it_never_reports_the_fallback_string_as_a_device(self, tmp_path): + """The old code printed this literal as though it were an adapter name.""" + result = _run(tmp_path, NO_DEVICES, 'detect_vulkan; echo "DEV=[$VULKAN_DEVICE]"') + assert "Vulkan-compatible GPU" not in result.stdout + + def test_a_device_listed_past_line_20_is_still_found(self, tmp_path): + result = _run( + tmp_path, + DEVICE_PAST_LINE_20, + 'detect_vulkan; echo "HAS=$HAS_VULKAN DEV=[$VULKAN_DEVICE]"', + ) + assert "HAS=yes" in result.stdout, result.stdout + result.stderr + assert "DEV=[AMD Radeon RX 7900 XTX (RADV NAVI31)]" in result.stdout + + def test_an_ordinary_device_is_reported_by_name(self, tmp_path): + result = _run( + tmp_path, DEVICE_EARLY, 'detect_vulkan; echo "HAS=$HAS_VULKAN DEV=[$VULKAN_DEVICE]"' + ) + assert "HAS=yes" in result.stdout + assert "DEV=[Intel(R) Iris(R) Xe Graphics (TGL GT2)]" in result.stdout + + def test_absent_vulkaninfo_is_no(self, tmp_path): + result = _run(tmp_path, None, 'detect_vulkan; echo "RC=$? HAS=$HAS_VULKAN"') + assert "RC=1 HAS=no" in result.stdout, result.stdout + result.stderr + + +class TestTheTwoDetectorsAgree: + """Both readers must see the same devices, or the wizard contradicts itself.""" + + @pytest.mark.parametrize( + "summary", [NO_DEVICES, DEVICE_PAST_LINE_20, DEVICE_EARLY], ids=["none", "late", "early"] + ) + def test_a_claimed_gpu_is_a_gpu_the_compat_check_can_see(self, tmp_path, summary): + result = _run( + tmp_path, + summary, + 'detect_vulkan || true; echo "HAS=$HAS_VULKAN"; ' + "check_vulkan_gpu_compatibility || true", + ) + claimed = "HAS=yes" in result.stdout + saw_no_devices = "No hardware GPU found" in result.stdout + assert not (claimed and saw_no_devices), ( + "detect_vulkan announced a GPU that check_vulkan_gpu_compatibility " + f"cannot find:\n{result.stdout}" + ) + + +class TestSoftwareRenderers: + """whisper.cpp on a CPU implementation of Vulkan is slower than its own CPU + backend, so a software renderer must not be reported as a GPU.""" + + def test_a_software_renderer_is_not_a_gpu(self, tmp_path): + result = _run( + tmp_path, + SOFTWARE_ONLY, + 'detect_vulkan; echo "RC=$? HAS=$HAS_VULKAN DEV=[$VULKAN_DEVICE]"', + ) + assert "RC=1 HAS=no DEV=[]" in result.stdout, result.stdout + result.stderr + + def test_it_is_remembered_for_the_message(self, tmp_path): + """Step 1 says "software Vulkan only", which beats "no GPU detected".""" + result = _run( + tmp_path, SOFTWARE_ONLY, 'detect_vulkan || true; echo "SOFT=[$VULKAN_SOFTWARE_DEVICE]"' + ) + assert "SOFT=[llvmpipe (LLVM 15.0.6, 256 bits)]" in result.stdout + + def test_hardware_still_wins_when_listed_after_software(self, tmp_path): + result = _run( + tmp_path, + SOFTWARE_THEN_HARDWARE, + 'detect_vulkan; echo "HAS=$HAS_VULKAN DEV=[$VULKAN_DEVICE]"', + ) + assert "HAS=yes" in result.stdout, result.stdout + result.stderr + assert "DEV=[AMD Radeon RX 7900 XTX (RADV NAVI31)]" in result.stdout + + def test_both_detectors_agree_that_it_is_not_hardware(self, tmp_path): + result = _run( + tmp_path, + SOFTWARE_ONLY, + 'detect_vulkan || true; echo "HAS=$HAS_VULKAN"; check_vulkan_gpu_compatibility || true', + ) + assert "HAS=no" in result.stdout + assert "No hardware GPU found" in result.stdout + + @pytest.mark.parametrize( + "name", ["llvmpipe", "swiftshader", "lavapipe", "zink", "virtio", "venus"] + ) + def test_every_pattern_is_recognised(self, tmp_path, name): + result = _run( + tmp_path, NO_DEVICES, f'is_software_renderer "{name} (something)"; echo "RC=$?"' + ) + assert "RC=0" in result.stdout, result.stdout + result.stderr + + +class TestDiagnosticsDoNotPolluteCapturedOutput: + """install.sh runs under `set -Eeuo pipefail` with an ERR trap that calls + print_error. While print_error wrote to stdout, that message landed *inside* + `$(detect_whispercpp_backends)` -- so a machine with a working Vulkan GPU + parsed garbage, fell back to CPU, and never logged the error, because the + substitution had swallowed it. `vulkaninfo --features` exits non-zero on real + drivers, which is what fired the trap. + """ + + # A working hardware GPU, and --features exiting non-zero as it does in practice. + STUB = """#!/bin/bash +case "$1" in + --summary) echo "\tdeviceName = Intel(R) Graphics (LNL)" ;; + --features) echo "VK_KHR_16bit_storage"; exit 1 ;; +esac +""" + + FUNCTIONS = ( + "is_software_renderer", + "vulkan_device_names", + "detect_nvidia_gpu", + "detect_vulkan", + "check_vulkan_gpu_compatibility", + "cuda_toolkit_root_has_runtime_library", + "find_valid_cuda_toolkit_root", + "detect_whispercpp_backends", + # the real ones, so the test fails if they go back to stdout + "print_error", + "print_warning", + ) + + def _script(self, tmp_path: Path, body: str) -> subprocess.CompletedProcess: + binary = tmp_path / "bin" + binary.mkdir(exist_ok=True) + for tool in NEEDED_TOOLS + ("find", "ls", "wc", "dirname", "basename", "readlink"): + found = shutil.which(tool) + if found and not (binary / tool).exists(): + (binary / tool).symlink_to(found) + stub = binary / "vulkaninfo" + stub.write_text(self.STUB) + stub.chmod(0o755) + + chunks = [PATTERNS.group(0)] + for name in self.FUNCTIONS: + match = re.search(rf"^{name}\(\) \{{.*?^\}}", SOURCE, re.M | re.S) + assert match, f"{name} not found in install.sh" + chunks.append(match.group(0)) + + prelude = ( + "set -Eeuo pipefail\n" + f'export PATH="{binary}"\n' + 'HAS_NVIDIA_GPU="no"\nHAS_VULKAN="no"\nVULKAN_DEVICE=""\n' + 'VULKAN_SOFTWARE_DEVICE=""\nGPU_NAME=""\nGPU_MEMORY=""\n' + 'INSTALL_LOG_FILE="/dev/null"\n' + 'print_info() { echo "INFO: $*"; }\n' + 'print_success() { echo "SUCCESS: $*"; }\n' + ) + trap = ( + "trap 'print_error \"Unexpected error near line $LINENO " + "(exit code $?); see $INSTALL_LOG_FILE\"' ERR\n" + ) + return subprocess.run( + ["bash", "-c", prelude + "\n".join(chunks) + "\n" + trap + body], + capture_output=True, + text=True, + timeout=30, + ) + + def test_print_error_stays_off_stdout(self, tmp_path): + result = self._script(tmp_path, 'print_error "boom"') + assert "boom" not in result.stdout + assert "boom" in result.stderr + + def test_print_warning_stays_off_stdout(self, tmp_path): + result = self._script(tmp_path, 'print_warning "careful"') + assert "careful" not in result.stdout + assert "careful" in result.stderr + + def test_backend_info_is_a_single_clean_line(self, tmp_path): + result = self._script( + tmp_path, 'B=$(detect_whispercpp_backends); printf "%s" "$B" | wc -l; echo "[$B]"' + ) + assert "ERROR" not in result.stdout, f"trap output captured:\n{result.stdout}" + lines = result.stdout.splitlines() + assert lines, result.stdout + result.stderr + assert lines[0] == "0", f"BACKEND_INFO spans several lines:\n{result.stdout}" + + def test_a_working_gpu_is_still_offered(self, tmp_path): + """The user-visible symptom: Step 3 refused GPU on hardware that supports it.""" + result = self._script( + tmp_path, + 'B=$(detect_whispercpp_backends); CAN=$(echo "$B" | cut -d: -f3); echo "CAN=[$CAN]"', + ) + assert "CAN=[true]" in result.stdout, result.stdout + result.stderr diff --git a/tests/test_model_checksums.py b/tests/test_model_checksums.py index 01de03a..bbbb74a 100644 --- a/tests/test_model_checksums.py +++ b/tests/test_model_checksums.py @@ -571,5 +571,98 @@ download_model_file() { self.assertEqual(leftovers, []) +class TestReleaseWithoutAManifest(unittest.TestCase): + """install.sh comes from main; the tree it clones is the last release tag. + + An absent manifest means the release predates pinning, so models are left to + first run. A manifest that omits a model still fails closed. + """ + + SOURCE = INSTALL_SH.read_text() + + PRELUDE = """ +set -uo pipefail +print_info() { echo "INFO: $*"; } +print_warning() { echo "WARNING: $*"; } +print_error() { echo "ERROR: $*"; } +print_success() { echo "SUCCESS: $*"; } +command_exists() { command -v "$1" >/dev/null 2>&1; } +check_connectivity() { return 0; } +download_model_file() { echo "DOWNLOAD ATTEMPTED"; return 1; } +""" + + def _function_body(self, name: str) -> str: + start = self.SOURCE.index(f"\n{name}() {{") + end = self.SOURCE.index("\n}\n", start) + len("\n}\n") + return self.SOURCE[start:end] + + def _run(self, script: str, functions=()) -> subprocess.CompletedProcess: + body = self.PRELUDE + "\n".join(self._function_body(n) for n in functions) + return subprocess.run(["bash", "-c", body + "\n" + script], capture_output=True, text=True) + + def test_available_when_the_manifest_ships(self): + result = self._run( + f'MODEL_CHECKSUMS_FILE="{REPO_ROOT}/src/vocalinux/utils/model_checksums.txt"; ' + "model_verification_available", + functions=("model_verification_available",), + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_unavailable_when_the_release_predates_pinning(self): + with TemporaryDirectory() as tmp: + result = self._run( + f'MODEL_CHECKSUMS_FILE="{tmp}/nope.txt"; model_verification_available', + functions=("model_verification_available",), + ) + self.assertEqual(result.returncode, 1) + + def test_vosk_refuses_before_spending_the_download(self): + """The 40MB fetch must not happen when nothing can verify it.""" + with TemporaryDirectory() as tmp: + result = self._run( + f'DATA_DIR="{tmp}/data"; VOCALINUX_TMP_DIR="{tmp}/tmp"; ' + f'mkdir -p "$DATA_DIR" "$VOCALINUX_TMP_DIR"; ' + f'MODEL_CHECKSUMS_FILE="{tmp}/nope.txt"; ' + "install_vosk_models; echo RC=$?", + functions=("pinned_digest_for", "install_vosk_models"), + ) + out = result.stdout + result.stderr + self.assertIn("RC=1", out) + self.assertIn("No checksum is pinned", out) + self.assertNotIn("DOWNLOAD ATTEMPTED", out, "refused only after downloading 40MB") + + def _model_install_block(self) -> str: + """The top-level section deciding which models are pre-downloaded.""" + start = self.SOURCE.index("# Install models based on selected engine") + end = self.SOURCE.index("# config.json survives reinstalls", start) + return self.SOURCE[start:end] + + def test_the_manifest_dependent_downloads_are_gated(self): + """Both engines whose digests live in the manifest sit behind the gate.""" + block = self._model_install_block() + for call in ("install_whispercpp_model ||", "install_vosk_models ||"): + gate = block.rindex("if model_verification_available; then", 0, block.index(call)) + self.assertLess(gate, block.index(call), f"{call} is not behind the gate") + + def test_openai_whisper_stays_ungated(self): + """It verifies against the sha256 in its own URL, so needs no manifest.""" + block = self._model_install_block() + call = block.index("install_whisper_model ||") + preceding = block[:call] + self.assertGreater( + preceding.rindex("fi"), + preceding.rindex("if model_verification_available; then"), + "the OpenAI Whisper download must not be behind the manifest gate", + ) + + def test_the_skew_is_explained_once(self): + block = self._model_install_block() + self.assertEqual( + block.count("if ! model_verification_available; then"), + 1, + "the reason models are skipped should be stated once, not per engine", + ) + + if __name__ == "__main__": unittest.main()