From 61afa08b8b5f8f869941aa0c8673435da250a022 Mon Sep 17 00:00:00 2001 From: karl Date: Tue, 13 Oct 2026 21:30:48 +0200 Subject: [PATCH] Require ELF closure verification when staging Linux speech runtime --- docs/LINUX-SPEECH-0.1.6.md | 13 +++++++ tools/stage_speech_worker.py | 12 +++++++ tools/test_stage_speech_worker.py | 59 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/docs/LINUX-SPEECH-0.1.6.md b/docs/LINUX-SPEECH-0.1.6.md index b12c354..ecaa11f 100644 --- a/docs/LINUX-SPEECH-0.1.6.md +++ b/docs/LINUX-SPEECH-0.1.6.md @@ -51,3 +51,16 @@ The running original snapshot does not contain this correction. Its output must not be released as corrected evidence. Next: wait for its terminal result, inspect the complete ELF closure, then validate a corrected normal runtime outside the build tree with `LD_LIBRARY_PATH` unset and forced CPU recognition. + +Linux staging now invokes the same ELF closure verification after manifest, +architecture and CPU/acceleration checks. A valid inventory cannot bypass this +check; missing `readelf` also fails staging. Six targeted staging tests passed, +including inspected ELF coverage, failure propagation and Windows independence. + +The upcoming real CPU test fixture was prepared from the unchanged verified +English WAV (source SHA-256 +`0aa1fa97d367d987bfb3bb01d7eca11db77a544552ccb8dc0a78ad74d0b496ea`). +Exact mono 16-kHz signed PCM16 samples were converted to float32 by division by +32768, preserving all 360192 samples. PCM SHA-256: +`56948226ac5a0ad4b70523a856dad0851929c7067957f339e9a5b30fc9b369ce`. +Fixture preparation succeeded; native recognition has not yet been executed. diff --git a/tools/stage_speech_worker.py b/tools/stage_speech_worker.py index 2d87233..b50c831 100644 --- a/tools/stage_speech_worker.py +++ b/tools/stage_speech_worker.py @@ -1,6 +1,7 @@ """Verify and atomically stage the isolated normal speech runtime; no inference.""" import argparse import hashlib +import importlib.util import json from pathlib import Path import re @@ -28,6 +29,15 @@ def input_path(root, name): return Path(root) / 'tools' / (name if '/' in name else 'speech-runtime/' + name) +def verify_linux_closure(directory): + # Load our pinned builder, never code from the candidate runtime directory. + path = ROOT / 'tools/speech-runtime/build.py' + spec = importlib.util.spec_from_file_location('searchi_speech_build', path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.verify_linux_closure(directory) + + def inventory(directory): path = Path(directory) / 'runtime-manifest.json' if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size <= 512 * 1024: @@ -84,6 +94,8 @@ def verify_directory(directory, root=ROOT, platform='windows', architecture='x86 'VULKAN-LICENSE.txt', 'MSVC-REDISTRIBUTION.txt'} | CRT_LIBRARIES if platform == 'windows' and not windows_libraries.issubset(names): raise ValueError('Speech runtime lacks required shared libraries') + if platform == 'linux': + verify_linux_closure(directory) records = {'speech-runtime/' + record['path']: record for record in manifest['files']} manifest_path = directory / 'runtime-manifest.json' records['speech-runtime/runtime-manifest.json'] = { diff --git a/tools/test_stage_speech_worker.py b/tools/test_stage_speech_worker.py index 0cbf1f4..52db349 100644 --- a/tools/test_stage_speech_worker.py +++ b/tools/test_stage_speech_worker.py @@ -3,12 +3,71 @@ from pathlib import Path import shutil import tempfile import unittest +from unittest import mock import stage_speech_worker as speech import test_package_windows as windows class SpeechStagingTests(unittest.TestCase): + def linux_fixture(self, directory): + root, resources, _, _ = windows.PackageTests().fixture(directory) + runtime = resources / 'speech-runtime' + manifest_path = runtime / 'runtime-manifest.json' + manifest = json.loads(manifest_path.read_text()) + for record in manifest['files']: + (runtime / record['path']).unlink() + manifest['files'] = [] + for name in ('searchi-speech-worker', 'libggml-cpu-x64.so', 'libggml-vulkan.so'): + path = runtime / name + path.write_bytes(b'ELF fixture inspected by mocked readelf') + manifest['files'].append({'path': name, 'bytes': path.stat().st_size, + 'sha256': speech.digest(path)}) + manifest_path.write_text(json.dumps(manifest)) + return root, runtime + + def test_linux_hash_valid_inventory_requires_elf_closure(self): + with tempfile.TemporaryDirectory() as directory: + root, runtime = self.linux_fixture(directory) + with mock.patch.object(speech, 'identity', return_value=('linux', 'x86_64', None)), \ + mock.patch.object(speech, 'verify_linux_closure') as closure: + records = speech.verify_directory(runtime, root, platform='linux') + closure.assert_called_once_with(runtime) + self.assertIn('speech-runtime/searchi-speech-worker', records) + + def test_linux_closure_failure_and_missing_readelf_are_not_bypassed(self): + for error in (ValueError('Unpackaged Linux speech dependency'), + FileNotFoundError('readelf is required')): + with self.subTest(error=type(error)), tempfile.TemporaryDirectory() as directory: + root, runtime = self.linux_fixture(directory) + with mock.patch.object(speech, 'identity', return_value=('linux', 'x86_64', None)), \ + mock.patch.object(speech, 'verify_linux_closure', side_effect=error): + with self.assertRaises(type(error)): + speech.verify_directory(runtime, root, platform='linux') + + def test_windows_does_not_require_elf_tools(self): + with tempfile.TemporaryDirectory() as directory: + root, resources, _, _ = windows.PackageTests().fixture(directory) + with mock.patch.object(speech, 'verify_linux_closure') as closure: + speech.verify_directory(resources / 'speech-runtime', root) + closure.assert_not_called() + + def test_loaded_builder_checks_every_linux_elf_and_rejects_build_tree_runpath(self): + with tempfile.TemporaryDirectory() as directory: + _, runtime = self.linux_fixture(directory) + valid = '(RUNPATH) Library runpath: [$ORIGIN]\n(NEEDED) Shared library: [libc.so.6]\n' + with mock.patch('subprocess.check_output', return_value=valid) as readelf: + speech.verify_linux_closure(runtime) + self.assertEqual(readelf.call_count, 3) + self.assertEqual({call.args[0][2] for call in readelf.call_args_list}, + {str(runtime / name) for name in + ('searchi-speech-worker', 'libggml-cpu-x64.so', 'libggml-vulkan.so')}) + self.assertTrue(all(call.args[0][:2] == ['readelf', '--dynamic'] + for call in readelf.call_args_list)) + with mock.patch('subprocess.check_output', return_value=valid.replace('$ORIGIN', '/build/whisper')): + with self.assertRaisesRegex(ValueError, 'origin-only RUNPATH'): + speech.verify_linux_closure(runtime) + def test_replacement_prunes_obsolete_declared_code_and_keeps_closed_inventory(self): with tempfile.TemporaryDirectory() as directory: root, resources, _, _ = windows.PackageTests().fixture(directory)