use std::{ collections::{BTreeMap, HashSet}, fs, path::Path, sync::Arc, }; use eyre::WrapErr; use lanspread_db::{ content_manifest::CatalogBundle, db::{Game, GameDB}, }; use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; use crate::eti::EtiGame; /// Application catalog state loaded from one coherent database snapshot. #[derive(Clone, Debug)] pub struct LoadedCatalog { game_db: GameDB, bundle: Arc, } impl LoadedCatalog { /// Returns the UI-facing game database. #[must_use] pub const fn game_db(&self) -> &GameDB { &self.game_db } /// Returns the immutable content authority paired with the UI database. #[must_use] pub fn bundle(&self) -> &CatalogBundle { &self.bundle } /// Clones the shared handle used by peer runtime consumers. #[must_use] pub fn shared_bundle(&self) -> Arc { Arc::clone(&self.bundle) } /// Splits the loaded state into its UI and content-authority components. #[must_use] pub fn into_parts(self) -> (GameDB, Arc) { (self.game_db, self.bundle) } } /// Applies defensive SQLite settings to a read-only catalog connection. /// /// `trusted_schema = OFF` stops SQL functions with side effects and virtual /// tables from running out of triggers, views, CHECK constraints or indexes /// stored inside the database file, and `cell_size_check = ON` makes the /// b-tree layer validate cell sizes when reading pages. The catalog database /// ships inside the application bundle, so this is defense in depth against a /// corrupted or tampered resource rather than a remote input. #[must_use] pub fn harden_read_only_catalog_options(options: SqliteConnectOptions) -> SqliteConnectOptions { options .pragma("trusted_schema", "OFF") .pragma("cell_size_check", "ON") } /// Loads the UI catalog and its exact content authority from one database /// snapshot. /// /// Manifest filenames and filesystem shapes are checked eagerly. Manifest /// bodies remain on-demand so application startup does not parse every catalog /// artifact. /// /// # Errors /// /// Returns an error when the database or manifest root is unsafe or invalid, /// database identities are ambiguous, genre expansion is not exactly one row /// per game, or manifest filenames do not exactly cover the database catalog. pub async fn load_catalog_bundle( game_db_path: &Path, manifests_root: &Path, ) -> eyre::Result { validate_regular_file(game_db_path, "catalog database")?; let options = harden_read_only_catalog_options( SqliteConnectOptions::new() .filename(game_db_path) .read_only(true), ); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await .wrap_err_with(|| format!("failed to open catalog database {}", game_db_path.display()))?; let query_result = query_catalog_snapshot(&pool).await; let (authority_rows, ui_rows) = close_pool_after_query(&pool, query_result) .await .wrap_err_with(|| format!("failed to read catalog database {}", game_db_path.display()))?; assemble_catalog(authority_rows, ui_rows, manifests_root) } #[derive(Clone, Debug, sqlx::FromRow)] struct CatalogAuthorityRow { db_id: i64, game_id: String, game_version: String, } #[derive(Debug, sqlx::FromRow)] struct JoinedCatalogRow { db_id: i64, game_id: String, game_title: String, game_key: String, game_release: String, game_publisher: String, game_size: f64, game_readme_de: String, game_readme_en: String, game_readme_fr: String, game_maxplayers: u32, game_master_req: i32, genre_de: String, game_version: String, } impl JoinedCatalogRow { fn into_eti_game(self) -> EtiGame { EtiGame { game_id: self.game_id, game_title: self.game_title, game_key: self.game_key, game_release: self.game_release, game_publisher: self.game_publisher, game_size: self.game_size, game_readme_de: self.game_readme_de, game_readme_en: self.game_readme_en, game_readme_fr: self.game_readme_fr, game_maxplayers: self.game_maxplayers, game_master_req: self.game_master_req, genre_de: self.genre_de, game_version: self.game_version, } } } async fn query_catalog_snapshot( pool: &SqlitePool, ) -> Result<(Vec, Vec), sqlx::Error> { let mut transaction = pool.begin().await?; let authority_rows = sqlx::query_as::<_, CatalogAuthorityRow>( "SELECT CAST(db_id AS INTEGER) AS db_id, game_id, game_version FROM games ORDER BY db_id, game_id", ) .fetch_all(&mut *transaction) .await?; let ui_rows = sqlx::query_as::<_, JoinedCatalogRow>( "SELECT CAST(g.db_id AS INTEGER) AS db_id, g.game_id, g.game_title, g.game_key, g.game_release, g.game_publisher, CAST(g.game_size AS REAL) AS game_size, g.game_readme_de, g.game_readme_en, g.game_readme_fr, CAST(g.game_maxplayers AS INTEGER) AS game_maxplayers, g.game_master_req, ge.genre_de, g.game_version FROM games g JOIN genre ge ON g.genre_id = ge.genre_id ORDER BY g.db_id, g.game_id", ) .fetch_all(&mut *transaction) .await?; transaction.commit().await?; Ok((authority_rows, ui_rows)) } async fn close_pool_after_query( pool: &SqlitePool, query_result: Result, ) -> Result { // `Pool::close` is infallible. Await it on both result paths before the // caller propagates a database error. pool.close().await; debug_assert!(pool.is_closed()); query_result } fn assemble_catalog( authority_rows: Vec, ui_rows: Vec, manifests_root: &Path, ) -> eyre::Result { if authority_rows.is_empty() { eyre::bail!("catalog database contains no games"); } let mut authorities_by_db_id = BTreeMap::new(); let mut expected_versions = BTreeMap::new(); for row in authority_rows { if authorities_by_db_id.contains_key(&row.db_id) { eyre::bail!( "catalog database contains duplicate raw game db_id: {}", row.db_id ); } if expected_versions.contains_key(&row.game_id) { eyre::bail!( "catalog database contains duplicate raw game ID: {}", row.game_id ); } expected_versions.insert(row.game_id.clone(), row.game_version.clone()); authorities_by_db_id.insert(row.db_id, row); } let mut expanded_db_ids = HashSet::new(); let mut games = Vec::with_capacity(authorities_by_db_id.len()); for row in ui_rows { let authority = authorities_by_db_id .get(&row.db_id) .ok_or_else(|| eyre::eyre!("genre join produced unknown game db_id: {}", row.db_id))?; if !expanded_db_ids.insert(row.db_id) { eyre::bail!( "duplicate genre join expansion for game {} (db_id {})", authority.game_id, authority.db_id ); } if row.game_id != authority.game_id || row.game_version != authority.game_version { eyre::bail!( "catalog identity/version mismatch for game db_id {}", authority.db_id ); } games.push(Game::from(row.into_eti_game())); } for (db_id, authority) in &authorities_by_db_id { if !expanded_db_ids.contains(db_id) { eyre::bail!( "missing genre join expansion for game {} (db_id {})", authority.game_id, authority.db_id ); } } let bundle = Arc::new(CatalogBundle::new(manifests_root, expected_versions)?); Ok(LoadedCatalog { game_db: GameDB::from(games), bundle, }) } fn validate_regular_file(path: &Path, label: &str) -> eyre::Result<()> { let metadata = fs::symlink_metadata(path) .wrap_err_with(|| format!("failed to inspect {label} {}", path.display()))?; if is_link_or_reparse(&metadata) || !metadata.is_file() { eyre::bail!("{label} is not a regular non-link file: {}", path.display()); } Ok(()) } #[cfg(unix)] fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { metadata.file_type().is_symlink() } #[cfg(windows)] fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; metadata.file_type().is_symlink() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 } #[cfg(not(any(unix, windows)))] fn is_link_or_reparse(metadata: &fs::Metadata) -> bool { metadata.file_type().is_symlink() } #[cfg(test)] mod tests { use std::{ path::{Path, PathBuf}, sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; use lanspread_db::content_manifest::{ CATALOG_CONTENT_INDEX_NAME, CatalogContentIdentity, CatalogContentIndex, CatalogContentIndexEntry, ContentId, write_canonical_content_index_atomic, }; use sqlx::sqlite::SqlitePoolOptions; use super::*; static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); struct TestDir(PathBuf); impl TestDir { fn new() -> Self { let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock should follow epoch") .as_nanos(); let path = std::env::temp_dir().join(format!( "lanspread-catalog-bundle-{}-{nanos}-{sequence}", std::process::id() )); fs::create_dir(&path).expect("test directory should be created"); Self(path) } fn path(&self) -> &Path { &self.0 } } impl Drop for TestDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } #[derive(Clone, Copy)] struct GameRow<'a> { db_id: i64, game_id: &'a str, game_version: &'a str, genre_id: i64, } async fn create_catalog_db(path: &Path, games: &[GameRow<'_>], genres: &[(i64, &str)]) { let options = SqliteConnectOptions::new() .filename(path) .create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await .expect("fixture database should open"); sqlx::query( "CREATE TABLE games ( game_id TEXT NOT NULL, db_id INTEGER NOT NULL, game_title TEXT NOT NULL, game_key TEXT NOT NULL, game_release TEXT NOT NULL, game_publisher TEXT NOT NULL, game_size REAL NOT NULL, game_readme_de TEXT NOT NULL, game_readme_en TEXT NOT NULL, game_readme_fr TEXT NOT NULL, game_maxplayers INTEGER NOT NULL, game_master_req INTEGER NOT NULL, genre_id INTEGER NOT NULL, game_version TEXT NOT NULL )", ) .execute(&pool) .await .expect("games table should be created"); sqlx::query("CREATE TABLE genre (genre_id INTEGER NOT NULL, genre_de TEXT NOT NULL)") .execute(&pool) .await .expect("genre table should be created"); for (genre_id, genre_de) in genres { sqlx::query("INSERT INTO genre (genre_id, genre_de) VALUES (?, ?)") .bind(genre_id) .bind(genre_de) .execute(&pool) .await .expect("genre should insert"); } for game in games { sqlx::query( "INSERT INTO games ( game_id, db_id, game_title, game_key, game_release, game_publisher, game_size, game_readme_de, game_readme_en, game_readme_fr, game_maxplayers, game_master_req, genre_id, game_version ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(game.game_id) .bind(game.db_id) .bind(format!("Game {}", game.game_id)) .bind("key") .bind("2024") .bind("publisher") .bind(1.0_f64) .bind("readme de") .bind("readme en") .bind("readme fr") .bind(4_i64) .bind(0_i64) .bind(game.genre_id) .bind(game.game_version) .execute(&pool) .await .expect("game should insert"); } pool.close().await; } fn create_manifest_root( root: &Path, indexed_games: &[(&str, &str)], artifact_game_ids: &[&str], ) -> PathBuf { let manifests = root.join("manifests"); fs::create_dir(&manifests).expect("manifest root should be created"); for game_id in artifact_game_ids { fs::write( manifests.join(format!("{game_id}.json")), b"not parsed at startup\n", ) .expect("manifest artifact should be created"); } let index = CatalogContentIndex::from_entries(indexed_games.iter().enumerate().map( |(position, (game_id, game_version))| CatalogContentIndexEntry { game_id: (*game_id).to_owned(), game_version: (*game_version).to_owned(), identity: CatalogContentIdentity { content_id: ContentId::from_bytes( [u8::try_from(position + 1).expect("test position should fit u8"); 32], ), supports_streamed_install: false, }, }, )) .expect("test content index should validate"); write_canonical_content_index_atomic(&manifests.join(CATALOG_CONTENT_INDEX_NAME), &index) .expect("test content index should publish"); manifests } fn joined_row(db_id: i64, game_id: &str, game_version: &str) -> JoinedCatalogRow { JoinedCatalogRow { db_id, game_id: game_id.to_owned(), game_title: format!("Game {game_id}"), game_key: "key".to_owned(), game_release: "2024".to_owned(), game_publisher: "publisher".to_owned(), game_size: 1.0, game_readme_de: "readme de".to_owned(), game_readme_en: "readme en".to_owned(), game_readme_fr: "readme fr".to_owned(), game_maxplayers: 4, game_master_req: 0, genre_de: "Strategy".to_owned(), game_version: game_version.to_owned(), } } #[tokio::test] async fn loader_pairs_ui_catalog_with_lazy_exact_authority() { let root = TestDir::new(); let db = root.path().join("game.db"); create_catalog_db( &db, &[GameRow { db_id: 1, game_id: "g", game_version: "20240101", genre_id: 10, }], &[(10, "Strategy")], ) .await; let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); let loaded = load_catalog_bundle(&db, &manifests) .await .expect("filename coverage should load without parsing JSON"); let game = loaded .game_db() .get_game_by_id("g") .expect("UI game should exist"); assert_eq!(game.genre, "Strategy"); assert_eq!(game.eti_game_version.as_deref(), Some("20240101")); assert!(loaded.bundle().catalog().contains("g")); assert_eq!( loaded.bundle().catalog().expected_version("g"), Some("20240101") ); assert_eq!( loaded .bundle() .content_identity("g") .expect("indexed identity should be available") .content_id, ContentId::from_bytes([1; 32]) ); assert!(loaded.bundle().cached_manifest("g").is_err()); assert!(loaded.bundle().manifest("g").is_err()); fs::remove_file(&db).expect("successful loading must close the catalog database"); } #[tokio::test] async fn loader_rejects_incomplete_manifest_publication() { let root = TestDir::new(); let db = root.path().join("game.db"); create_catalog_db( &db, &[GameRow { db_id: 1, game_id: "g", game_version: "20240101", genre_id: 10, }], &[(10, "Strategy")], ) .await; let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); fs::write( manifests.join(lanspread_db::content_manifest::CATALOG_PUBLICATION_MARKER_NAME), b"lanspread catalog publication v1\n", ) .expect("publication marker should be created"); let error = load_catalog_bundle(&db, &manifests) .await .expect_err("runtime loading must reject an interrupted publication"); assert!(error.to_string().contains("publication is incomplete")); fs::remove_file(&db).expect("validation failure must leave the database closed"); } #[tokio::test] async fn loader_rejects_duplicate_raw_game_ids_before_hash_authority() { let root = TestDir::new(); let db = root.path().join("game.db"); create_catalog_db( &db, &[ GameRow { db_id: 1, game_id: "g", game_version: "20240101", genre_id: 10, }, GameRow { db_id: 2, game_id: "g", game_version: "20240101", genre_id: 10, }, ], &[(10, "Strategy")], ) .await; let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); let error = load_catalog_bundle(&db, &manifests) .await .expect_err("duplicate raw IDs must fail"); assert!(error.to_string().contains("duplicate raw game ID: g")); fs::remove_file(&db).expect("validation failure must leave the database closed"); } #[tokio::test] async fn loader_rejects_missing_genre_expansion() { let root = TestDir::new(); let db = root.path().join("game.db"); create_catalog_db( &db, &[GameRow { db_id: 1, game_id: "g", game_version: "20240101", genre_id: 10, }], &[], ) .await; let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); let error = load_catalog_bundle(&db, &manifests) .await .expect_err("a game without a joined genre must fail"); assert!(error.to_string().contains("missing genre join expansion")); } #[tokio::test] async fn loader_rejects_duplicate_genre_expansion() { let root = TestDir::new(); let db = root.path().join("game.db"); create_catalog_db( &db, &[GameRow { db_id: 1, game_id: "g", game_version: "20240101", genre_id: 10, }], &[(10, "Strategy"), (10, "Duplicate")], ) .await; let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); let error = load_catalog_bundle(&db, &manifests) .await .expect_err("ambiguous genre expansion must fail"); assert!(error.to_string().contains("duplicate genre join expansion")); } #[tokio::test] async fn production_loader_rejects_identity_and_genre_corruption_matrix() { let cases = [ ( "duplicate raw database ID", vec![ GameRow { db_id: 1, game_id: "g", game_version: "20240101", genre_id: 10, }, GameRow { db_id: 1, game_id: "h", game_version: "20240102", genre_id: 10, }, ], vec![(10, "Strategy")], "duplicate raw game db_id", ), ( "missing genre join", vec![GameRow { db_id: 1, game_id: "g", game_version: "20240101", genre_id: 10, }], vec![], "missing genre join expansion", ), ( "duplicate genre join", vec![GameRow { db_id: 1, game_id: "g", game_version: "20240101", genre_id: 10, }], vec![(10, "Strategy"), (10, "Duplicate")], "duplicate genre join expansion", ), ]; for (label, games, genres, expected_error) in cases { let root = TestDir::new(); let db = root.path().join("game.db"); create_catalog_db(&db, &games, &genres).await; let indexed_games = games .iter() .map(|game| (game.game_id, game.game_version)) .collect::>() .into_iter() .collect::>(); let game_ids = indexed_games .iter() .map(|(game_id, _)| *game_id) .collect::>(); let manifests = create_manifest_root(root.path(), &indexed_games, &game_ids); let error = load_catalog_bundle(&db, &manifests).await.expect_err(label); assert!( error.to_string().contains(expected_error), "{label} produced unexpected error: {error:#}" ); fs::remove_file(&db).expect("loader failure must leave the database closed"); } } #[tokio::test] async fn loader_rejects_inexact_manifest_filename_coverage() { let root = TestDir::new(); let db = root.path().join("game.db"); create_catalog_db( &db, &[GameRow { db_id: 1, game_id: "g", game_version: "20240101", genre_id: 10, }], &[(10, "Strategy")], ) .await; let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g", "other"]); let error = load_catalog_bundle(&db, &manifests) .await .expect_err("unexpected JSON artifacts must fail"); assert!( error .to_string() .contains("unexpected catalog manifest artifact: other.json") ); } #[test] fn assembler_rejects_ui_authority_version_drift() { let root = TestDir::new(); let manifests = create_manifest_root(root.path(), &[("g", "20240101")], &["g"]); let authority = CatalogAuthorityRow { db_id: 1, game_id: "g".to_owned(), game_version: "20240101".to_owned(), }; let error = assemble_catalog( vec![authority], vec![joined_row(1, "g", "20250101")], &manifests, ) .expect_err("UI rows must not drift from the authority snapshot"); assert!(error.to_string().contains("identity/version mismatch")); } #[tokio::test] async fn query_error_is_returned_only_after_pool_close() { let pool = SqlitePoolOptions::new().connect_lazy_with(SqliteConnectOptions::new().in_memory(true)); let query_result: Result<(), sqlx::Error> = Err(sqlx::Error::RowNotFound); let error = close_pool_after_query(&pool, query_result) .await .expect_err("query failure should propagate"); assert!(matches!(error, sqlx::Error::RowNotFound)); assert!(pool.is_closed()); } }