1use 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
105fn 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 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 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 roots.push(exe_dir.to_path_buf());
219 roots.push(exe_dir.join("..").join("lib").join("smriti"));
221 roots.push(exe_dir.join("..").join("lib").join("photovault"));
223 roots.push(exe_dir.join("..").join(".."));
227 }
228 }
229 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")); 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 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 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 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}