From 0bba1a0aa7aa1baae923f51b2bdc2c366c9516d5 Mon Sep 17 00:00:00 2001 From: karl Date: Mon, 12 Oct 2026 22:35:34 +0200 Subject: [PATCH] Ship portable CPU dispatch and verified shared runtime dependencies --- .gitattributes | 2 +- .gitignore | 3 + crates/searchi-semantic/native/CMakeLists.txt | 28 +++++- crates/searchi-semantic/native/worker.cpp | 39 +++++++- docs/BUILDING.md | 11 ++- docs/ROADMAP.md | 5 + docs/SEMANTIC.md | 9 +- docs/VALIDATION.md | 29 ++++++ .../windows-package-cpu-baseline.json | 15 +++ .../windows-package-cpu-dispatch.json | 15 +++ tools/embeddinggemma2/build_runtime.py | 66 +++++++++++++ tools/embeddinggemma2/cpu-dispatch.patch | 98 +++++++++++++++++++ tools/embeddinggemma2/cpu-only-loader.patch | 31 ++++++ tools/embeddinggemma2/package_smoke.py | 11 ++- tools/embeddinggemma2/stage_runtime.py | 57 ++++++++++- tools/embeddinggemma2/test_build_runtime.py | 67 +++++++++++++ tools/embeddinggemma2/test_stage_runtime.py | 65 ++++++++++++ tools/package_windows.py | 46 ++++++++- tools/test_package_windows.py | 95 +++++++++++++++++- 19 files changed, 675 insertions(+), 17 deletions(-) create mode 100644 docs/evidence/windows-package-cpu-baseline.json create mode 100644 docs/evidence/windows-package-cpu-dispatch.json create mode 100644 tools/embeddinggemma2/cpu-dispatch.patch create mode 100644 tools/embeddinggemma2/cpu-only-loader.patch create mode 100644 tools/embeddinggemma2/test_build_runtime.py diff --git a/.gitattributes b/.gitattributes index 2eb11d4..431e323 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,4 +3,4 @@ *.jpg binary *.ico binary # Unified-diff context lines intentionally contain a single leading space. -tools/embeddinggemma2/no-logits.patch -whitespace +tools/embeddinggemma2/*.patch -whitespace diff --git a/.gitignore b/.gitignore index 445781d..0d8734f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,10 @@ __pycache__/ /apps/desktop/src-tauri/gen/ /apps/desktop/src-tauri/resources/searchi-gemma-worker* /apps/desktop/src-tauri/resources/vulkan-1.dll +/apps/desktop/src-tauri/resources/*.dll +/apps/desktop/src-tauri/resources/runtime-libraries.json /apps/desktop/src-tauri/resources/VULKAN-LICENSE.txt +/apps/desktop/src-tauri/resources/MSVC-REDISTRIBUTION.txt /apps/desktop/src-tauri/resources/embeddinggemma2/ /apps/desktop/src-tauri/resources/media-runtime/bin/ /apps/desktop/src-tauri/icons/android/ diff --git a/crates/searchi-semantic/native/CMakeLists.txt b/crates/searchi-semantic/native/CMakeLists.txt index 949db19..dd949cf 100644 --- a/crates/searchi-semantic/native/CMakeLists.txt +++ b/crates/searchi-semantic/native/CMakeLists.txt @@ -4,10 +4,28 @@ if(NOT DEFINED LLAMA_CPP_DIR OR NOT EXISTS "${LLAMA_CPP_DIR}/include/llama.h") message(FATAL_ERROR "Supply -DLLAMA_CPP_DIR=") endif() set(CMAKE_CXX_STANDARD 17) -if(MSVC) - set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" CACHE STRING "Standalone worker static MSVC runtime" FORCE) +# CPU-only builds remain small internal VM tools. Normal Windows x64 builds +# ship baseline and optimized CPU modules in the same GPU-capable package. +set(SEARCHI_ACCELERATION "auto" CACHE STRING "auto; explicit developer overrides: cpu, metal, vulkan, cuda") +set(SEARCHI_CPU_DISPATCH OFF) +if(WIN32 AND CMAKE_SIZEOF_VOID_P EQUAL 8 AND + (CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$" OR CMAKE_GENERATOR_PLATFORM STREQUAL "x64") AND + NOT SEARCHI_ACCELERATION STREQUAL "cpu") + set(SEARCHI_CPU_DISPATCH ON) endif() -set(BUILD_SHARED_LIBS OFF CACHE BOOL "Static local worker runtime" FORCE) +if(MSVC) + # llama and ggml exchange FILE* and other CRT-owned state across DLLs. + # Every shared-runtime component must use the same dynamic CRT. + if(SEARCHI_CPU_DISPATCH) + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL" CACHE STRING "Shared worker CRT across runtime DLLs" FORCE) + else() + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" CACHE STRING "Internal static worker CRT" FORCE) + endif() +endif() +set(BUILD_SHARED_LIBS ${SEARCHI_CPU_DISPATCH} CACHE BOOL "Shared runtime for Windows CPU dispatch" FORCE) +set(GGML_BACKEND_DL ${SEARCHI_CPU_DISPATCH} CACHE BOOL "Load compatible packaged CPU backend" FORCE) +set(GGML_CPU_ALL_VARIANTS ${SEARCHI_CPU_DISPATCH} CACHE BOOL "Baseline and optimized CPU variants" FORCE) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}") set(GGML_OPENMP OFF CACHE BOOL "Use bounded native llama thread pools without external OpenMP runtime" FORCE) set(GGML_NATIVE OFF CACHE BOOL "Portable CPU kernels") # Normal distribution builds include GPU support and CPU fallback. CPU-only @@ -15,7 +33,6 @@ set(GGML_NATIVE OFF CACHE BOOL "Portable CPU kernels") set(GGML_CUDA OFF CACHE BOOL "Production CUDA backend") set(GGML_VULKAN OFF CACHE BOOL "Production Vulkan backend") set(GGML_METAL OFF CACHE BOOL "Production Metal backend") -set(SEARCHI_ACCELERATION "auto" CACHE STRING "auto; explicit developer overrides: cpu, metal, vulkan, cuda") if(SEARCHI_ACCELERATION STREQUAL "cpu") set(GGML_CUDA OFF CACHE BOOL "CPU test build" FORCE) set(GGML_VULKAN OFF CACHE BOOL "CPU test build" FORCE) @@ -46,5 +63,8 @@ add_subdirectory("${LLAMA_CPP_DIR}" llama-build) add_executable(searchi-gemma-worker worker.cpp) target_include_directories(searchi-gemma-worker PRIVATE "${LLAMA_CPP_DIR}/vendor") target_link_libraries(searchi-gemma-worker PRIVATE llama mtmd) +if(SEARCHI_CPU_DISPATCH) + target_compile_definitions(searchi-gemma-worker PRIVATE SEARCHI_CPU_DISPATCH=1) +endif() target_compile_definitions(searchi-gemma-worker PRIVATE SEARCHI_LLAMA_TAG="b11461-no-logits-v1") install(TARGETS searchi-gemma-worker RUNTIME DESTINATION bin) diff --git a/crates/searchi-semantic/native/worker.cpp b/crates/searchi-semantic/native/worker.cpp index c43918d..abb451e 100644 --- a/crates/searchi-semantic/native/worker.cpp +++ b/crates/searchi-semantic/native/worker.cpp @@ -1,6 +1,7 @@ // Local NDJSON helper around the pinned llama.cpp encoder. stdout is exclusively // protocol output; llama diagnostics go to stderr. No model download or HTTP. #include "llama.h" +#include "ggml-backend.h" #include "mtmd.h" #include "nlohmann/json.hpp" #include @@ -14,6 +15,12 @@ #include #include #include +#ifdef SEARCHI_CPU_DISPATCH +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#endif using json = nlohmann::json; constexpr size_t max_request_bytes = 16 * 1024 * 1024; @@ -71,6 +78,8 @@ class Encoder { } public: std::string device; + std::string cpu_backend_module; + bool vulkan_module_loaded = false; int threads; Encoder(const std::string & path, int cpu_threads, bool want_gpu) : model_path(path),threads(cpu_threads) { llama_log_set([](ggml_log_level level, const char * text, void *) { @@ -79,6 +88,31 @@ public: mtmd_log_set([](ggml_log_level level, const char * text, void *) { if (level >= GGML_LOG_LEVEL_WARN) std::fputs(text,stderr); },nullptr); +#ifdef SEARCHI_CPU_DISPATCH + // Only load our adjacent modules: no current-directory or environment + // plugin discovery. CPU-only startup never loads the Vulkan module. + std::vector executable(32768); + DWORD length = GetModuleFileNameW(nullptr, executable.data(), DWORD(executable.size())); + require(length > 0 && length < executable.size(), "Cannot locate packaged worker backends"); + const auto directory = std::filesystem::path(std::wstring(executable.data(), length)).parent_path().u8string(); + if (want_gpu) { + ggml_backend_load_all_from_path(directory.c_str()); + } else { + ggml_backend_load_cpu_only_from_path(directory.c_str()); + } + require(ggml_backend_reg_by_name("CPU") != nullptr, "Cannot load compatible packaged CPU backend"); + const auto cpu_reg = ggml_backend_reg_by_name("CPU"); + const auto feature_proc = ggml_backend_reg_get_proc_address(cpu_reg, "ggml_backend_get_features"); + HMODULE cpu_module = nullptr; + if (feature_proc && GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(feature_proc), &cpu_module)) { + DWORD module_length = GetModuleFileNameW(cpu_module, executable.data(), DWORD(executable.size())); + if (module_length > 0 && module_length < executable.size()) { + cpu_backend_module = std::filesystem::path(std::wstring(executable.data(), module_length)).filename().u8string(); + } + } + vulkan_module_loaded = GetModuleHandleW(L"ggml-vulkan.dll") != nullptr; +#endif llama_backend_init(); bool use_gpu = want_gpu && llama_supports_gpu_offload(); require(open(use_gpu) || (use_gpu && open(false)),"Cannot initialize local EmbeddingGemma 2 model/context"); @@ -335,14 +369,17 @@ int main(int argc, char ** argv) { // probing/enabling the Vulkan provider in the Windows VM. if (force_cpu || device == "cpu") { #ifdef _WIN32 + require(_putenv_s("GGML_CPU_ONLY", "1") == 0, "Cannot restrict CPU startup"); require(_putenv_s("GGML_DISABLE_VULKAN", "1") == 0, "Cannot disable Vulkan for CPU startup"); #else + require(setenv("GGML_CPU_ONLY", "1", 1) == 0, "Cannot restrict CPU startup"); require(setenv("GGML_DISABLE_VULKAN", "1", 1) == 0, "Cannot disable Vulkan for CPU startup"); #endif } Encoder encoder(path,threads,device == "auto" && !force_cpu); std::cout << json({{"ready",true},{"model","google/embeddinggemma-2"},{"dimensions",dimensions}, - {"device",encoder.device},{"threads",threads},{"runtime",SEARCHI_LLAMA_TAG},{"media_supported",true},{"code_query_supported",true}}).dump() << '\n' << std::flush; + {"device",encoder.device},{"threads",threads},{"runtime",SEARCHI_LLAMA_TAG},{"media_supported",true},{"code_query_supported",true}, + {"cpu_backend_module",encoder.cpu_backend_module},{"vulkan_module_loaded",encoder.vulkan_module_loaded}}).dump() << '\n' << std::flush; for (;;) { std::string line; char c; while (std::cin.get(c) && c != '\n') { diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 4f339ee..b1b9060 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -135,10 +135,17 @@ The generated `.searchi/packages/searchi-windows.zip` contains the desktop, matching-architecture Google worker, FFmpeg/FFprobe and notices. It excludes model caches, local settings and indexes. `PACKAGE.json` records SHA-256 for each included file. Extract the entire archive before launching `searchi.exe`. -First launch uses normal verified model setup. WebView2 and the applicable -Microsoft C++ runtime remain Windows prerequisites. The Windows archive includes +First launch uses normal verified model setup. WebView2 remains a Windows +prerequisite. The normal x64 archive ships unchanged Microsoft release CRT +DLLs with their redistribution notice; Visual Studio is not required to run it. +The Windows archive includes baseline and optimized CPU modules, selected +using CPU and OS capabilities, and the verified Vulkan loader DLL and its license; GPU availability is detected at runtime, with CPU fallback. The SDK is used only to compile the app. +All runtime DLLs are listed by size and SHA-256 in `runtime-libraries.json`. +Staging and packaging reject missing or changed listed dependencies before +replacing output. CPU-only VM smoke tests can also omit optimized modules to +exercise the packaged baseline; this does not certify older physical hardware. This is an unsigned development archive, not a production installer. Native launch, clean-machine dependencies, upgrades and signatures must still be diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 54ecfb4..2867897 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -57,6 +57,11 @@ Status: implementation started; no production release yet. Windows VM tests use provenance; universal CPU/Vulkan worker and embedded-frontend desktop built. - [x] Real Windows archive integrity, packaged decoder startup and auto-mode CPU inference with a controlled no-GPU-provider registry; Google CPU parity. +- [x] Normal Windows x64 archive includes baseline/optimized CPU dispatch, + OS AVX-state guards, shared app-local MSVC CRT and hashed DLL inventory; + real optimized and baseline-only package inference passes in the CPU VM. +- [ ] Verify CPU dispatch on older physical CPUs and a clean Windows machine; + dependency inspection and baseline execution on a modern VM are insufficient. - [ ] Actual missing/incompatible GPU drivers, precision, device loss and memory pressure on release hardware; native GUI and clean-machine archive launch. - [x] Windows desktop archive packager includes explicit runtime/helper files diff --git a/docs/SEMANTIC.md b/docs/SEMANTIC.md index ed9e66b..88e3152 100644 --- a/docs/SEMANTIC.md +++ b/docs/SEMANTIC.md @@ -28,8 +28,13 @@ retry of that request. WorkerInfo and responses report the actual device. **Windows VM tests remain CPU only**, via SEARCHI_FORCE_CPU=1 and Device::Cpu. GPU execution/failure recovery has not been exercised here. -The Windows worker links a static MSVC CRT and static llama libraries, without -an external OpenMP runtime. Production CMake supports cpu, auto, metal, vulkan, +The normal Windows x64 worker ships shared llama libraries and baseline plus +optimized CPU modules. Selection checks CPU features and OS support for AVX +register state; it loads packaged backends from the executable directory. +Shared libraries use a common MSVC CRT because model file handles cross DLL +boundaries. The unchanged release CRT files are packaged alongside the worker. +Internal static CPU builds retain a static CRT. Neither profile requires an +external OpenMP runtime. Production CMake supports cpu, auto, metal, vulkan, and cuda; CPU builds force GPU flags off. Runtime/provider parity and packaging on other operating systems remain independent release checks. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index f4e0674..4cd4be6 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -7,6 +7,35 @@ and `SEARCHI_FORCE_CPU=1`. ## Current integration checkpoint (2026-10-12) +The normal Windows x64 package now includes baseline and optimized CPU DLLs +with CPU/OS feature checks, plus the Vulkan backend. Shared DLLs use a common +MSVC CRT: a first `/MT` prototype crashed because model file handles crossed +separate CRT instances; the corrected `/MD` package passes real-model startup. +All 25 runtime DLLs and the worker have their non-Windows PE imports satisfied +by packaged files. Windows system UCRT/WinRT and WebView2 remain platform +requirements. This is dependency inspection, not clean-machine certification. + +Both extracted-archive tests passed: automatic CPU dispatch selected +`ggml-cpu-cascadelake.dll`, and removing optimized modules from the temporary +extraction selected `ggml-cpu-x64.dll`. Both produced genuine normalized 768d +Google code vectors, started FFprobe, verified all 39 listed files, and confirmed +the Vulkan module was not loaded. The unsigned archive is 145,196,878 bytes; +its digest is recorded in [dispatch evidence](evidence/windows-package-cpu-dispatch.json) +and [baseline evidence](evidence/windows-package-cpu-baseline.json). +Recorded startup/query/exit times are diagnostic smoke samples, not comparable +benchmarks: cache state and concurrent checks differ. Baseline inference on +this modern VM does not certify older physical CPUs or operating systems. +Three builder, five staging and eleven archive regression tests passed. +Two real Google code tests and two text-reference/tail/debug parity tests also +passed using the new shared worker in forced-CPU mode. Text reference cosine +similarities range from 0.99983106 to 0.99993036; debug/normal query cosine is +1.00000000. These verify runtime equivalence, not broad retrieval quality. +The genuine Google media parity test also passed on this shared CPU runtime: +image budgets 280/140/70 and the speech fixture all meet the 0.999 reference +cosine threshold. Image/audio quality limitations and GPU gates remain open. + +The following evidence describes the earlier static worker checkpoint: + The Windows-x64 universal worker built with CPU and Vulkan enabled, using the pinned official LunarG 1.4.363.0 SDK (download hash and publisher signature verified). Copy-only installation and later provenance/swap validation ran in diff --git a/docs/evidence/windows-package-cpu-baseline.json b/docs/evidence/windows-package-cpu-baseline.json new file mode 100644 index 0000000..ccdc96f --- /dev/null +++ b/docs/evidence/windows-package-cpu-baseline.json @@ -0,0 +1,15 @@ +{ + "architecture": "x86_64", + "verified_files": 39, + "cpu_backend_module": "ggml-cpu-x64.dll", + "vulkan_module_loaded": false, + "baseline_only": true, + "archive_sha256": "0e35c10ec48d4f3380915528a16529eb694418fb72717acd5cb81e2d9da46332", + "worker_requested_device": "auto", + "worker_actual_device": "cpu", + "no_gpu_condition": "GGML_DISABLE_VULKAN=1 only in child; SEARCHI_FORCE_CPU absent", + "worker_startup_query_and_exit_ms": 7931.76289999974, + "query_dimensions": 768, + "decoder": "ffprobe version n9.0.2-27-g7e93014c5b-20261012 Copyright (c) 2007-2026 the FFmpeg developers", + "scope": "Real packaged worker default-mode CPU fallback with disabled provider; no GPU, native UI or clean-machine certification" +} diff --git a/docs/evidence/windows-package-cpu-dispatch.json b/docs/evidence/windows-package-cpu-dispatch.json new file mode 100644 index 0000000..d514c16 --- /dev/null +++ b/docs/evidence/windows-package-cpu-dispatch.json @@ -0,0 +1,15 @@ +{ + "architecture": "x86_64", + "verified_files": 39, + "cpu_backend_module": "ggml-cpu-cascadelake.dll", + "vulkan_module_loaded": false, + "baseline_only": false, + "archive_sha256": "0e35c10ec48d4f3380915528a16529eb694418fb72717acd5cb81e2d9da46332", + "worker_requested_device": "auto", + "worker_actual_device": "cpu", + "no_gpu_condition": "GGML_DISABLE_VULKAN=1 only in child; SEARCHI_FORCE_CPU absent", + "worker_startup_query_and_exit_ms": 19626.68719998328, + "query_dimensions": 768, + "decoder": "ffprobe version n9.0.2-27-g7e93014c5b-20261012 Copyright (c) 2007-2026 the FFmpeg developers", + "scope": "Real packaged worker default-mode CPU fallback with disabled provider; no GPU, native UI or clean-machine certification" +} diff --git a/tools/embeddinggemma2/build_runtime.py b/tools/embeddinggemma2/build_runtime.py index ecf7c95..f605e7c 100644 --- a/tools/embeddinggemma2/build_runtime.py +++ b/tools/embeddinggemma2/build_runtime.py @@ -1,6 +1,9 @@ """Build a pinned isolated native worker; development VM builds use --backend cpu.""" import argparse +import hashlib +import json import os +import re from pathlib import Path import shutil import subprocess @@ -9,6 +12,49 @@ import sys ROOT = Path(__file__).resolve().parents[2] TAG = "b11461" COMMIT = "4d756bc72bf00a4aacf410ae15a2d315f3db400d" +WINDOWS_CRT_FILES = ( + "concrt140.dll", "msvcp140.dll", "msvcp140_1.dll", "msvcp140_2.dll", + "msvcp140_atomic_wait.dll", "msvcp140_codecvt_ids.dll", "vccorlib140.dll", + "vcruntime140.dll", "vcruntime140_1.dll", "vcruntime140_threads.dll", +) + + +def bundle_windows_crt(build): + """Copy unchanged release CRT from the selected Visual Studio installation.""" + cache = (build / "CMakeCache.txt").read_text(encoding="utf-8") + match = re.search(r"^CMAKE_GENERATOR_INSTANCE:[^=]+=([^\r\n]+)$", cache, re.MULTILINE) + if not match: + raise RuntimeError("Selected Visual Studio installation is missing from CMake cache") + installation = Path(match.group(1)) + auxiliary = installation / "VC/Auxiliary/Build" + version = (auxiliary / "Microsoft.VCRedistVersion.default.txt").read_text().strip() + tools_version = (auxiliary / "Microsoft.VCToolsVersion.default.txt").read_text().strip() + if not re.fullmatch(r"\d+\.\d+\.\d+", version) or version.split(".")[:2] != tools_version.split(".")[:2]: + raise RuntimeError("Visual Studio CRT redistributable does not match selected build tools") + cmake_version = [re.search(rf"^CMAKE_CACHE_{part}_VERSION:[^=]+=(\d+)$", cache, re.MULTILINE) + for part in ("MAJOR", "MINOR", "PATCH")] + if all(cmake_version): + compiler_info = build / "CMakeFiles" / ".".join(match.group(1) for match in cmake_version) / "CMakeCXXCompiler.cmake" + if compiler_info.is_file(): + compiler = compiler_info.read_text(encoding="utf-8") + actual = re.search(r'set\(CMAKE_CXX_COMPILER_VERSION "(\d+)\.(\d+)\.[^"]+"\)', compiler) + if not actual or (int(actual.group(1)), int(actual.group(2))) != (19, int(version.split(".")[1])): + raise RuntimeError("Visual Studio CRT redistributable does not match actual selected compiler") + source = installation / "VC/Redist/MSVC" / version / "x64/Microsoft.VC143.CRT" + files = [source / name for name in WINDOWS_CRT_FILES] + if any(not path.is_file() or path.is_symlink() for path in files): + raise RuntimeError("Required release Visual Studio CRT redistributable is missing") + destination = build / "Release" + for path in files: + shutil.copy2(path, destination / path.name) + (destination / "MSVC-REDISTRIBUTION.txt").write_text( + "Microsoft Visual C++ release runtime — copyright Microsoft Corporation.\n" + f"Unmodified x64 redistributable files from Visual Studio VC/Redist/MSVC/{version}/x64/Microsoft.VC143.CRT.\n" + "These proprietary files are distributed under Microsoft's applicable Visual Studio license terms, not searchi's license.\n" + "Terms: https://visualstudio.microsoft.com/license-terms/vs2022-ga-diagnosticbuildtools/\n" + "Redistributable list: https://learn.microsoft.com/en-us/visualstudio/releases/2022/redistribution\n" + "Deployment guidance: https://learn.microsoft.com/en-us/cpp/windows/redistributing-visual-cpp-files\n", + encoding="utf-8") def run(arguments, cwd=None): @@ -44,6 +90,14 @@ def main(): reverse = subprocess.run(["git", "apply", "--reverse", "--check", str(patch)], cwd=source, capture_output=True) if reverse.returncode: raise RuntimeError("Runtime memory patch cannot be applied or verified") + if os.name == "nt" and args.backend != "cpu": + for patch_name in ["cpu-dispatch.patch", "cpu-only-loader.patch"]: + dispatch_patch = Path(__file__).with_name(patch_name) + check = subprocess.run(["git", "apply", "--check", str(dispatch_patch)], cwd=source, capture_output=True) + if check.returncode == 0: + run(["git", "apply", dispatch_patch], cwd=source) + elif subprocess.run(["git", "apply", "--reverse", "--check", str(dispatch_patch)], cwd=source, capture_output=True).returncode: + raise RuntimeError(f"Runtime patch cannot be applied or verified: {patch_name}") cmake_exe = shutil.which("cmake") if not cmake_exe: try: @@ -59,6 +113,18 @@ def main(): if vulkan_loader: for name in ["vulkan-1.dll", "VULKAN-LICENSE.txt"]: shutil.copy2(vulkan_loader / name, build / "Release" / name) + if os.name == "nt" and args.backend != "cpu": + directory = build / "Release" + bundle_windows_crt(build) + def checksum(path): + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + inventory = {"worker_sha256": checksum(directory / "searchi-gemma-worker.exe"), + "libraries": [{"path": path.name, "bytes": path.stat().st_size, "sha256": checksum(path)} + for path in sorted(directory.glob("*.dll"))]} + temporary = directory / "runtime-libraries.json.partial" + temporary.write_text(json.dumps(inventory, indent=2), encoding="utf-8") + os.replace(temporary, directory / "runtime-libraries.json") print(f"Runtime build complete: {build}") diff --git a/tools/embeddinggemma2/cpu-dispatch.patch b/tools/embeddinggemma2/cpu-dispatch.patch new file mode 100644 index 0000000..dba4f11 --- /dev/null +++ b/tools/embeddinggemma2/cpu-dispatch.patch @@ -0,0 +1,98 @@ +--- a/ggml/src/ggml-cpu/arch/x86/cpu-feats.cpp ++++ b/ggml/src/ggml-cpu/arch/x86/cpu-feats.cpp +@@ -261,10 +261,30 @@ + #endif + + static int ggml_backend_cpu_x86_score() { +- // FIXME: this does not check for OS support +- + int score = 1; + cpuid_x86 is; ++ // CPUID describes hardware; AVX also requires OS-managed XMM/YMM state. ++ // Keep this selector compiled without architecture flags (upstream design). ++ unsigned long long xcr0 = 0; ++ if (is.XSAVE() && is.OSXSAVE()) { ++#ifdef _MSC_VER ++ xcr0 = _xgetbv(0); ++#else ++ unsigned int eax, edx; ++ __asm__ __volatile__("xgetbv" : "=a"(eax), "=d"(edx) : "c"(0)); ++ xcr0 = (static_cast(edx) << 32) | eax; ++#endif ++ } ++#if defined(GGML_AVX) || defined(GGML_AVX2) || defined(GGML_FMA) || defined(GGML_F16C) || defined(GGML_AVX_VNNI) || defined(GGML_AVX512) || defined(GGML_AVX512_VBMI) || defined(GGML_AVX512_BF16) || defined(GGML_AVX512_VNNI) ++ if (!is.AVX() || (xcr0 & 0x6) != 0x6) { return 0; } ++#endif ++#if defined(GGML_AVX512) || defined(GGML_AVX512_VBMI) || defined(GGML_AVX512_BF16) || defined(GGML_AVX512_VNNI) ++ if ((xcr0 & 0xe6) != 0xe6) { return 0; } ++#endif ++#ifdef GGML_AMX_INT8 ++ // Tile state also needs OS permission. Searchi does not request it. ++ return 0; ++#endif + + #ifdef GGML_FMA + if (!is.FMA()) { return 0; } +--- a/ggml/src/ggml-backend-reg.cpp ++++ b/ggml/src/ggml-backend-reg.cpp +@@ -582,6 +582,11 @@ + bool silent = false; + #endif + ++ const char * cpu_only = std::getenv("GGML_CPU_ONLY"); ++ if (cpu_only && std::strcmp(cpu_only, "1") == 0) { ++ ggml_backend_load_best("cpu", silent, dir_path); ++ return; ++ } + ggml_backend_load_best("blas", silent, dir_path); + ggml_backend_load_best("zendnn", silent, dir_path); + ggml_backend_load_best("cann", silent, dir_path); +@@ -590,7 +595,10 @@ + ggml_backend_load_best("metal", silent, dir_path); + ggml_backend_load_best("rpc", silent, dir_path); + ggml_backend_load_best("sycl", silent, dir_path); +- ggml_backend_load_best("vulkan", silent, dir_path); ++ const char * disable_vulkan = std::getenv("GGML_DISABLE_VULKAN"); ++ if (!disable_vulkan || std::strcmp(disable_vulkan, "1") != 0) { ++ ggml_backend_load_best("vulkan", silent, dir_path); ++ } + ggml_backend_load_best("virtgpu", silent, dir_path); + ggml_backend_load_best("opencl", silent, dir_path); + ggml_backend_load_best("hexagon", silent, dir_path); +@@ -599,7 +607,7 @@ + ggml_backend_load_best("cpu", silent, dir_path); + // check the environment variable GGML_BACKEND_PATH to load an out-of-tree backend + const char * backend_path = std::getenv("GGML_BACKEND_PATH"); +- if (backend_path) { ++ if (backend_path && dir_path == nullptr) { + ggml_backend_load(backend_path); + } + } +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -37,7 +37,9 @@ + # Add path to modules + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/") + +-set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) ++if (NOT DEFINED CMAKE_RUNTIME_OUTPUT_DIRECTORY) ++ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) ++endif() + set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) + + if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) +--- a/ggml/src/ggml-cpu/CMakeLists.txt ++++ b/ggml/src/ggml-cpu/CMakeLists.txt +@@ -13,7 +13,11 @@ + # from inlining architecture-specific instructions into the score function. + # Without this, LTO can cause SIGILL when loading backends on older CPUs + # (e.g., loading power10 backend on power9 crashes before feature check runs). +- target_compile_options(${GGML_CPU_FEATS_NAME} PRIVATE -fno-lto) ++ if (MSVC) ++ target_compile_options(${GGML_CPU_FEATS_NAME} PRIVATE /GL-) ++ else() ++ target_compile_options(${GGML_CPU_FEATS_NAME} PRIVATE -fno-lto) ++ endif() + target_link_libraries(${cpu_name} PRIVATE ${GGML_CPU_FEATS_NAME}) + endfunction() + diff --git a/tools/embeddinggemma2/cpu-only-loader.patch b/tools/embeddinggemma2/cpu-only-loader.patch new file mode 100644 index 0000000..e8a7557 --- /dev/null +++ b/tools/embeddinggemma2/cpu-only-loader.patch @@ -0,0 +1,31 @@ +--- a/ggml/include/ggml-backend.h ++++ b/ggml/include/ggml-backend.h +@@ -260,6 +260,8 @@ + // Load all known backends from dynamic libraries + GGML_API void ggml_backend_load_all(void); + GGML_API void ggml_backend_load_all_from_path(const char * dir_path); ++ // Searchi: explicit CPU-only startup, independent of per-DLL CRT environment state. ++ GGML_API void ggml_backend_load_cpu_only_from_path(const char * dir_path); + + // + // Backend scheduler +--- a/ggml/src/ggml-backend-reg.cpp ++++ b/ggml/src/ggml-backend-reg.cpp +@@ -575,6 +575,17 @@ + ggml_backend_load_all_from_path(nullptr); + } + ++void ggml_backend_load_cpu_only_from_path(const char * dir_path) { ++#ifdef NDEBUG ++ const bool silent = true; ++#else ++ const bool silent = false; ++#endif ++ // Do not inspect GPU backends or GGML_BACKEND_PATH. The caller supplies ++ // the executable directory, so working-directory discovery is excluded. ++ ggml_backend_load_best("cpu", silent, dir_path); ++} ++ + void ggml_backend_load_all_from_path(const char * dir_path) { + #ifdef NDEBUG + bool silent = true; diff --git a/tools/embeddinggemma2/package_smoke.py b/tools/embeddinggemma2/package_smoke.py index 32d1273..a399129 100644 --- a/tools/embeddinggemma2/package_smoke.py +++ b/tools/embeddinggemma2/package_smoke.py @@ -22,6 +22,7 @@ def main(): parser.add_argument("--archive", type=Path, default=ROOT / ".searchi/packages/searchi-windows.zip") parser.add_argument("--models", type=Path, default=ROOT / ".searchi/models/embeddinggemma2") parser.add_argument("--output", type=Path, default=ROOT / ".searchi/packages/smoke.json") + parser.add_argument("--baseline", action="store_true", help="Extract only the baseline CPU module to test the packaged fallback") args = parser.parse_args() manifest = json.loads(Path(__file__).with_name("model-manifest.json").read_text()) model = args.models / manifest["files"][0]["path"] @@ -40,6 +41,8 @@ def main(): data = archive.read(record["path"]) assert len(data) == record["bytes"] assert hashlib.sha256(data).hexdigest() == record["sha256"] + if args.baseline and record["path"].startswith("ggml-cpu-") and record["path"] != "ggml-cpu-x64.dll": + continue path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) environment = dict(os.environ, GGML_DISABLE_VULKAN="1") @@ -56,10 +59,13 @@ def main(): worker.kill() worker.wait(timeout=10) raise - assert worker.returncode == 0, stderr + assert worker.returncode == 0, f"Worker exit {worker.returncode:#x}: {stderr}" worker_duration_ms = (time.perf_counter()-started)*1000 hello, result = [json.loads(line) for line in stdout.splitlines()] assert hello["ready"] and hello["device"] == "cpu" and result["device"] == "cpu" and result["ok"] + assert hello.get("vulkan_module_loaded") is False + if args.baseline: + assert hello.get("cpu_backend_module") == "ggml-cpu-x64.dll" vector = result["vectors"][0] assert len(vector) == 768 and all(math.isfinite(x) for x in vector) assert abs(sum(x*x for x in vector) - 1) < 0.0001 @@ -69,6 +75,9 @@ def main(): with args.archive.open("rb") as stream: archive_checksum = hashlib.file_digest(stream, "sha256").hexdigest() report = {"architecture": package["architecture"], "verified_files": len(package["files"]), + "cpu_backend_module": hello.get("cpu_backend_module"), + "vulkan_module_loaded": hello.get("vulkan_module_loaded"), + "baseline_only": args.baseline, "archive_sha256": archive_checksum, "worker_requested_device": "auto", "worker_actual_device": hello["device"], "no_gpu_condition": "GGML_DISABLE_VULKAN=1 only in child; SEARCHI_FORCE_CPU absent", diff --git a/tools/embeddinggemma2/stage_runtime.py b/tools/embeddinggemma2/stage_runtime.py index 58ae74c..1ce52a1 100644 --- a/tools/embeddinggemma2/stage_runtime.py +++ b/tools/embeddinggemma2/stage_runtime.py @@ -3,10 +3,56 @@ import argparse import hashlib import json import os +import re from pathlib import Path import shutil ROOT = Path(__file__).resolve().parents[2] +CRT_LIBRARIES = {"concrt140.dll", "msvcp140.dll", "msvcp140_1.dll", "msvcp140_2.dll", "msvcp140_atomic_wait.dll", "msvcp140_codecvt_ids.dll", "vccorlib140.dll", "vcruntime140.dll", "vcruntime140_1.dll", "vcruntime140_threads.dll"} + + +def verify_runtime(source, required=False): + """Preflight an explicit, bounded dependency inventory, never a DLL glob.""" + inventory = source.parent / "runtime-libraries.json" + if not inventory.is_file(): + if required: + raise RuntimeError("Runtime dependency inventory is missing") + return [] + if inventory.stat().st_size > 65536: + raise RuntimeError("Runtime dependency inventory is oversized") + manifest = json.loads(inventory.read_text(encoding="utf-8")) + if not isinstance(manifest, dict): + raise RuntimeError("Invalid runtime dependency inventory") + with source.open("rb") as stream: + if hashlib.file_digest(stream, "sha256").hexdigest() != manifest.get("worker_sha256"): + raise RuntimeError("Runtime worker checksum mismatch") + records = manifest.get("libraries") + if not isinstance(records, list) or not 1 <= len(records) <= 64: + raise RuntimeError("Invalid runtime dependency inventory") + paths, names = [], set() + for record in records: + if not isinstance(record, dict): + raise RuntimeError("Invalid runtime library metadata") + name = record.get("path", "") + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_-]+\.dll", name) or name.lower() in names: + raise RuntimeError("Invalid or duplicate runtime library path") + names.add(name.lower()) + artifact = source.parent / name + if artifact.is_symlink() or not artifact.resolve().is_relative_to(source.parent.resolve()): + raise RuntimeError("Runtime library resolves outside build directory") + size = record.get("bytes") + checksum = record.get("sha256", "") + if type(size) is not int or size <= 0 or not isinstance(checksum, str) or not re.fullmatch(r"[0-9a-f]{64}", checksum): + raise RuntimeError("Invalid runtime library metadata") + if not artifact.is_file() or artifact.stat().st_size != size: + raise RuntimeError(f"Runtime library missing or size mismatch: {name}") + with artifact.open("rb") as stream: + if hashlib.file_digest(stream, "sha256").hexdigest() != checksum: + raise RuntimeError(f"Runtime library checksum mismatch: {name}") + paths.append(artifact) + if required and not ({"ggml-cpu-x64.dll", "ggml-vulkan.dll", "vulkan-1.dll", "ggml.dll", "ggml-base.dll", "llama.dll", "mtmd.dll"} | CRT_LIBRARIES).issubset(names): + raise RuntimeError("Runtime inventory lacks required CPU/GPU libraries") + return [inventory, *paths] def verify_bundle(root, manifest): @@ -38,6 +84,12 @@ def main(): license_path = ROOT / ".searchi/references/llama.cpp/LICENSE" if not license_path.is_file(): raise RuntimeError("Native worker license is missing") + dependencies = verify_runtime(source, os.name == "nt" and args.backend in {"auto", "vulkan"}) + crt_notice = source.parent / "MSVC-REDISTRIBUTION.txt" + if os.name == "nt" and args.backend != "cpu": + if not crt_notice.is_file(): + raise RuntimeError("Microsoft CRT redistribution notice is missing") + dependencies.append(crt_notice) artifacts = [] if args.bundle_model: manifest = json.loads(Path(__file__).with_name("model-manifest.json").read_text()) @@ -49,11 +101,14 @@ def main(): destination = ROOT / "apps/desktop/src-tauri/resources" destination.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination / name) + for dependency in dependencies: + shutil.copy2(dependency, destination / dependency.name) # Include the upstream runtime license with the statically linked helper. shutil.copy2(license_path, destination / "LLAMA-LICENSE.txt") if loader: for name in ["vulkan-1.dll", "VULKAN-LICENSE.txt"]: - shutil.copy2(loader / name, destination / name) + if name not in {path.name for path in dependencies}: + shutil.copy2(loader / name, destination / name) if args.bundle_model: model_destination = destination / "embeddinggemma2" model_destination.mkdir(exist_ok=True) diff --git a/tools/embeddinggemma2/test_build_runtime.py b/tools/embeddinggemma2/test_build_runtime.py new file mode 100644 index 0000000..826988d --- /dev/null +++ b/tools/embeddinggemma2/test_build_runtime.py @@ -0,0 +1,67 @@ +"""Redistributable preparation tests; no native builds or GPU execution.""" +from pathlib import Path +import tempfile +import unittest + +from build_runtime import bundle_windows_crt, WINDOWS_CRT_FILES + + +class CrtTests(unittest.TestCase): + def fixture(self, directory): + root = Path(directory) + installation = root / "Visual Studio" + auxiliary = installation / "VC/Auxiliary/Build" + auxiliary.mkdir(parents=True) + (auxiliary / "Microsoft.VCRedistVersion.default.txt").write_text("14.44.35112") + (auxiliary / "Microsoft.VCToolsVersion.default.txt").write_text("14.44.35207") + source = installation / "VC/Redist/MSVC/14.44.35112/x64/Microsoft.VC143.CRT" + source.mkdir(parents=True) + for name in WINDOWS_CRT_FILES: + (source / name).write_bytes(name.encode()) + (source / "unlisted-debug.dll").write_bytes(b"do not distribute") + build = root / "build" + (build / "Release").mkdir(parents=True) + (build / "CMakeCache.txt").write_text(f"CMAKE_GENERATOR_INSTANCE:INTERNAL={installation}\n") + return build, source, auxiliary + + def test_copies_only_fixed_unmodified_release_files_and_public_provenance(self): + with tempfile.TemporaryDirectory() as directory: + build, source, _ = self.fixture(directory) + bundle_windows_crt(build) + for name in WINDOWS_CRT_FILES: + self.assertEqual((build / "Release" / name).read_bytes(), (source / name).read_bytes()) + self.assertFalse((build / "Release/unlisted-debug.dll").exists()) + notice = (build / "Release/MSVC-REDISTRIBUTION.txt").read_text(encoding="utf-8") + self.assertIn("14.44.35112", notice) + self.assertIn("https://learn.microsoft.com/", notice) + self.assertNotIn(directory, notice) + + def test_missing_or_mismatched_crt_preserves_existing_files(self): + with tempfile.TemporaryDirectory() as directory: + build, source, auxiliary = self.fixture(directory) + existing = build / "Release/msvcp140.dll" + existing.write_bytes(b"previous CRT") + (source / "vcruntime140.dll").unlink() + with self.assertRaisesRegex(RuntimeError, "redistributable is missing"): + bundle_windows_crt(build) + self.assertEqual(existing.read_bytes(), b"previous CRT") + (auxiliary / "Microsoft.VCRedistVersion.default.txt").write_text("14.43.12345") + with self.assertRaisesRegex(RuntimeError, "does not match"): + bundle_windows_crt(build) + self.assertEqual(existing.read_bytes(), b"previous CRT") + + def test_custom_toolset_compiler_mismatch_rejects_before_copy(self): + with tempfile.TemporaryDirectory() as directory: + build, _, _ = self.fixture(directory) + with (build / "CMakeCache.txt").open("a") as stream: + stream.write("CMAKE_CACHE_MAJOR_VERSION:INTERNAL=4\nCMAKE_CACHE_MINOR_VERSION:INTERNAL=2\nCMAKE_CACHE_PATCH_VERSION:INTERNAL=0\n") + compiler = build / "CMakeFiles/4.2.0/CMakeCXXCompiler.cmake" + compiler.parent.mkdir(parents=True) + compiler.write_text('set(CMAKE_CXX_COMPILER_VERSION "19.45.12345")\n') + with self.assertRaisesRegex(RuntimeError, "actual selected compiler"): + bundle_windows_crt(build) + self.assertFalse((build / "Release/msvcp140.dll").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/embeddinggemma2/test_stage_runtime.py b/tools/embeddinggemma2/test_stage_runtime.py index 60a04e1..bd01a78 100644 --- a/tools/embeddinggemma2/test_stage_runtime.py +++ b/tools/embeddinggemma2/test_stage_runtime.py @@ -1,5 +1,6 @@ """Offline installation regressions; no model downloads or inference.""" import hashlib +import json import os from pathlib import Path import tempfile @@ -10,6 +11,70 @@ import stage_runtime class StagingTests(unittest.TestCase): + @unittest.skipUnless(os.name == "nt", "Windows app-local CRT staging") + def test_missing_crt_notice_preserves_existing_staged_worker(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + runtime = root / ".searchi/runtime/auto/Release" + runtime.mkdir(parents=True) + worker = runtime / "searchi-gemma-worker.exe" + worker.write_bytes(b"new worker") + names = {"ggml-cpu-x64.dll", "ggml-vulkan.dll", "vulkan-1.dll", "ggml.dll", "ggml-base.dll", "llama.dll", "mtmd.dll"} | stage_runtime.CRT_LIBRARIES + records = [] + for name in sorted(names): + data = name.encode() + (runtime / name).write_bytes(data) + records.append({"path": name, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}) + (runtime / "runtime-libraries.json").write_text(json.dumps({"worker_sha256": hashlib.sha256(worker.read_bytes()).hexdigest(), "libraries": records})) + license_path = root / ".searchi/references/llama.cpp/LICENSE" + license_path.parent.mkdir(parents=True) + license_path.write_text("license") + destination = root / "apps/desktop/src-tauri/resources" + destination.mkdir(parents=True) + (destination / worker.name).write_bytes(b"previous worker") + with patch.object(stage_runtime, "ROOT", root), patch("sys.argv", ["stage_runtime"]): + with self.assertRaisesRegex(RuntimeError, "redistribution notice"): + stage_runtime.main() + self.assertEqual((destination / worker.name).read_bytes(), b"previous worker") + + def test_missing_dependency_preserves_staged_worker(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + runtime = root / ".searchi/runtime/cpu" + if os.name == "nt": + runtime /= "Release" + runtime.mkdir(parents=True) + name = "searchi-gemma-worker.exe" if os.name == "nt" else "searchi-gemma-worker" + (runtime / name).write_bytes(b"new worker") + (runtime / "runtime-libraries.json").write_text(json.dumps({"worker_sha256": hashlib.sha256(b"new worker").hexdigest(), "libraries": [{"path": "ggml-cpu-x64.dll", "bytes": 4, "sha256": hashlib.sha256(b"test").hexdigest()}]})) + license_path = root / ".searchi/references/llama.cpp/LICENSE" + license_path.parent.mkdir(parents=True) + license_path.write_text("license") + destination = root / "apps/desktop/src-tauri/resources" + destination.mkdir(parents=True) + (destination / name).write_bytes(b"previous worker") + with patch.object(stage_runtime, "ROOT", root), patch("sys.argv", ["stage_runtime", "--backend", "cpu"]): + with self.assertRaisesRegex(RuntimeError, "missing or size mismatch"): + stage_runtime.main() + self.assertEqual((destination / name).read_bytes(), b"previous worker") + + def test_inventory_rejects_escape_and_omits_unlisted_libraries(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + worker = root / "worker.exe" + worker.write_bytes(b"worker") + (root / "good.dll").write_bytes(b"good") + (root / "private.dll").write_bytes(b"private") + record = {"path": "good.dll", "bytes": 4, "sha256": hashlib.sha256(b"good").hexdigest()} + manifest = {"worker_sha256": hashlib.sha256(b"worker").hexdigest(), "libraries": [record]} + path = root / "runtime-libraries.json" + path.write_text(json.dumps(manifest)) + self.assertEqual([p.name for p in stage_runtime.verify_runtime(worker)], ["runtime-libraries.json", "good.dll"]) + record["path"] = "../escape.dll" + path.write_text(json.dumps(manifest)) + with self.assertRaisesRegex(RuntimeError, "library path"): + stage_runtime.verify_runtime(worker) + def test_corrupt_second_model_preserves_existing_staged_worker(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tools/package_windows.py b/tools/package_windows.py index bd7fe12..303c113 100644 --- a/tools/package_windows.py +++ b/tools/package_windows.py @@ -7,12 +7,14 @@ import argparse import hashlib import json import os +import re from pathlib import Path import struct import tempfile import zipfile ROOT = Path(__file__).resolve().parents[1] +CRT_LIBRARIES = {"concrt140.dll", "msvcp140.dll", "msvcp140_1.dll", "msvcp140_2.dll", "msvcp140_atomic_wait.dll", "msvcp140_codecvt_ids.dll", "vccorlib140.dll", "vcruntime140.dll", "vcruntime140_1.dll", "vcruntime140_threads.dll"} def pe_machine(path): @@ -32,11 +34,49 @@ def pe_machine(path): def package(root, executable, output): resources = root / "apps/desktop/src-tauri/resources" + inventory = resources / "runtime-libraries.json" + if not inventory.is_file() or inventory.stat().st_size > 65536: + raise ValueError("Required runtime dependency inventory is missing or oversized") + inventory_bytes = inventory.read_bytes() + manifest = json.loads(inventory_bytes) + if not isinstance(manifest, dict): + raise ValueError("Invalid runtime dependency inventory") + libraries = manifest.get("libraries") + if not isinstance(libraries, list) or not 1 <= len(libraries) <= 64: + raise ValueError("Invalid runtime dependency inventory") + expected = {"searchi-gemma-worker.exe": {"sha256": manifest.get("worker_sha256")}} + for record in libraries: + if not isinstance(record, dict): + raise ValueError("Invalid runtime library metadata") + name = record.get("path", "") + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_-]+\.dll", name) or name.lower() in {key.lower() for key in expected}: + raise ValueError("Invalid or duplicate runtime library path") + if type(record.get("bytes")) is not int or record["bytes"] <= 0: + raise ValueError("Invalid runtime library size") + expected[name] = record + if not ({"ggml-cpu-x64.dll", "ggml-vulkan.dll", "vulkan-1.dll", "ggml.dll", "ggml-base.dll", "llama.dll", "mtmd.dll"} | CRT_LIBRARIES).issubset(expected): + raise ValueError("Runtime inventory lacks required CPU/GPU libraries") + expected[inventory.name] = {"bytes": len(inventory_bytes), "sha256": hashlib.sha256(inventory_bytes).hexdigest()} + for name, record in expected.items(): + path = resources / name + if path.is_symlink() or not path.resolve().is_relative_to(resources.resolve()) or not path.is_file(): + raise ValueError(f"Required runtime library is missing or outside staging: {name}") + checksum = record.get("sha256", "") + if not isinstance(checksum, str) or not re.fullmatch(r"[0-9a-f]{64}", checksum): + raise ValueError("Invalid runtime checksum") + with path.open("rb") as stream: + if hashlib.file_digest(stream, "sha256").hexdigest() != checksum: + raise ValueError(f"Runtime checksum mismatch: {name}") + if "bytes" in record and path.stat().st_size != record["bytes"]: + raise ValueError(f"Runtime size mismatch: {name}") selected = [(executable, "searchi.exe"), (root / "LICENSE", "LICENSE.txt")] - for name in ["searchi-gemma-worker.exe", "LLAMA-LICENSE.txt", "vulkan-1.dll", "VULKAN-LICENSE.txt", "media-runtime/manifest.json", + for name in ["searchi-gemma-worker.exe", "LLAMA-LICENSE.txt", "MSVC-REDISTRIBUTION.txt", "vulkan-1.dll", "VULKAN-LICENSE.txt", "media-runtime/manifest.json", "media-runtime/bin/ffmpeg.exe", "media-runtime/bin/ffprobe.exe", "licenses/Inter-OFL.txt", "licenses/Fluent-MIT.txt", "media-runtime/licenses/LICENSE.txt"]: selected.append((resources / name, name)) + selected.append((inventory, inventory.name)) + selected_names = {name for _, name in selected} + selected.extend((resources / name, name) for name in expected if name not in selected_names) for directory in ["licenses", "media-runtime/licenses"]: notices = list((resources / directory).rglob("*.txt")) if not notices: @@ -73,11 +113,13 @@ def package(root, executable, output): checksum.update(block) count += len(block) records.append({"path": name, "bytes": count, "sha256": checksum.hexdigest()}) + if name in expected and (checksum.hexdigest() != expected[name]["sha256"] or ("bytes" in expected[name] and count != expected[name]["bytes"])): + raise ValueError(f"Runtime changed while packaging: {name}") archive.writestr("PACKAGE.json", json.dumps({"platform": "windows", "architecture": architecture, "status": "unsigned development package; clean-machine verification pending", "files": records}, indent=2)) archive.writestr("START-HERE.txt", "searchi — Find what matters. Locally.\n\n" "Extract the entire folder, then open searchi.exe. Keep its helpers alongside it.\n" - "Windows needs WebView2 and the applicable Microsoft C++ runtime.\n" + "Windows needs WebView2. The Microsoft C++ runtime ships with these helpers; Visual Studio is not required.\n" "First launch downloads approximately 865 MB of verified Google model files.\n" "Choose Add folder to index your own files; ordinary search stays local.\n" "This is an unsigned development build, not a certified production release.\n") diff --git a/tools/test_package_windows.py b/tools/test_package_windows.py index 6e5dc2d..18895eb 100644 --- a/tools/test_package_windows.py +++ b/tools/test_package_windows.py @@ -7,7 +7,7 @@ import unittest from unittest import mock import zipfile -from package_windows import package +from package_windows import package, CRT_LIBRARIES def executable(path, machine=0x8664): @@ -20,6 +20,15 @@ def executable(path, machine=0x8664): path.write_bytes(data) +def inventory(resources): + worker = resources / "searchi-gemma-worker.exe" + records = [] + for name in ["ggml-cpu-x64.dll", "ggml-vulkan.dll", "vulkan-1.dll", "ggml.dll", "ggml-base.dll", "llama.dll", "mtmd.dll", *sorted(CRT_LIBRARIES)]: + data = (resources / name).read_bytes() + records.append({"path": name, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}) + (resources / "runtime-libraries.json").write_text(json.dumps({"worker_sha256": hashlib.sha256(worker.read_bytes()).hexdigest(), "libraries": records})) + + class PackageTests(unittest.TestCase): def fixture(self, directory): root = Path(directory) @@ -27,13 +36,16 @@ class PackageTests(unittest.TestCase): app = root / "build/app.exe" executable(app) (root / "LICENSE").write_text("application notice") - for name in ["searchi-gemma-worker.exe", "vulkan-1.dll", "media-runtime/bin/ffmpeg.exe", "media-runtime/bin/ffprobe.exe"]: + for name in ["searchi-gemma-worker.exe", "vulkan-1.dll", "ggml-cpu-x64.dll", "ggml-vulkan.dll", "ggml.dll", "ggml-base.dll", "llama.dll", "mtmd.dll", "media-runtime/bin/ffmpeg.exe", "media-runtime/bin/ffprobe.exe"]: executable(resources / name) - for name in ["LLAMA-LICENSE.txt", "VULKAN-LICENSE.txt", "licenses/Inter-OFL.txt", "licenses/Fluent-MIT.txt", "media-runtime/licenses/LICENSE.txt", "media-runtime/manifest.json"]: + for name in sorted(CRT_LIBRARIES): + executable(resources / name) + for name in ["LLAMA-LICENSE.txt", "MSVC-REDISTRIBUTION.txt", "VULKAN-LICENSE.txt", "licenses/Inter-OFL.txt", "licenses/Fluent-MIT.txt", "media-runtime/licenses/LICENSE.txt", "media-runtime/manifest.json"]: path = resources / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text("notice") (resources / "settings.json").write_text("private settings") + inventory(resources) return root, resources, app, root / "archive.zip" def test_complete_archive_excludes_settings_and_verifies_recorded_bytes(self): @@ -53,6 +65,7 @@ class PackageTests(unittest.TestCase): root, resources, app, output = self.fixture(directory) output.write_bytes(b"previous package") executable(resources / "searchi-gemma-worker.exe", 0xAA64) + inventory(resources) with self.assertRaisesRegex(ValueError, "architecture differs"): package(root, app, output) self.assertEqual(output.read_bytes(), b"previous package") @@ -109,10 +122,86 @@ class PackageTests(unittest.TestCase): root, resources, app, output = self.fixture(directory) output.write_bytes(b"previous package") executable(resources / "vulkan-1.dll", 0xAA64) + inventory(resources) with self.assertRaisesRegex(ValueError, "architecture differs: vulkan-1.dll"): package(root, app, output) self.assertEqual(output.read_bytes(), b"previous package") + def test_inventory_includes_dispatch_libraries_but_not_unlisted_dll(self): + with tempfile.TemporaryDirectory() as directory: + root, resources, app, output = self.fixture(directory) + executable(resources / "private-plugin.dll") + package(root, app, output) + with zipfile.ZipFile(output) as archive: + self.assertIn("ggml-cpu-x64.dll", archive.namelist()) + self.assertIn("ggml-vulkan.dll", archive.namelist()) + self.assertIn("runtime-libraries.json", archive.namelist()) + self.assertIn("msvcp140.dll", archive.namelist()) + self.assertIn("MSVC-REDISTRIBUTION.txt", archive.namelist()) + self.assertNotIn("private-plugin.dll", archive.namelist()) + + def test_missing_inventory_or_corrupt_library_preserves_archive(self): + with tempfile.TemporaryDirectory() as directory: + root, resources, app, output = self.fixture(directory) + output.write_bytes(b"previous package") + (resources / "ggml-cpu-x64.dll").write_bytes(b"corrupt") + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + package(root, app, output) + self.assertEqual(output.read_bytes(), b"previous package") + (resources / "runtime-libraries.json").unlink() + with self.assertRaisesRegex(ValueError, "inventory"): + package(root, app, output) + self.assertEqual(output.read_bytes(), b"previous package") + + def test_dependency_change_during_copy_preserves_archive(self): + with tempfile.TemporaryDirectory() as directory: + root, resources, app, output = self.fixture(directory) + output.write_bytes(b"previous package") + original_open = zipfile.ZipFile.open + + def mutate_library(archive, name, mode="r", *args, **kwargs): + if name == "ggml-cpu-x64.dll" and mode == "w": + library = resources / name + library.write_bytes(library.read_bytes() + b"changed") + return original_open(archive, name, mode, *args, **kwargs) + + with mock.patch.object(zipfile.ZipFile, "open", mutate_library): + with self.assertRaisesRegex(ValueError, "changed while packaging"): + package(root, app, output) + self.assertEqual(output.read_bytes(), b"previous package") + self.assertEqual(list(output.parent.glob("*.partial")), []) + + def test_missing_crt_or_notice_preserves_previous_archive(self): + with tempfile.TemporaryDirectory() as directory: + root, resources, app, output = self.fixture(directory) + output.write_bytes(b"previous package") + (resources / "msvcp140.dll").unlink() + with self.assertRaisesRegex(ValueError, "msvcp140"): + package(root, app, output) + self.assertEqual(output.read_bytes(), b"previous package") + executable(resources / "msvcp140.dll") + (resources / "MSVC-REDISTRIBUTION.txt").unlink() + with self.assertRaisesRegex(ValueError, "MSVC-REDISTRIBUTION"): + package(root, app, output) + self.assertEqual(output.read_bytes(), b"previous package") + + def test_inventory_change_during_copy_preserves_previous_archive(self): + with tempfile.TemporaryDirectory() as directory: + root, resources, app, output = self.fixture(directory) + output.write_bytes(b"previous package") + original_open = zipfile.ZipFile.open + + def mutate_inventory(archive, name, mode="r", *args, **kwargs): + if name == "runtime-libraries.json" and mode == "w": + manifest = resources / name + manifest.write_bytes(manifest.read_bytes() + b" ") + return original_open(archive, name, mode, *args, **kwargs) + + with mock.patch.object(zipfile.ZipFile, "open", mutate_inventory): + with self.assertRaisesRegex(ValueError, "changed while packaging"): + package(root, app, output) + self.assertEqual(output.read_bytes(), b"previous package") + if __name__ == "__main__": unittest.main()