use std::{ collections::{BTreeMap, HashSet}, ffi::OsStr, fs, path::Path, }; use eyre::WrapErr; use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; /// The authoritative identity/version fields used by manifest publishing. #[derive(Clone, Debug, Eq, PartialEq, sqlx::FromRow)] pub struct CatalogGame { pub game_id: String, pub game_version: String, } /// Loads every catalog identity directly from `games`, rejecting duplicate IDs /// instead of inheriting the application's historical last-row-wins behavior. /// /// # Errors /// /// Returns an error when the database cannot be read, has no games, or contains /// duplicate game IDs. pub async fn load_catalog_games(path: &Path) -> eyre::Result> { validate_regular_file(path, "catalog database")?; let options = SqliteConnectOptions::new().filename(path).read_only(true); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await .wrap_err_with(|| format!("failed to open catalog database {}", path.display()))?; let query_result = sqlx::query_as::<_, CatalogGame>( "SELECT game_id, game_version FROM games ORDER BY game_id, db_id", ) .fetch_all(&pool) .await; let rows = close_pool_after_query(&pool, query_result) .await .wrap_err_with(|| format!("failed to read catalog database {}", path.display()))?; let mut games = BTreeMap::new(); for game in rows { let game_id = game.game_id.clone(); if games.insert(game_id.clone(), game).is_some() { eyre::bail!("catalog database contains duplicate game ID: {game_id}"); } } if games.is_empty() { eyre::bail!("catalog database contains no games"); } Ok(games) } async fn close_pool_after_query( pool: &SqlitePool, query_result: Result, ) -> Result { // `Pool::close` is infallible, so preserve the original query result after // waiting for every SQLite connection to close on both result paths. pool.close().await; debug_assert!(pool.is_closed()); query_result } pub(super) fn reject_unexpected_manifest_artifacts( root: &Path, catalog: &BTreeMap, ) -> eyre::Result<()> { match fs::symlink_metadata(root) { Ok(_) => validate_regular_directory(root)?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(error) => return Err(error.into()), } let mut portable_names = HashSet::new(); for entry in fs::read_dir(root)? { let entry = entry?; let path = entry.path(); if !path .extension() .and_then(OsStr::to_str) .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) { continue; } let file_name = entry .file_name() .into_string() .map_err(|name| eyre::eyre!("manifest filename is not valid UTF-8: {name:?}"))?; let game_id = file_name .strip_suffix(".json") .ok_or_else(|| eyre::eyre!("manifest suffix must be lowercase .json: {file_name}"))?; if !catalog.contains_key(game_id) { eyre::bail!("unexpected catalog manifest artifact: {file_name}"); } if !portable_names.insert(game_id.to_uppercase()) { eyre::bail!("duplicate or platform-alias manifest artifact: {file_name}"); } } Ok(()) } pub(super) fn validate_regular_directory(path: &Path) -> eyre::Result<()> { let metadata = fs::symlink_metadata(path) .wrap_err_with(|| format!("failed to inspect directory {}", path.display()))?; if is_link_or_reparse(&metadata) || !metadata.is_dir() { eyre::bail!("expected a regular non-link directory: {}", path.display()); } Ok(()) } 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(test)] mod tests { use std::{ path::PathBuf, sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; use sqlx::sqlite::SqlitePoolOptions; use super::*; static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); struct TempDb(PathBuf); impl TempDb { fn new() -> Self { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock should follow epoch") .as_nanos(); let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); Self(std::env::temp_dir().join(format!( "lanspread-duplicate-catalog-{}-{nanos}-{sequence}.db", std::process::id() ))) } } impl Drop for TempDb { fn drop(&mut self) { let _ = fs::remove_file(&self.0); } } #[tokio::test] async fn duplicate_database_game_ids_are_rejected() { let db = TempDb::new(); let options = SqliteConnectOptions::new() .filename(&db.0) .create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(1) .connect_with(options) .await .expect("temporary database should open"); sqlx::query( "CREATE TABLE games (game_id TEXT NOT NULL, game_version TEXT NOT NULL, db_id INTEGER NOT NULL)", ) .execute(&pool) .await .expect("table should be created"); sqlx::query( "INSERT INTO games (game_id, game_version, db_id) VALUES ('g', '20240101', 1), ('g', '20240101', 2)", ) .execute(&pool) .await .expect("duplicate rows should be inserted"); pool.close().await; let error = load_catalog_games(&db.0) .await .expect_err("duplicate IDs should fail"); assert!(error.to_string().contains("duplicate game ID: g")); } #[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()); } } #[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() }