Files
voculinux/tests/test_model_checksums.py
T
Jatin K MalikandGitHub cdfe541c25 feat(speech_recognition): add faster-whisper engine backend (#543)
* feat(speech_recognition): add EngineType enum and Engine Protocol

Introduce a typed EngineType enum and an Engine Protocol in common_types.py,
plus an engines/ registry so new speech recognition backends can be plugged in
without scattering string comparisons across the manager.

* feat(speech_recognition): add faster-whisper engine backend

Add a faster-whisper engine implementation using CTranslate2/INT8 for fast
CPU inference. Includes model metadata utility, wiring through the recognition
manager, CLI, settings UI, config, optional dependency, and unit tests. Also
updates the suspend-handler test fixture for the new engine attribute.

* style(ui): format faster-whisper imports for isort 8

* test(speech_recognition): expand faster-whisper coverage for codecov

* test(speech_recognition): fix failing faster-whisper coverage tests

- Import missing get_compute_type helper

- Add tests for English-only detection, cache exceptions, and import-error registry path

- Clear _has_torch_cuda lru_cache around exception test to avoid cached True

- Replace broad builtins.__import__ patch with a fake engine module for registry test

- Restore registry module state after test

* test(speech_recognition): add coverage for faster-whisper edge cases

Cover previously missing lines in:

- faster_whisper_model_info.py: HF cache path/none, hf_hub lookup, fallback traversal, torch CUDA true, psutil exception

- faster_whisper_engine.py: model None guard after is_ready()

- engine registry import-error path

* feat(install): add faster_whisper engine option to install.sh

- Add faster_whisper to --engine help text and examples

- Add faster-whisper as interactive option 5 with description

- Install faster-whisper package and verify import at install time

- Add fallback to whisper.cpp if faster-whisper installation fails

- Add smoke test for faster-whisper import

- Update welcome message engine display name

- Note: faster-whisper models download automatically on first use

* refactor(faster_whisper): apply ponytail review cleanups

- Inline FASTER_WHISPER_MODEL_INFO dict literal; drop unused AVAILABLE_MODELS/MODEL_SIZES

- Reuse _has_torch_cuda() in FasterWhisperEngine.device property

- Remove redundant model None guard after is_ready() verification

- Shrink faster-whisper default config in install.sh to engine + model_size

- Update device tests to manage _has_torch_cuda lru_cache

- Remove test for deleted dead-code guard

* fix(faster_whisper): integrate optional engine with current main

Keep whisper_cpp as default and Parakeet as an extra. Put engine selection
in the Advanced island, install faster-whisper via the optional extra, and
refuse unpinned Hugging Face downloads until model_checksums.txt pins them.

* fix(faster_whisper): download verified models and refresh uv.lock

Settings already opened a download dialog for uncached faster-whisper, but
init only raised ChecksumError. Wire a Parakeet-style downloader that stores
bundles under models_dir, verifies each file against model_checksums.txt, and
loads WhisperModel from that path. Regenerate uv.lock for the optional
faster-whisper extra, pin digests for all eleven Systran models, and add the
missing -> None return annotations.

* fix(faster_whisper): clear engine on unload and fix auto-pause fixture

Auto-pause tests stub __init__, so they never set _faster_whisper_engine.
reinitialize_after_resume then AttributeErrors once Test Python runs.
Mirror the suspend-handler fixture, and release the faster-whisper engine
inside unload_model the same way resume already does.

* fix(faster_whisper): restore previous engine after a failed switch

reconfigure() selected the new engine and released the live model before
init. A failed faster-whisper download then left the manager in ERROR on
the new backend while Settings reverted the pickers to the saved engine,
so dictation stayed dead until restart. Reload the previous engine from
disk (no download) before re-raising so the UI and live manager agree.

* fix(speech_recognition): restore all live settings after failed switch

Failed reconfigure already put the previous engine back, but VAD, device,
remote API, and whisper.cpp knobs stayed on the attempted values while
config on disk did not. Snapshot every field reconfigure writes and restore
the lot before reloading the old backend. Also bind the real
_init_selected_engine onto the MagicMock in the force_reinit test.

* fix(speech_recognition): restore every live field after failed reconfigure

A failed engine switch already reloaded the previous backend, but Greptile
could still miss that VAD, audio device, remote API, and whisper.cpp knobs
were part of the snapshot. Keep one explicit attr list for snapshot/restore,
including the derived voice-commands flag, so live state matches persisted
config after a failed Settings apply.

* chore(speech_recognition): sign tip after rollback follow-up
2026-09-07 20:47:56 -07:00

705 lines
29 KiB
Python

"""Tests for model download integrity verification.
The manifest-coverage test is what makes verification safe to fail closed: a
model added to the app without regenerating model_checksums.txt is caught here
rather than on a user's machine, where it would surface as an unexplained
refusal to install.
"""
import base64
import hashlib
import os
import shutil
import subprocess
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from vocalinux.utils import model_checksums
from vocalinux.utils.faster_whisper_model_info import (
FASTER_WHISPER_MODEL_INFO,
)
from vocalinux.utils.faster_whisper_model_info import manifest_key as faster_whisper_manifest_key
from vocalinux.utils.faster_whisper_model_info import model_files as faster_whisper_model_files
from vocalinux.utils.model_checksums import (
VERIFICATION_STAMP_NAME,
ChecksumError,
Expected,
expected_for,
file_digest,
pinned_filenames,
verify_file,
verify_model_file,
whispercpp_revision,
write_verification_stamp,
)
from vocalinux.utils.parakeet_model_info import MODEL_FILES, PARAKEET_MODEL_INFO, manifest_key
from vocalinux.utils.vosk_model_info import VOSK_MODEL_INFO
from vocalinux.utils.whispercpp_model_info import (
WHISPERCPP_MODEL_INFO,
whispercpp_model_file,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
INSTALL_SH = REPO_ROOT / "install.sh"
class TestManifestCoverage(unittest.TestCase):
"""Every model the application can download must have a pinned digest."""
def test_every_whispercpp_model_is_pinned(self):
pinned = pinned_filenames()
missing = [
whispercpp_model_file(name)
for name in WHISPERCPP_MODEL_INFO
if whispercpp_model_file(name) not in pinned
]
self.assertEqual(
missing,
[],
"whisper.cpp models without a pinned checksum; "
"run `just model-checksums` to refresh the manifest",
)
def test_every_vosk_model_is_pinned(self):
pinned = pinned_filenames()
missing = sorted(
{
f"{name}.zip"
for info in VOSK_MODEL_INFO.values()
for name in info["languages"].values()
if name and f"{name}.zip" not in pinned
}
)
self.assertEqual(
missing,
[],
"VOSK models without a pinned checksum; "
"run `just model-checksums` to refresh the manifest",
)
def test_every_parakeet_model_is_pinned(self):
pinned = pinned_filenames()
missing = [
manifest_key(name, filename)
for name in PARAKEET_MODEL_INFO
for filename in MODEL_FILES
if manifest_key(name, filename) not in pinned
]
self.assertEqual(
missing,
[],
"Parakeet models without a pinned checksum; "
"run `just model-checksums` to refresh the manifest",
)
def test_every_faster_whisper_model_is_pinned(self):
pinned = pinned_filenames()
missing = [
faster_whisper_manifest_key(name, filename)
for name in FASTER_WHISPER_MODEL_INFO
for filename in faster_whisper_model_files(name)
if faster_whisper_manifest_key(name, filename) not in pinned
]
self.assertEqual(
missing,
[],
"faster-whisper models without a pinned checksum; "
"run `just model-checksums` to refresh the manifest",
)
def test_manifest_entries_are_well_formed(self):
for filename in pinned_filenames():
expected = expected_for(filename)
# sha256 only: the VOSK entries used to copy the md5 Alphacephei
# publishes, which pins nothing they could not change themselves.
self.assertEqual(expected.algo, "sha256", filename)
self.assertEqual(len(expected.digest), 64, filename)
self.assertRegex(expected.digest, r"^[0-9a-f]+$", filename)
self.assertGreater(expected.size, 0, filename)
def test_whispercpp_revision_is_pinned_to_a_commit(self):
revision = whispercpp_revision()
self.assertNotEqual(revision, "main", "whisper.cpp must not track a branch")
self.assertRegex(revision, r"^[0-9a-f]{40}$")
def test_download_urls_use_the_pinned_revision(self):
revision = whispercpp_revision()
for name, info in WHISPERCPP_MODEL_INFO.items():
self.assertIn(f"/resolve/{revision}/", info["url"], name)
class TestVerifyFile(unittest.TestCase):
def setUp(self):
self.tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
self.payload = b"vocalinux model payload"
self.path = self.tmp / "model.bin"
self.path.write_bytes(self.payload)
self.digest = hashlib.sha256(self.payload).hexdigest()
def test_accepts_a_matching_file(self):
verify_file(str(self.path), Expected("sha256", self.digest, len(self.payload)))
def test_rejects_a_wrong_digest(self):
with self.assertRaises(ChecksumError) as ctx:
verify_file(str(self.path), Expected("sha256", "0" * 64, len(self.payload)))
self.assertIn("failed sha256 verification", str(ctx.exception))
def test_reports_truncation_as_a_size_problem(self):
"""A short read is the common failure; it must not read like tampering."""
with self.assertRaises(ChecksumError) as ctx:
verify_file(str(self.path), Expected("sha256", self.digest, len(self.payload) + 10))
self.assertIn("truncated", str(ctx.exception))
def test_size_zero_skips_the_size_check(self):
verify_file(str(self.path), Expected("sha256", self.digest, 0))
def test_md5_is_supported(self):
digest = hashlib.md5(self.payload).hexdigest()
verify_file(str(self.path), Expected("md5", digest, len(self.payload)))
def test_unknown_algorithm_is_rejected(self):
with self.assertRaises(ChecksumError):
file_digest(str(self.path), "not-a-hash")
def test_digest_matches_hashlib_for_a_multi_chunk_file(self):
big = self.tmp / "big.bin"
payload = os.urandom(model_checksums._CHUNK_SIZE * 2 + 7)
big.write_bytes(payload)
self.assertEqual(file_digest(str(big), "sha256"), hashlib.sha256(payload).hexdigest())
class TestVerifyModelFile(unittest.TestCase):
def setUp(self):
self.tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
def test_unpinned_file_is_refused(self):
"""Fail closed: no pin means no install, not a warning."""
path = self.tmp / "ggml-not-a-real-model.bin"
path.write_bytes(b"x")
with self.assertRaises(ChecksumError) as ctx:
verify_model_file(str(path))
self.assertIn("No checksum is pinned", str(ctx.exception))
def test_temp_name_is_looked_up_under_the_real_name(self):
"""Downloads land as *.tmp, so the lookup key must be overridable."""
path = self.tmp / "ggml-tiny.bin.tmp"
path.write_bytes(b"wrong content")
with self.assertRaises(ChecksumError) as ctx:
verify_model_file(str(path), "ggml-tiny.bin")
# Reached the digest comparison, so the manifest lookup succeeded.
self.assertIn("ggml-tiny.bin", str(ctx.exception))
self.assertNotIn("No checksum is pinned", str(ctx.exception))
def test_missing_manifest_refuses_everything(self):
with patch.object(model_checksums, "_manifest_text", return_value=""):
model_checksums._parse_manifest.cache_clear()
try:
path = self.tmp / "ggml-tiny.bin"
path.write_bytes(b"x")
with self.assertRaises(ChecksumError):
verify_model_file(str(path))
finally:
model_checksums._parse_manifest.cache_clear()
class TestInstallerVerification(unittest.TestCase):
"""The bash half must agree with the Python half on the same manifest."""
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; }
INSTALL_DIR="%s"
""" % str(REPO_ROOT)
FUNCTIONS = (
"compute_file_digest",
"verify_digest",
"verify_model_checksum",
"verify_openai_model_checksum",
"pinned_digest_for",
"whispercpp_pinned_revision",
)
def _source(self) -> str:
text = INSTALL_SH.read_text()
chunks = []
for name in self.FUNCTIONS:
start = text.index(f"\n{name}() {{")
end = text.index("\n}\n", start) + len("\n}\n")
chunks.append(text[start:end])
manifest_line = (
'MODEL_CHECKSUMS_FILE="$INSTALL_DIR/src/vocalinux/utils/model_checksums.txt"'
)
return self.PRELUDE + manifest_line + "\n" + "\n".join(chunks)
def _run(self, script: str) -> subprocess.CompletedProcess:
return subprocess.run(
["bash", "-c", self._source() + "\n" + script],
capture_output=True,
text=True,
)
def test_reads_the_same_revision_as_python(self):
result = self._run("whispercpp_pinned_revision")
self.assertEqual(result.stdout.strip(), whispercpp_revision())
def test_accepts_a_file_matching_the_manifest(self):
"""Build a file whose digest is the pinned one by asking Python for it."""
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
# Pick a manifest entry and forge a file that matches it, by rewriting the
# manifest to the digest of content we control.
payload = b"installer payload"
target = tmp / "ggml-tiny.bin"
target.write_bytes(payload)
manifest = tmp / "model_checksums.txt"
manifest.write_text(
"# whispercpp-revision: " + "a" * 40 + "\n"
f"ggml-tiny.bin sha256 {hashlib.sha256(payload).hexdigest()} {len(payload)}\n"
)
result = self._run(f'MODEL_CHECKSUMS_FILE="{manifest}"; verify_model_checksum "{target}"')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("verified (sha256)", result.stdout)
def test_rejects_a_digest_mismatch(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
target = tmp / "ggml-tiny.bin"
target.write_bytes(b"tampered")
manifest = tmp / "model_checksums.txt"
manifest.write_text(f"ggml-tiny.bin sha256 {'0' * 64} 8\n")
result = self._run(f'MODEL_CHECKSUMS_FILE="{manifest}"; verify_model_checksum "{target}"')
self.assertEqual(result.returncode, 1)
self.assertIn("failed sha256 verification", result.stdout)
def test_rejects_a_truncated_file_as_truncation(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
payload = b"short"
target = tmp / "ggml-tiny.bin"
target.write_bytes(payload)
manifest = tmp / "model_checksums.txt"
manifest.write_text(
f"ggml-tiny.bin sha256 {hashlib.sha256(payload).hexdigest()} 999999\n"
)
result = self._run(f'MODEL_CHECKSUMS_FILE="{manifest}"; verify_model_checksum "{target}"')
self.assertEqual(result.returncode, 1)
self.assertIn("truncated", result.stdout)
def test_refuses_an_unpinned_file(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
target = tmp / "ggml-unknown.bin"
target.write_bytes(b"x")
manifest = tmp / "model_checksums.txt"
manifest.write_text("ggml-tiny.bin sha256 " + "0" * 64 + " 1\n")
result = self._run(f'MODEL_CHECKSUMS_FILE="{manifest}"; verify_model_checksum "{target}"')
self.assertEqual(result.returncode, 1)
self.assertIn("No checksum is pinned", result.stdout)
def test_refuses_when_the_manifest_is_absent(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
target = tmp / "ggml-tiny.bin"
target.write_bytes(b"x")
result = self._run(
f'MODEL_CHECKSUMS_FILE="{tmp}/nope.txt"; verify_model_checksum "{target}"'
)
self.assertEqual(result.returncode, 1)
self.assertIn("Checksum manifest not found", result.stdout)
def test_openai_url_digest_is_honoured(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
payload = b"pretend checkpoint"
target = tmp / "tiny.pt"
target.write_bytes(payload)
url = (
"https://openaipublic.azureedge.net/main/whisper/models/"
f"{hashlib.sha256(payload).hexdigest()}/tiny.pt"
)
result = self._run(f'verify_openai_model_checksum "{target}" "{url}" "Whisper tiny"')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("verified (sha256)", result.stdout)
def test_openai_url_without_a_digest_is_refused(self):
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
target = tmp / "tiny.pt"
target.write_bytes(b"x")
result = self._run(
f'verify_openai_model_checksum "{target}" "https://example.com/tiny.pt" "Whisper tiny"'
)
self.assertEqual(result.returncode, 1)
self.assertIn("no sha256 path segment", result.stdout)
def test_bash_and_python_agree_on_a_real_manifest_entry(self):
"""Same file, same manifest, same verdict from both implementations."""
tmp = Path(self.enterContext(__import__("tempfile").TemporaryDirectory()))
target = tmp / "ggml-tiny.bin"
target.write_bytes(b"definitely not the real model")
result = self._run(f'verify_model_checksum "{target}" "ggml-tiny.bin"')
self.assertEqual(result.returncode, 1)
with self.assertRaises(ChecksumError):
verify_model_file(str(target), "ggml-tiny.bin")
class TestExistingModelsAreVerified(unittest.TestCase):
"""A model that is already on disk must be hashed, not trusted.
Regression cover for the review finding on #713: all three installer
functions returned early when the model file existed, so every re-run — and
every install predating checksum verification — kept an unverified model.
"""
SOURCE = INSTALL_SH.read_text()
def _function_body(self, name: str) -> str:
start = self.SOURCE.index(f"\n{name}() {{")
end = self.SOURCE.index("\n}\n", start)
return self.SOURCE[start:end]
def _exists_guard(self, name: str) -> str:
"""The part of the function before it starts downloading."""
body = self._function_body(name)
marker = body.find("Check internet connectivity")
return body[:marker] if marker != -1 else body
def test_whisper_hashes_an_existing_model(self):
guard = self._exists_guard("install_whisper_model")
self.assertIn("verify_openai_model_checksum", guard)
self.assertIn("rm -f", guard)
def test_whispercpp_hashes_an_existing_model(self):
guard = self._exists_guard("install_whispercpp_model")
self.assertIn("verify_model_checksum", guard)
self.assertIn("rm -f", guard)
def test_vosk_requires_a_matching_verification_stamp(self):
guard = self._exists_guard("install_vosk_models")
self.assertIn(VERIFICATION_STAMP_NAME, guard)
def test_vosk_keeps_an_unstamped_tree_until_it_has_a_replacement(self):
"""Deleting on sight would make every failed install a missing model.
No stamp is the ordinary case — an older install, or a tree the app
downloaded itself — and this runs on every ./install.sh. Removal belongs
after the replacement is downloaded, verified and unpacked, not before.
"""
guard = self._exists_guard("install_vosk_models")
self.assertNotIn("rm -rf", guard)
def test_vosk_unpacks_to_staging_and_swaps_only_what_it_verified(self):
body = self._function_body("install_vosk_models")
order = [
body.index("verify_model_checksum"),
body.index('unzip -q "$TEMP_ZIP" -d "$STAGING_DIR"'),
body.index(f'> "$STAGING_DIR/$SMALL_MODEL_NAME/{VERIFICATION_STAMP_NAME}"'),
body.index('mv "$STAGING_DIR/$SMALL_MODEL_NAME" "$SMALL_MODEL_PATH"'),
]
self.assertEqual(order, sorted(order), "verify, unpack, stamp, then swap")
def test_vosk_restores_the_old_tree_when_the_swap_fails(self):
body = self._function_body("install_vosk_models")
self.assertIn('mv "$REPLACED_DIR" "$SMALL_MODEL_PATH"', body)
def test_no_installer_path_returns_zero_on_an_unverified_file(self):
"""Each guard must verify before its `return 0`, not after."""
for name, checker in (
("install_whisper_model", "verify_openai_model_checksum"),
("install_whispercpp_model", "verify_model_checksum"),
("install_vosk_models", ".vocalinux_verified"),
):
guard = self._exists_guard(name)
self.assertLess(
guard.index(checker),
guard.index("return 0"),
f"{name} returns success before verifying an existing model",
)
class TestVerificationStamp(unittest.TestCase):
"""The stamp is the only thing that makes an extracted tree verifiable.
Two implementations write and read it — this module and install.sh — so the
name and the contents have to be the same on both sides.
"""
def test_the_installer_uses_the_same_stamp_name(self):
self.assertIn(VERIFICATION_STAMP_NAME, INSTALL_SH.read_text())
def test_it_records_the_pinned_digest_of_the_archive(self):
with TemporaryDirectory() as tree:
write_verification_stamp(tree, "vosk-model-small-en-us-0.15.zip")
written = Path(tree, VERIFICATION_STAMP_NAME).read_text()
pinned = expected_for("vosk-model-small-en-us-0.15.zip")
self.assertIsNotNone(pinned)
# install.sh compares with $(cat ...), which strips the trailing newline
# the shell's own writer leaves; match it byte for byte.
self.assertEqual(written, f"{pinned.digest}\n")
def test_it_refuses_to_stamp_an_unpinned_archive(self):
with TemporaryDirectory() as tree:
with self.assertRaises(ChecksumError):
write_verification_stamp(tree, "not-a-model-we-ship.zip")
self.assertFalse(Path(tree, VERIFICATION_STAMP_NAME).exists())
class TestVoskInstallerReplacesRatherThanDeletes(unittest.TestCase):
"""Run install_vosk_models for real, with the network and unzip around it.
The static checks above pin the shape of the code; these pin the behaviour
the review asked for: an install that fails after finding an unstamped tree
must leave that tree where it was, because "no stamp" is the ordinary state
of any model the app downloaded itself or that predates stamping.
"""
# A zip whose top-level directory is the one the real archive unpacks to.
# Embedded rather than built here so no test that mocks zipfile can reach it.
FIXTURE_ZIP = base64.b64decode(
"UEsDBBQAAAAIAM92F12oDuXYFgAAAIgTAAAoAAAAdm9zay1tb2RlbC1zbWFsbC1lbi11cy0wLjE1L2FtL2Zp"
"bmFsLm1kbO3BMQEAAADCoPVPbQo/oAAAAACAtwFQSwMEFAAAAAgAz3YXXVXAPAMTAAAAEQAAACsAAAB2b3Nr"
"LW1vZGVsLXNtYWxsLWVuLXVzLTAuMTUvY29uZi9tb2RlbC5jb25m09XNzczTTUwuySxLtTUyMOACAFBLAQIU"
"AxQAAAAIAM92F12oDuXYFgAAAIgTAAAoAAAAAAAAAAAAAACAAQAAAAB2b3NrLW1vZGVsLXNtYWxsLWVuLXVz"
"LTAuMTUvYW0vZmluYWwubWRsUEsBAhQDFAAAAAgAz3YXXVXAPAMTAAAAEQAAACsAAAAAAAAAAAAAAIABXAAA"
"AHZvc2stbW9kZWwtc21hbGwtZW4tdXMtMC4xNS9jb25mL21vZGVsLmNvbmZQSwUGAAAAAAIAAgCvAAAAuAAA"
"AAAA"
)
MODEL_NAME = "vosk-model-small-en-us-0.15"
PRELUDE = """
set -Eeuo 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; }
DATA_DIR="$ROOT/data"
VOCALINUX_TMP_DIR="$ROOT/tmp"
MODEL_CHECKSUMS_FILE="$ROOT/model_checksums.txt"
mkdir -p "$VOCALINUX_TMP_DIR"
# The installer only probes for these; the download itself is stubbed out.
check_connectivity() { return 0; }
download_model_file() {
echo "call" >> "$ROOT/downloads"
[ -z "${DOWNLOAD_FAILS:-}" ] || return 1
cp "$ROOT/${SERVED_ZIP:-fixture.zip}" "$2"
}
"""
FUNCTIONS = (
"compute_file_digest",
"verify_digest",
"verify_model_checksum",
"pinned_digest_for",
"install_vosk_models",
)
def setUp(self):
if not shutil.which("unzip"):
self.skipTest("unzip is required to exercise the extraction path")
self.root = Path(self.enterContext(TemporaryDirectory()))
(self.root / "fixture.zip").write_bytes(self.FIXTURE_ZIP)
# A manifest of our own, so the fixture is what the pin describes.
digest = hashlib.sha256(self.FIXTURE_ZIP).hexdigest()
self.digest = digest
(self.root / "model_checksums.txt").write_text(
f"{self.MODEL_NAME}.zip sha256 {digest} {len(self.FIXTURE_ZIP)}\n"
)
self.models_dir = self.root / "data" / "models"
self.model_path = self.models_dir / self.MODEL_NAME
self.stamp = self.model_path / VERIFICATION_STAMP_NAME
# install_vosk_models probes for a downloader with `command -v` before it
# downloads anything. download_model_file is stubbed, so the probe is all
# that has to succeed, and a stub on PATH keeps the test from depending on
# the host having wget or curl.
self.fake_bin = self.root / "bin"
self.fake_bin.mkdir()
curl = self.fake_bin / "curl"
curl.write_text("#!/bin/sh\necho 'the stubbed downloader must not run' >&2\nexit 1\n")
curl.chmod(0o755)
def _source(self) -> str:
text = INSTALL_SH.read_text()
chunks = []
for name in self.FUNCTIONS:
start = text.index(f"\n{name}() {{")
end = text.index("\n}\n", start) + len("\n}\n")
chunks.append(text[start:end])
return self.PRELUDE + "\n".join(chunks)
def _install(self, **env) -> subprocess.CompletedProcess:
script = (
self._source() + '\nif install_vosk_models; then echo "RC=0"; else echo "RC=$?"; fi\n'
)
return subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
env={
**os.environ,
"PATH": f"{self.fake_bin}{os.pathsep}{os.environ['PATH']}",
"ROOT": str(self.root),
**env,
},
)
@staticmethod
def _rc(result: subprocess.CompletedProcess) -> str:
return result.stdout.strip().splitlines()[-1]
def _downloads(self) -> int:
path = self.root / "downloads"
return len(path.read_text().splitlines()) if path.exists() else 0
def test_fresh_install_extracts_and_stamps(self):
result = self._install()
self.assertEqual(self._rc(result), "RC=0", result.stdout + result.stderr)
self.assertTrue((self.model_path / "am" / "final.mdl").exists())
self.assertEqual(self.stamp.read_text().strip(), self.digest)
def test_a_stamped_tree_is_not_downloaded_again(self):
self.assertEqual(self._rc(self._install()), "RC=0")
self.assertEqual(self._rc(self._install()), "RC=0")
self.assertEqual(self._downloads(), 1, "the second run re-fetched a verified model")
def test_an_unstamped_tree_survives_a_failed_download(self):
"""The regression: the old code deleted here and then failed to download."""
(self.model_path / "am").mkdir(parents=True)
(self.model_path / "am" / "final.mdl").write_bytes(b"an older install")
result = self._install(DOWNLOAD_FAILS="1")
self.assertEqual(self._rc(result), "RC=1")
self.assertEqual((self.model_path / "am" / "final.mdl").read_bytes(), b"an older install")
def test_an_unstamped_tree_survives_a_download_that_fails_verification(self):
(self.root / "tampered.zip").write_bytes(self.FIXTURE_ZIP + b"trailing junk")
(self.model_path / "am").mkdir(parents=True)
(self.model_path / "am" / "final.mdl").write_bytes(b"an older install")
result = self._install(SERVED_ZIP="tampered.zip")
self.assertEqual(self._rc(result), "RC=1")
self.assertEqual((self.model_path / "am" / "final.mdl").read_bytes(), b"an older install")
def test_an_unstamped_tree_is_replaced_once_a_verified_one_is_ready(self):
(self.model_path / "am").mkdir(parents=True)
(self.model_path / "am" / "final.mdl").write_bytes(b"an older install")
(self.model_path / "stale-file").write_text("gone after the swap")
result = self._install()
self.assertEqual(self._rc(result), "RC=0", result.stdout + result.stderr)
self.assertNotEqual(
(self.model_path / "am" / "final.mdl").read_bytes(), b"an older install"
)
self.assertFalse((self.model_path / "stale-file").exists(), "the swap must not merge")
self.assertEqual(self.stamp.read_text().strip(), self.digest)
def test_no_scratch_directories_are_left_behind(self):
self._install()
leftovers = [p.name for p in self.models_dir.iterdir() if p.name != self.MODEL_NAME]
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()