Security audit finding SEC-DB-01. The three read-only opens of the catalog `game.db` (runtime bundle loader, legacy ETI reader and the catalog publisher) set only `read_only(true)`. SQLite still honours schema-embedded SQL in that mode: triggers, views, CHECK constraints and expression indexes may call functions with side effects or virtual tables unless `trusted_schema` is off. `harden_read_only_catalog_options` now applies `trusted_schema = OFF` and `cell_size_check = ON` to those connections. The database is a bundled application resource, not a remote input, so this is defense in depth against a corrupted or tampered bundle; it has no effect on the parameterised queries the code runs. Test plan: `just test` (the compat tests open real fixture databases through the hardened options). Claude-Session: https://claude.ai/code/session_017C3Nbgwpdm3YNwZhhFLHwg
742 lines
24 KiB
Rust
742 lines
24 KiB
Rust
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<CatalogBundle>,
|
|
}
|
|
|
|
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<CatalogBundle> {
|
|
Arc::clone(&self.bundle)
|
|
}
|
|
|
|
/// Splits the loaded state into its UI and content-authority components.
|
|
#[must_use]
|
|
pub fn into_parts(self) -> (GameDB, Arc<CatalogBundle>) {
|
|
(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<LoadedCatalog> {
|
|
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<CatalogAuthorityRow>, Vec<JoinedCatalogRow>), 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<T>(
|
|
pool: &SqlitePool,
|
|
query_result: Result<T, sqlx::Error>,
|
|
) -> Result<T, sqlx::Error> {
|
|
// `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<CatalogAuthorityRow>,
|
|
ui_rows: Vec<JoinedCatalogRow>,
|
|
manifests_root: &Path,
|
|
) -> eyre::Result<LoadedCatalog> {
|
|
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::<BTreeMap<_, _>>()
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
let game_ids = indexed_games
|
|
.iter()
|
|
.map(|(game_id, _)| *game_id)
|
|
.collect::<Vec<_>>();
|
|
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());
|
|
}
|
|
}
|