Skip to main content

smriti/
bootstrap.rs

1//! Runtime bootstrap checks and setup helpers.
2
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6use futures::StreamExt;
7use zip::read::ZipArchive;
8
9const MAX_ASSET_PACK_FILES: usize = 1_000;
10const MAX_ASSET_PACK_DOWNLOAD_BYTES: u64 = 1024 * 1024 * 1024;
11const MAX_ASSET_PACK_EXPANDED_BYTES: u64 = 2 * 1024 * 1024 * 1024;
12
13#[cfg(target_os = "windows")]
14pub const SETUP_ASSETS_HINT: &str =
15    "powershell -ExecutionPolicy Bypass -File scripts\\setup_assets.ps1";
16
17#[cfg(not(target_os = "windows"))]
18pub const SETUP_ASSETS_HINT: &str = "./scripts/setup_assets.sh";
19
20pub const ASSET_PACK_URL_DEFAULT: &str = concat!(
21    "https://github.com/ChivukulaVirinchi/photovault/releases/download/v",
22    env!("CARGO_PKG_VERSION"),
23    "/Smriti-Assets.zip"
24);
25const ASSET_PACK_LATEST_URL: &str =
26    "https://github.com/ChivukulaVirinchi/photovault/releases/latest/download/Smriti-Assets.zip";
27
28#[derive(Debug, Clone, Default)]
29pub struct AssetHealth {
30    pub missing_face_models: bool,
31    pub missing_onnx_runtime: bool,
32    pub missing_geonames_db: bool,
33}
34
35impl AssetHealth {
36    pub fn missing_any(&self) -> bool {
37        self.missing_face_models || self.missing_onnx_runtime || self.missing_geonames_db
38    }
39
40    pub fn summary(&self) -> String {
41        let mut missing = Vec::new();
42        if self.missing_face_models {
43            missing.push("face models");
44        }
45        if self.missing_onnx_runtime {
46            missing.push("ONNX runtime");
47        }
48        if self.missing_geonames_db {
49            missing.push("GeoNames database");
50        }
51        if missing.is_empty() {
52            "All optional assets are installed.".to_string()
53        } else {
54            format!("Missing: {}", missing.join(", "))
55        }
56    }
57}
58
59#[cfg(target_os = "windows")]
60const ORT_LIB_NAME: &str = "onnxruntime.dll";
61#[cfg(target_os = "macos")]
62const ORT_LIB_NAME: &str = "libonnxruntime.dylib";
63#[cfg(not(any(target_os = "windows", target_os = "macos")))]
64const ORT_LIB_NAME: &str = "libonnxruntime.so";
65fn ort_platform_dir() -> Option<&'static str> {
66    #[cfg(target_os = "windows")]
67    return Some("windows");
68    #[cfg(target_os = "linux")]
69    return Some("linux");
70    #[cfg(target_os = "macos")]
71    return Some("macos");
72    #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
73    return None;
74}
75
76const MIN_BINARY_ASSET_BYTES: u64 = 1024 * 1024;
77
78fn usable_binary_asset(path: &Path) -> bool {
79    std::fs::metadata(path)
80        .is_ok_and(|metadata| metadata.is_file() && metadata.len() >= MIN_BINARY_ASSET_BYTES)
81}
82
83fn runtime_file_name_matches(name: &str) -> bool {
84    #[cfg(target_os = "macos")]
85    {
86        name.starts_with("libonnxruntime") && name.ends_with(".dylib")
87    }
88    #[cfg(not(target_os = "macos"))]
89    {
90        name.starts_with(ORT_LIB_NAME)
91    }
92}
93
94pub fn project_root() -> PathBuf {
95    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
96}
97
98pub fn default_asset_install_dir() -> PathBuf {
99    dirs::data_dir()
100        .unwrap_or_else(|| PathBuf::from("."))
101        .join("smriti")
102        .join("assets")
103}
104
105/// Pre-rename install dir, kept as a secondary lookup so users who
106/// installed assets under the old "photovault" path keep working
107/// after the upgrade. Removed in a future release.
108fn legacy_asset_install_dir() -> PathBuf {
109    dirs::data_dir()
110        .unwrap_or_else(|| PathBuf::from("."))
111        .join("photovault")
112        .join("assets")
113}
114
115pub fn asset_pack_url() -> String {
116    // Read SMRITI_* first, fall back to the legacy PHOTOVAULT_*
117    // env name for one release worth of grace.
118    std::env::var("SMRITI_ASSET_PACK_URL")
119        .or_else(|_| std::env::var("PHOTOVAULT_ASSET_PACK_URL"))
120        .unwrap_or_else(|_| ASSET_PACK_URL_DEFAULT.to_string())
121}
122
123fn find_runtime_in_dir(dir: &Path) -> Option<PathBuf> {
124    let direct = dir.join(ORT_LIB_NAME);
125    if usable_binary_asset(&direct) {
126        return Some(direct);
127    }
128
129    let entries = std::fs::read_dir(dir).ok()?;
130    for entry in entries.flatten() {
131        let path = entry.path();
132        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
133            if runtime_file_name_matches(name) && usable_binary_asset(&path) {
134                return Some(path);
135            }
136        }
137    }
138
139    None
140}
141
142pub fn onnx_runtime_path() -> Option<PathBuf> {
143    for root in candidate_asset_roots() {
144        if let Some(path) = onnx_runtime_path_in_root(&root) {
145            return Some(path);
146        }
147    }
148    None
149}
150
151fn onnx_runtime_path_in_root(root: &Path) -> Option<PathBuf> {
152    let candidate = root.join("libs").join("onnxruntime");
153    if let Some(path) = find_runtime_in_dir(&candidate) {
154        return Some(path);
155    }
156    if let Some(platform_dir) = ort_platform_dir() {
157        return find_runtime_in_dir(&candidate.join(platform_dir));
158    }
159    None
160}
161
162pub fn onnx_runtime_install_path() -> PathBuf {
163    let base = default_asset_install_dir().join("libs").join("onnxruntime");
164    if let Some(platform_dir) = ort_platform_dir() {
165        base.join(platform_dir).join(ORT_LIB_NAME)
166    } else {
167        base.join(ORT_LIB_NAME)
168    }
169}
170
171pub fn onnx_runtime_exists() -> bool {
172    onnx_runtime_path().is_some()
173}
174
175pub fn geonames_db_exists() -> bool {
176    crate::db::geonames::geonames_db_exists()
177}
178
179pub fn asset_health() -> AssetHealth {
180    AssetHealth {
181        missing_face_models: !has_face_models(),
182        missing_onnx_runtime: !onnx_runtime_exists(),
183        missing_geonames_db: !geonames_db_exists(),
184    }
185}
186
187fn asset_health_in_root(root: &Path) -> AssetHealth {
188    let model_dir = root.join("models");
189    let detector = model_dir.join("scrfd_10g_bnkps.onnx");
190    let embedder = model_dir.join(crate::config::AppConfig::load().face_embedder_model);
191    AssetHealth {
192        missing_face_models: !usable_binary_asset(&detector) || !usable_binary_asset(&embedder),
193        missing_onnx_runtime: onnx_runtime_path_in_root(root).is_none(),
194        missing_geonames_db: !crate::db::geonames::geonames_db_is_current(
195            &root.join("data").join("geonames.db"),
196        ),
197    }
198}
199
200fn candidate_asset_roots() -> Vec<PathBuf> {
201    let mut roots = Vec::new();
202
203    roots.push(default_asset_install_dir());
204    // Legacy fallback so users with assets installed under the old
205    // PhotoVault path keep working through the rename.
206    roots.push(legacy_asset_install_dir());
207
208    if let Ok(from_env) = std::env::var("SMRITI_ASSET_DIR") {
209        roots.push(PathBuf::from(from_env));
210    }
211    if let Ok(from_env) = std::env::var("PHOTOVAULT_ASSET_DIR") {
212        roots.push(PathBuf::from(from_env));
213    }
214
215    if let Ok(exe) = std::env::current_exe() {
216        if let Some(exe_dir) = exe.parent() {
217            // Portable/AppImage layout
218            roots.push(exe_dir.to_path_buf());
219            // Debian install layout: /usr/bin/<name> -> /usr/lib/<name>
220            roots.push(exe_dir.join("..").join("lib").join("smriti"));
221            // Legacy install path under the old name.
222            roots.push(exe_dir.join("..").join("lib").join("photovault"));
223            // `cargo tauri dev` runs the binary from target/debug/.
224            // Walk up two levels to land on the workspace root, where
225            // the dev tree's `libs/` and `models/` actually live.
226            roots.push(exe_dir.join("..").join(".."));
227        }
228    }
229    // `cargo tauri dev` sets CWD to src-tauri/. Walk up so the
230    // workspace root's libs/ + models/ are reachable.
231    if let Some(parent) = std::env::current_dir()
232        .ok()
233        .as_deref()
234        .and_then(|p| p.parent())
235    {
236        roots.push(parent.to_path_buf());
237    }
238
239    roots.push(project_root());
240    roots.push(PathBuf::from("/usr/lib/smriti"));
241    roots.push(PathBuf::from("/usr/lib/photovault")); // legacy
242
243    roots
244}
245
246pub fn asset_roots() -> Vec<PathBuf> {
247    candidate_asset_roots()
248}
249
250pub fn model_dir() -> PathBuf {
251    for root in candidate_asset_roots() {
252        let candidate = root.join("models");
253        if candidate.exists() {
254            return candidate;
255        }
256    }
257
258    project_root().join("models")
259}
260
261pub fn detector_model_path() -> PathBuf {
262    model_dir().join("scrfd_10g_bnkps.onnx")
263}
264
265pub fn embedder_model_path() -> PathBuf {
266    model_dir().join(crate::config::AppConfig::load().face_embedder_model)
267}
268
269pub fn has_face_models() -> bool {
270    usable_binary_asset(&detector_model_path()) && usable_binary_asset(&embedder_model_path())
271}
272
273pub fn ensure_geonames_db() {
274    use crate::db::geonames::{build_geonames_db, geonames_db_exists, geonames_db_path};
275
276    if geonames_db_exists() {
277        return;
278    }
279
280    let existing_path = geonames_db_path();
281    let root = existing_path
282        .parent()
283        .and_then(Path::parent)
284        .filter(|root| {
285            root.join("data").join("cities1000.txt").is_file()
286                && root.join("data").join("country_codes.txt").is_file()
287        })
288        .map(Path::to_path_buf)
289        .or_else(|| {
290            candidate_asset_roots().into_iter().find(|root| {
291                root.join("data").join("cities1000.txt").is_file()
292                    && root.join("data").join("country_codes.txt").is_file()
293            })
294        })
295        .unwrap_or_else(project_root);
296    let data_dir = root.join("data");
297    let cities = data_dir.join("cities1000.txt");
298    let countries = data_dir.join("country_codes.txt");
299    let db_path = data_dir.join("geonames.db");
300
301    if !cities.exists() || !countries.exists() {
302        tracing::warn!(
303            "GeoNames source files missing (expected {} and {}). Geocoding disabled until provided. Run {}",
304            cities.display(),
305            countries.display(),
306            SETUP_ASSETS_HINT
307        );
308        return;
309    }
310
311    tracing::info!("GeoNames DB not found; building {}", db_path.display());
312
313    match build_geonames_db(&root) {
314        Ok(()) => tracing::info!("GeoNames DB created at {}", db_path.display()),
315        Err(e) => tracing::warn!("Failed to auto-build GeoNames DB: {}", e),
316    }
317}
318
319pub async fn install_asset_pack() -> Result<String, String> {
320    let install_root = default_asset_install_dir();
321    let install_parent = install_root
322        .parent()
323        .ok_or_else(|| "Asset install path has no parent directory".to_string())?;
324    std::fs::create_dir_all(install_parent).map_err(|e| {
325        format!(
326            "Failed creating asset parent dir {}: {}",
327            install_parent.display(),
328            e
329        )
330    })?;
331
332    // Honour SMRITI_* env vars first, fall back to legacy PHOTOVAULT_*
333    // for one release worth of grace.
334    let local_path = std::env::var("SMRITI_ASSET_PACK_PATH")
335        .or_else(|_| std::env::var("PHOTOVAULT_ASSET_PACK_PATH"))
336        .ok();
337    let (archive_path, remove_archive_after) = if let Some(local_path) = local_path {
338        let path = PathBuf::from(&local_path);
339        let metadata = std::fs::metadata(&path).map_err(|e| {
340            format!(
341                "Failed to read SMRITI_ASSET_PACK_PATH {}: {}",
342                local_path, e
343            )
344        })?;
345        if !metadata.is_file() || metadata.len() > MAX_ASSET_PACK_DOWNLOAD_BYTES {
346            return Err("Local asset pack is not a file or exceeds the 1 GiB limit".to_string());
347        }
348        (path, false)
349    } else {
350        let primary_url = asset_pack_url();
351        let fallback_url = std::env::var("SMRITI_ASSET_PACK_FALLBACK_URL")
352            .or_else(|_| std::env::var("PHOTOVAULT_ASSET_PACK_FALLBACK_URL"))
353            .unwrap_or_else(|_| ASSET_PACK_LATEST_URL.to_string());
354
355        let download_path = install_parent.join(format!(
356            ".smriti-assets-download-{}.zip",
357            std::process::id()
358        ));
359        let client = reqwest::Client::builder()
360            .connect_timeout(std::time::Duration::from_secs(20))
361            .timeout(std::time::Duration::from_secs(30 * 60))
362            .build()
363            .map_err(|error| format!("Failed creating asset download client: {error}"))?;
364        let mut last_error = None;
365        let mut downloaded = false;
366        for url in [primary_url.as_str(), fallback_url.as_str()] {
367            let response = match client.get(url).send().await {
368                Ok(r) => r,
369                Err(e) => {
370                    last_error = Some(format!("request error from {}: {}", url, e));
371                    continue;
372                }
373            };
374
375            if !response.status().is_success() {
376                last_error = Some(format!("HTTP {} from {}", response.status(), url));
377                continue;
378            }
379
380            if response
381                .content_length()
382                .is_some_and(|length| length > MAX_ASSET_PACK_DOWNLOAD_BYTES)
383            {
384                last_error = Some(format!("asset pack from {} exceeds 1 GiB", url));
385                continue;
386            }
387
388            let mut output = match std::fs::File::create(&download_path) {
389                Ok(file) => file,
390                Err(error) => {
391                    last_error = Some(format!("cannot create download file: {}", error));
392                    break;
393                }
394            };
395            let mut stream = response.bytes_stream();
396            let mut written = 0_u64;
397            let mut stream_error = None;
398            while let Some(chunk) = stream.next().await {
399                let chunk = match chunk {
400                    Ok(chunk) => chunk,
401                    Err(error) => {
402                        stream_error = Some(format!("body read error from {}: {}", url, error));
403                        break;
404                    }
405                };
406                written = written.saturating_add(chunk.len() as u64);
407                if written > MAX_ASSET_PACK_DOWNLOAD_BYTES {
408                    stream_error = Some(format!("asset pack from {} exceeds 1 GiB", url));
409                    break;
410                }
411                if let Err(error) = output.write_all(&chunk) {
412                    stream_error = Some(format!("download write failed: {}", error));
413                    break;
414                }
415            }
416            drop(output);
417            if let Some(error) = stream_error {
418                last_error = Some(error);
419                let _ = std::fs::remove_file(&download_path);
420                continue;
421            }
422            if written == 0 {
423                last_error = Some(format!("empty response from {}", url));
424                let _ = std::fs::remove_file(&download_path);
425                continue;
426            }
427            downloaded = true;
428            break;
429        }
430
431        if !downloaded {
432            return Err(format!(
433                "Asset pack download failed. {}. If you are testing locally before publishing a release, set SMRITI_ASSET_PACK_PATH to a local Smriti-Assets.zip.",
434                last_error.unwrap_or_else(|| "No successful download source".to_string())
435            ));
436        }
437        (download_path, true)
438    };
439
440    let install_root_for_worker = install_root.clone();
441    let install_parent_for_worker = install_parent.to_path_buf();
442    tokio::task::spawn_blocking(move || {
443        install_downloaded_asset_pack(
444            &archive_path,
445            remove_archive_after,
446            &install_root_for_worker,
447            &install_parent_for_worker,
448        )
449    })
450    .await
451    .map_err(|error| format!("Asset installation worker failed: {error}"))??;
452
453    Ok(format!("Assets installed to {}", install_root.display()))
454}
455
456fn install_downloaded_asset_pack(
457    archive_path: &Path,
458    remove_archive_after: bool,
459    install_root: &Path,
460    install_parent: &Path,
461) -> Result<(), String> {
462    let nonce = std::time::SystemTime::now()
463        .duration_since(std::time::UNIX_EPOCH)
464        .map(|duration| duration.as_nanos())
465        .unwrap_or(0);
466    let staging = install_parent.join(format!(
467        ".smriti-assets-installing-{}-{nonce}",
468        std::process::id()
469    ));
470    if staging.exists() {
471        std::fs::remove_dir_all(&staging)
472            .map_err(|e| format!("Failed clearing stale asset staging directory: {e}"))?;
473    }
474    std::fs::create_dir_all(&staging)
475        .map_err(|e| format!("Failed creating asset staging directory: {e}"))?;
476
477    let install_result = (|| {
478        extract_asset_archive(archive_path, &staging)?;
479        prepare_staged_asset_pack(&staging)?;
480        merge_asset_tree(&staging, install_root)
481    })();
482
483    let cleanup_result = std::fs::remove_dir_all(&staging);
484    if remove_archive_after {
485        let _ = std::fs::remove_file(archive_path);
486    }
487    install_result?;
488    if let Err(error) = cleanup_result {
489        tracing::warn!(
490            "Assets installed, but staging cleanup failed at {}: {}",
491            staging.display(),
492            error
493        );
494    }
495
496    Ok(())
497}
498
499fn prepare_staged_asset_pack(staging: &Path) -> Result<(), String> {
500    // v0.3.1's published pack accidentally contained GeoNames source text
501    // but no geonames.db. Repair that pack locally so one-click setup works
502    // for existing releases as well as corrected future ones.
503    let staged_geonames = staging.join("data").join("geonames.db");
504    if !crate::db::geonames::geonames_db_is_current(&staged_geonames) {
505        let cities = staging.join("data").join("cities1000.txt");
506        let countries = staging.join("data").join("country_codes.txt");
507        if cities.is_file() && countries.is_file() {
508            crate::db::geonames::build_geonames_db(staging)?;
509        }
510    }
511
512    // Validate this download itself. Looking across all candidate roots could
513    // let an old development asset mask an incomplete release archive.
514    let health = asset_health_in_root(staging);
515    if health.missing_any() {
516        return Err(format!(
517            "Downloaded asset pack is incomplete. {}",
518            health.summary()
519        ));
520    }
521    Ok(())
522}
523
524fn extract_asset_archive(archive_path: &Path, destination: &Path) -> Result<(), String> {
525    let reader = std::fs::File::open(archive_path)
526        .map_err(|e| format!("Failed opening downloaded asset zip: {}", e))?;
527    let mut archive = ZipArchive::new(reader).map_err(|e| format!("Invalid asset zip: {}", e))?;
528    if archive.len() > MAX_ASSET_PACK_FILES {
529        return Err(format!(
530            "Asset zip contains too many entries ({}; maximum {})",
531            archive.len(),
532            MAX_ASSET_PACK_FILES
533        ));
534    }
535
536    let mut expanded_bytes = 0_u64;
537    for i in 0..archive.len() {
538        let mut file = archive
539            .by_index(i)
540            .map_err(|e| format!("Failed reading zip entry {}: {}", i, e))?;
541        expanded_bytes = expanded_bytes
542            .checked_add(file.size())
543            .ok_or_else(|| "Asset zip expanded size overflow".to_string())?;
544        if expanded_bytes > MAX_ASSET_PACK_EXPANDED_BYTES {
545            return Err("Asset zip expands beyond the 2 GiB safety limit".to_string());
546        }
547        let enclosed = file
548            .enclosed_name()
549            .ok_or_else(|| format!("Unsafe path in zip entry: {}", file.name()))?;
550
551        let out_path = destination.join(enclosed);
552        if file.is_dir() {
553            std::fs::create_dir_all(&out_path)
554                .map_err(|e| format!("Failed creating directory {}: {}", out_path.display(), e))?;
555            continue;
556        }
557
558        if let Some(parent) = out_path.parent() {
559            std::fs::create_dir_all(parent).map_err(|e| {
560                format!(
561                    "Failed creating parent directory {}: {}",
562                    parent.display(),
563                    e
564                )
565            })?;
566        }
567
568        let mut out = std::fs::File::create(&out_path)
569            .map_err(|e| format!("Failed creating {}: {}", out_path.display(), e))?;
570        std::io::copy(&mut file, &mut out)
571            .map_err(|e| format!("Failed writing {}: {}", out_path.display(), e))?;
572    }
573    Ok(())
574}
575
576fn merge_asset_tree(source: &Path, destination: &Path) -> Result<(), String> {
577    std::fs::create_dir_all(destination)
578        .map_err(|e| format!("Failed creating {}: {}", destination.display(), e))?;
579    for entry in std::fs::read_dir(source)
580        .map_err(|e| format!("Failed reading {}: {}", source.display(), e))?
581    {
582        let entry = entry.map_err(|e| format!("Failed reading asset entry: {}", e))?;
583        let source_path = entry.path();
584        let destination_path = destination.join(entry.file_name());
585        if entry
586            .file_type()
587            .map_err(|e| format!("Failed reading asset file type: {}", e))?
588            .is_dir()
589        {
590            merge_asset_tree(&source_path, &destination_path)?;
591        } else {
592            let temp_path = destination_path.with_extension("smriti-installing");
593            std::fs::copy(&source_path, &temp_path).map_err(|e| {
594                format!(
595                    "Failed staging asset {} to {}: {}",
596                    source_path.display(),
597                    temp_path.display(),
598                    e
599                )
600            })?;
601            if destination_path.exists() {
602                std::fs::remove_file(&destination_path).map_err(|e| {
603                    format!(
604                        "Failed replacing asset {}: {}",
605                        destination_path.display(),
606                        e
607                    )
608                })?;
609            }
610            std::fs::rename(&temp_path, &destination_path).map_err(|e| {
611                format!(
612                    "Failed installing asset {}: {}",
613                    destination_path.display(),
614                    e
615                )
616            })?;
617        }
618    }
619    Ok(())
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn resolves_runtime_from_asset_pack_platform_subdir() {
628        let temp = tempfile::tempdir().expect("tempdir");
629        let runtime_dir = if let Some(platform_dir) = ort_platform_dir() {
630            temp.path()
631                .join("libs")
632                .join("onnxruntime")
633                .join(platform_dir)
634        } else {
635            temp.path().join("libs").join("onnxruntime")
636        };
637        std::fs::create_dir_all(&runtime_dir).expect("runtime dir");
638        let runtime = runtime_dir.join(ORT_LIB_NAME);
639        std::fs::write(&runtime, vec![0_u8; MIN_BINARY_ASSET_BYTES as usize])
640            .expect("runtime marker");
641
642        assert_eq!(onnx_runtime_path_in_root(temp.path()), Some(runtime));
643    }
644
645    #[test]
646    fn runtime_install_path_matches_asset_pack_layout() {
647        let path = onnx_runtime_install_path();
648        let suffix = if let Some(platform_dir) = ort_platform_dir() {
649            PathBuf::from("libs")
650                .join("onnxruntime")
651                .join(platform_dir)
652                .join(ORT_LIB_NAME)
653        } else {
654            PathBuf::from("libs").join("onnxruntime").join(ORT_LIB_NAME)
655        };
656
657        assert!(path.ends_with(suffix));
658    }
659
660    #[test]
661    fn default_asset_pack_matches_running_app_version() {
662        assert!(ASSET_PACK_URL_DEFAULT.contains(&format!(
663            "/v{}/Smriti-Assets.zip",
664            env!("CARGO_PKG_VERSION")
665        )));
666    }
667
668    #[test]
669    fn rejects_truncated_runtime_files() {
670        let temp = tempfile::tempdir().expect("tempdir");
671        let runtime_dir = temp.path().join("libs").join("onnxruntime");
672        std::fs::create_dir_all(&runtime_dir).expect("runtime dir");
673        std::fs::write(runtime_dir.join(ORT_LIB_NAME), b"download error page")
674            .expect("runtime marker");
675
676        assert_eq!(onnx_runtime_path_in_root(temp.path()), None);
677    }
678
679    #[test]
680    fn repairs_published_pack_with_geonames_sources_but_no_database() {
681        let temp = tempfile::tempdir().expect("tempdir");
682        let root = temp.path();
683        let data = root.join("data");
684        let models = root.join("models");
685        let runtime_dir = if let Some(platform_dir) = ort_platform_dir() {
686            root.join("libs").join("onnxruntime").join(platform_dir)
687        } else {
688            root.join("libs").join("onnxruntime")
689        };
690        std::fs::create_dir_all(&data).expect("data");
691        std::fs::create_dir_all(&models).expect("models");
692        std::fs::create_dir_all(&runtime_dir).expect("runtime");
693        std::fs::write(data.join("country_codes.txt"), "ZZ\tTest Country\n").expect("countries");
694        let mut cities = String::new();
695        for id in 1..=1_001 {
696            cities.push_str(&format!(
697                "{id}\tCity {id}\tCity {id}\t\t1.0\t2.0\tP\tPPL\tZZ\t\t\t\t\t\t{id}\t\t\tUTC\t2026-01-01\n"
698            ));
699        }
700        std::fs::write(data.join("cities1000.txt"), cities).expect("cities");
701        let marker = vec![0_u8; MIN_BINARY_ASSET_BYTES as usize];
702        std::fs::write(models.join("scrfd_10g_bnkps.onnx"), &marker).expect("detector");
703        std::fs::write(models.join("adaface_ir101_webface12m.onnx"), &marker).expect("embedder");
704        std::fs::write(runtime_dir.join(ORT_LIB_NAME), marker).expect("runtime");
705
706        prepare_staged_asset_pack(root).expect("repair and validate pack");
707
708        assert!(crate::db::geonames::geonames_db_is_current(
709            &data.join("geonames.db")
710        ));
711    }
712}