feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
use std::io::{self, Write};
|
||||
|
||||
use lanspread_compat::catalog_publisher::cli::{HELP, ParseOutcome, execute, parse_args};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(error) = run().await {
|
||||
eprintln!("error: {error:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> eyre::Result<()> {
|
||||
match parse_args(std::env::args_os().skip(1))? {
|
||||
ParseOutcome::Help => println!("{HELP}"),
|
||||
ParseOutcome::Command(command) => {
|
||||
let stdout = io::stdout();
|
||||
let mut stdout = stdout.lock();
|
||||
for line in execute(&command).await? {
|
||||
writeln!(stdout, "{line}")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Test-only catalog generator used by the peer-CLI acceptance fixtures.
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
ffi::OsString,
|
||||
io::{self, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use lanspread_compat::catalog_publisher::fixture::{
|
||||
FixtureCatalogOptions,
|
||||
FixturePackage,
|
||||
generate_fixture_catalog,
|
||||
};
|
||||
|
||||
const HELP: &str = "\
|
||||
Usage:
|
||||
lanspread-fixture-catalog --source-catalog-db PATH --output-dir PATH --unrar PATH --game-root PATH... [--no-stream-install GAME_ID...]
|
||||
|
||||
This test-only tool creates a reduced game.db and sibling manifests/ from the
|
||||
selected fixture packages. --no-stream-install is only for synthetic transfer
|
||||
fixtures whose .eti bytes intentionally are not RAR archives.";
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Options(FixtureCatalogOptions),
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(error) = run().await {
|
||||
eprintln!("error: {error:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> eyre::Result<()> {
|
||||
match parse_args(std::env::args_os().skip(1))? {
|
||||
ParseOutcome::Help => println!("{HELP}"),
|
||||
ParseOutcome::Options(options) => {
|
||||
let reports = generate_fixture_catalog(&options).await?;
|
||||
let stdout = io::stdout();
|
||||
let mut stdout = stdout.lock();
|
||||
for report in &reports {
|
||||
writeln!(
|
||||
stdout,
|
||||
"generated fixture game_id={} game_version={} content_id={}",
|
||||
report.game_id, report.game_version, report.content_id
|
||||
)?;
|
||||
}
|
||||
writeln!(stdout, "generated fixture total={}", reports.len())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(args: impl IntoIterator<Item = OsString>) -> eyre::Result<ParseOutcome> {
|
||||
let mut source_catalog_db = None;
|
||||
let mut output_dir = None;
|
||||
let mut unrar = None;
|
||||
let mut game_roots = Vec::new();
|
||||
let mut no_streamed_install = BTreeSet::new();
|
||||
let mut args = args.into_iter();
|
||||
|
||||
while let Some(argument) = args.next() {
|
||||
match argument.to_str() {
|
||||
Some("--help" | "-h") => return Ok(ParseOutcome::Help),
|
||||
Some("--source-catalog-db") => set_once(
|
||||
&mut source_catalog_db,
|
||||
next_path(&mut args, "--source-catalog-db")?,
|
||||
"--source-catalog-db",
|
||||
)?,
|
||||
Some("--output-dir") => set_once(
|
||||
&mut output_dir,
|
||||
next_path(&mut args, "--output-dir")?,
|
||||
"--output-dir",
|
||||
)?,
|
||||
Some("--unrar") => set_once(&mut unrar, next_path(&mut args, "--unrar")?, "--unrar")?,
|
||||
Some("--game-root") => game_roots.push(next_path(&mut args, "--game-root")?),
|
||||
Some("--no-stream-install") => {
|
||||
let game_id = next_utf8(&mut args, "--no-stream-install")?;
|
||||
if !no_streamed_install.insert(game_id.clone()) {
|
||||
eyre::bail!("duplicate --no-stream-install game ID: {game_id}");
|
||||
}
|
||||
}
|
||||
Some(other) => eyre::bail!("unknown argument: {other}"),
|
||||
None => eyre::bail!("argument is not valid UTF-8: {argument:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
let mut selected_ids = BTreeSet::new();
|
||||
let packages = game_roots
|
||||
.into_iter()
|
||||
.map(|package_root| {
|
||||
let game_id = package_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| eyre::eyre!("--game-root needs a UTF-8 final component"))?
|
||||
.to_owned();
|
||||
if !selected_ids.insert(game_id.clone()) {
|
||||
eyre::bail!("duplicate --game-root game ID: {game_id}");
|
||||
}
|
||||
Ok(FixturePackage {
|
||||
streamed_install: !no_streamed_install.contains(&game_id),
|
||||
game_id,
|
||||
package_root,
|
||||
})
|
||||
})
|
||||
.collect::<eyre::Result<Vec<_>>>()?;
|
||||
if packages.is_empty() {
|
||||
eyre::bail!("at least one --game-root is required");
|
||||
}
|
||||
if let Some(game_id) = no_streamed_install
|
||||
.iter()
|
||||
.find(|game_id| !selected_ids.contains(*game_id))
|
||||
{
|
||||
eyre::bail!("--no-stream-install selects missing game root: {game_id}");
|
||||
}
|
||||
|
||||
Ok(ParseOutcome::Options(FixtureCatalogOptions {
|
||||
source_catalog_db: source_catalog_db
|
||||
.ok_or_else(|| eyre::eyre!("--source-catalog-db is required"))?,
|
||||
output_dir: output_dir.ok_or_else(|| eyre::eyre!("--output-dir is required"))?,
|
||||
unrar: unrar.ok_or_else(|| eyre::eyre!("--unrar is required"))?,
|
||||
packages,
|
||||
}))
|
||||
}
|
||||
|
||||
fn next_path(args: &mut impl Iterator<Item = OsString>, option: &str) -> eyre::Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| eyre::eyre!("{option} requires a value"))
|
||||
}
|
||||
|
||||
fn next_utf8(args: &mut impl Iterator<Item = OsString>, option: &str) -> eyre::Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| eyre::eyre!("{option} requires a value"))?
|
||||
.into_string()
|
||||
.map_err(|value| eyre::eyre!("{option} value is not valid UTF-8: {value:?}"))
|
||||
}
|
||||
|
||||
fn set_once<T>(slot: &mut Option<T>, value: T, option: &str) -> eyre::Result<()> {
|
||||
if slot.replace(value).is_some() {
|
||||
eyre::bail!("{option} may be specified only once");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(arguments: &[&str]) -> eyre::Result<ParseOutcome> {
|
||||
parse_args(arguments.iter().map(OsString::from))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_packages_and_synthetic_streaming_opt_out() {
|
||||
let parsed = parse(&[
|
||||
"--source-catalog-db",
|
||||
"source.db",
|
||||
"--output-dir",
|
||||
"catalog",
|
||||
"--unrar",
|
||||
"unrar",
|
||||
"--game-root",
|
||||
"fixtures/alienswarm",
|
||||
"--game-root",
|
||||
"fixtures/bf1942",
|
||||
"--no-stream-install",
|
||||
"bf1942",
|
||||
])
|
||||
.expect("fixture options should parse");
|
||||
|
||||
let ParseOutcome::Options(options) = parsed else {
|
||||
panic!("expected parsed fixture options");
|
||||
};
|
||||
assert!(options.packages[0].streamed_install);
|
||||
assert!(!options.packages[1].streamed_install);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_implicit_or_mismatched_fixture_authority() {
|
||||
for arguments in [
|
||||
vec![
|
||||
"--source-catalog-db",
|
||||
"source.db",
|
||||
"--output-dir",
|
||||
"catalog",
|
||||
"--unrar",
|
||||
"unrar",
|
||||
],
|
||||
vec![
|
||||
"--source-catalog-db",
|
||||
"source.db",
|
||||
"--output-dir",
|
||||
"catalog",
|
||||
"--unrar",
|
||||
"unrar",
|
||||
"--game-root",
|
||||
"fixtures/g",
|
||||
"--no-stream-install",
|
||||
"other",
|
||||
],
|
||||
] {
|
||||
assert!(parse(&arguments).is_err(), "accepted {arguments:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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<BTreeMap<String, CatalogGame>> {
|
||||
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<T>(
|
||||
pool: &SqlitePool,
|
||||
query_result: Result<T, sqlx::Error>,
|
||||
) -> Result<T, sqlx::Error> {
|
||||
// `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<String, CatalogGame>,
|
||||
) -> 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()
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Minimal, dependency-free command-line surface for the catalog publisher.
|
||||
|
||||
use std::{collections::BTreeSet, ffi::OsString, path::PathBuf};
|
||||
|
||||
use super::{
|
||||
CatalogSelection,
|
||||
CheckOptions,
|
||||
GenerateOptions,
|
||||
check_catalog_manifests,
|
||||
default_manifests_dir,
|
||||
generate_catalog_manifests,
|
||||
};
|
||||
|
||||
pub const HELP: &str = "\
|
||||
Usage:
|
||||
lanspread-catalog-publisher generate --catalog-db PATH --packages-dir PATH --unrar PATH [--manifests-dir PATH] (--all | --game-id ID...)
|
||||
lanspread-catalog-publisher check --catalog-db PATH [--manifests-dir PATH] (--all | --game-id ID...)
|
||||
|
||||
The default manifests directory is manifests/ beside game.db.";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PublisherCommand {
|
||||
Generate(GenerateOptions),
|
||||
Check(CheckOptions),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ParseOutcome {
|
||||
Help,
|
||||
Command(PublisherCommand),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RawOptions {
|
||||
catalog_db: Option<PathBuf>,
|
||||
packages_dir: Option<PathBuf>,
|
||||
manifests_dir: Option<PathBuf>,
|
||||
unrar: Option<PathBuf>,
|
||||
all: bool,
|
||||
game_ids: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// Parses arguments after the executable name.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for an unknown, missing, repeated, or conflicting option.
|
||||
pub fn parse_args(args: impl IntoIterator<Item = OsString>) -> eyre::Result<ParseOutcome> {
|
||||
let mut args = args.into_iter();
|
||||
let Some(subcommand) = args.next() else {
|
||||
eyre::bail!("missing subcommand; use --help for usage");
|
||||
};
|
||||
if matches!(subcommand.to_str(), Some("--help" | "-h")) {
|
||||
return Ok(ParseOutcome::Help);
|
||||
}
|
||||
let subcommand = subcommand
|
||||
.into_string()
|
||||
.map_err(|value| eyre::eyre!("subcommand is not valid UTF-8: {value:?}"))?;
|
||||
if !matches!(subcommand.as_str(), "generate" | "check") {
|
||||
eyre::bail!("unknown subcommand: {subcommand}");
|
||||
}
|
||||
|
||||
let mut raw = RawOptions::default();
|
||||
while let Some(argument) = args.next() {
|
||||
match argument.to_str() {
|
||||
Some("--help" | "-h") => return Ok(ParseOutcome::Help),
|
||||
Some("--catalog-db") => {
|
||||
set_once(
|
||||
&mut raw.catalog_db,
|
||||
next_path(&mut args, "--catalog-db")?,
|
||||
"--catalog-db",
|
||||
)?;
|
||||
}
|
||||
Some("--packages-dir") => {
|
||||
set_once(
|
||||
&mut raw.packages_dir,
|
||||
next_path(&mut args, "--packages-dir")?,
|
||||
"--packages-dir",
|
||||
)?;
|
||||
}
|
||||
Some("--manifests-dir") => {
|
||||
set_once(
|
||||
&mut raw.manifests_dir,
|
||||
next_path(&mut args, "--manifests-dir")?,
|
||||
"--manifests-dir",
|
||||
)?;
|
||||
}
|
||||
Some("--unrar") => {
|
||||
set_once(&mut raw.unrar, next_path(&mut args, "--unrar")?, "--unrar")?;
|
||||
}
|
||||
Some("--all") => {
|
||||
if raw.all {
|
||||
eyre::bail!("--all may be specified only once");
|
||||
}
|
||||
raw.all = true;
|
||||
}
|
||||
Some("--game-id") => {
|
||||
let game_id = next_utf8(&mut args, "--game-id")?;
|
||||
if !raw.game_ids.insert(game_id.clone()) {
|
||||
eyre::bail!("duplicate --game-id selector: {game_id}");
|
||||
}
|
||||
}
|
||||
Some(other) => eyre::bail!("unknown argument: {other}"),
|
||||
None => eyre::bail!("argument is not valid UTF-8: {argument:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
let catalog_db = raw
|
||||
.catalog_db
|
||||
.ok_or_else(|| eyre::eyre!("--catalog-db is required"))?;
|
||||
let manifests_dir = raw
|
||||
.manifests_dir
|
||||
.unwrap_or_else(|| default_manifests_dir(&catalog_db));
|
||||
let selection = parse_selection(raw.all, raw.game_ids)?;
|
||||
|
||||
match subcommand.as_str() {
|
||||
"generate" => Ok(ParseOutcome::Command(PublisherCommand::Generate(
|
||||
GenerateOptions {
|
||||
catalog_db,
|
||||
packages_dir: raw
|
||||
.packages_dir
|
||||
.ok_or_else(|| eyre::eyre!("--packages-dir is required for generate"))?,
|
||||
manifests_dir,
|
||||
unrar: raw
|
||||
.unrar
|
||||
.ok_or_else(|| eyre::eyre!("--unrar is required for generate"))?,
|
||||
selection,
|
||||
},
|
||||
))),
|
||||
"check" => {
|
||||
if raw.packages_dir.is_some() {
|
||||
eyre::bail!("--packages-dir is not valid for check");
|
||||
}
|
||||
if raw.unrar.is_some() {
|
||||
eyre::bail!("--unrar is not valid for check");
|
||||
}
|
||||
Ok(ParseOutcome::Command(PublisherCommand::Check(
|
||||
CheckOptions {
|
||||
catalog_db,
|
||||
manifests_dir,
|
||||
selection,
|
||||
},
|
||||
)))
|
||||
}
|
||||
_ => unreachable!("subcommand was validated above"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one parsed command and returns deterministic stdout lines.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when generation or checking fails.
|
||||
pub async fn execute(command: &PublisherCommand) -> eyre::Result<Vec<String>> {
|
||||
let (verb, reports) = match command {
|
||||
PublisherCommand::Generate(options) => {
|
||||
("generated", generate_catalog_manifests(options).await?)
|
||||
}
|
||||
PublisherCommand::Check(options) => ("checked", check_catalog_manifests(options).await?),
|
||||
};
|
||||
let mut lines = reports
|
||||
.into_iter()
|
||||
.map(|report| {
|
||||
format!(
|
||||
"{verb} game_id={} game_version={} content_id={}",
|
||||
report.game_id, report.game_version, report.content_id
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
lines.push(format!("{verb} total={}", lines.len()));
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn parse_selection(all: bool, game_ids: BTreeSet<String>) -> eyre::Result<CatalogSelection> {
|
||||
match (all, game_ids.is_empty()) {
|
||||
(true, true) => Ok(CatalogSelection::All),
|
||||
(false, false) => Ok(CatalogSelection::GameIds(game_ids)),
|
||||
(true, false) => eyre::bail!("--all cannot be combined with --game-id"),
|
||||
(false, true) => eyre::bail!("select games with --all or at least one --game-id"),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_path(args: &mut impl Iterator<Item = OsString>, option: &str) -> eyre::Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| eyre::eyre!("{option} requires a value"))
|
||||
}
|
||||
|
||||
fn next_utf8(args: &mut impl Iterator<Item = OsString>, option: &str) -> eyre::Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| eyre::eyre!("{option} requires a value"))?
|
||||
.into_string()
|
||||
.map_err(|value| eyre::eyre!("{option} value is not valid UTF-8: {value:?}"))
|
||||
}
|
||||
|
||||
fn set_once<T>(slot: &mut Option<T>, value: T, option: &str) -> eyre::Result<()> {
|
||||
if slot.replace(value).is_some() {
|
||||
eyre::bail!("{option} may be specified only once");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(arguments: &[&str]) -> eyre::Result<ParseOutcome> {
|
||||
parse_args(arguments.iter().map(OsString::from))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_defaults_to_sibling_manifests_and_sorts_selectors() {
|
||||
let outcome = parse(&[
|
||||
"generate",
|
||||
"--catalog-db",
|
||||
"/catalog/game.db",
|
||||
"--packages-dir",
|
||||
"/packages",
|
||||
"--unrar",
|
||||
"/tools/unrar",
|
||||
"--game-id",
|
||||
"zeta",
|
||||
"--game-id",
|
||||
"alpha",
|
||||
])
|
||||
.expect("arguments should parse");
|
||||
let ParseOutcome::Command(PublisherCommand::Generate(options)) = outcome else {
|
||||
panic!("expected generate command");
|
||||
};
|
||||
assert_eq!(options.manifests_dir, PathBuf::from("/catalog/manifests"));
|
||||
assert_eq!(
|
||||
options.selection,
|
||||
CatalogSelection::GameIds(BTreeSet::from(["alpha".to_owned(), "zeta".to_owned()]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selectors_are_explicit_and_mutually_exclusive() {
|
||||
for arguments in [
|
||||
vec!["check", "--catalog-db", "game.db"],
|
||||
vec![
|
||||
"check",
|
||||
"--catalog-db",
|
||||
"game.db",
|
||||
"--all",
|
||||
"--game-id",
|
||||
"g",
|
||||
],
|
||||
vec![
|
||||
"check",
|
||||
"--catalog-db",
|
||||
"game.db",
|
||||
"--game-id",
|
||||
"g",
|
||||
"--game-id",
|
||||
"g",
|
||||
],
|
||||
] {
|
||||
assert!(parse(&arguments).is_err(), "accepted {arguments:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_rejects_generation_only_inputs() {
|
||||
assert!(
|
||||
parse(&[
|
||||
"check",
|
||||
"--catalog-db",
|
||||
"game.db",
|
||||
"--packages-dir",
|
||||
"packages",
|
||||
"--all",
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
//! Test-only catalog authority generation for peer-CLI fixtures.
|
||||
//!
|
||||
//! This module deliberately lives beside the production publisher, but is not
|
||||
//! used by either application runtime. It lets acceptance tests derive an
|
||||
//! isolated catalog from explicitly selected fixture packages without
|
||||
//! reimplementing manifest hashing in the scenario harness.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
use lanspread_db::content_manifest::{
|
||||
CATALOG_CONTENT_INDEX_NAME,
|
||||
CatalogContentIndex,
|
||||
write_canonical_content_index_atomic,
|
||||
write_canonical_manifest_atomic,
|
||||
};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
use super::{
|
||||
CatalogGame,
|
||||
CatalogSelection,
|
||||
CheckOptions,
|
||||
ManifestReport,
|
||||
catalog::{load_catalog_games, validate_regular_directory},
|
||||
check_catalog_manifests,
|
||||
package::{StreamedInstallPolicy, build_manifest_from_package_with_policy},
|
||||
};
|
||||
use crate::catalog_bundle::load_catalog_bundle;
|
||||
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// One canonical package selected for a generated acceptance-test catalog.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct FixturePackage {
|
||||
pub game_id: String,
|
||||
pub package_root: PathBuf,
|
||||
pub streamed_install: bool,
|
||||
}
|
||||
|
||||
/// Inputs for generating a complete, reduced acceptance-test catalog.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct FixtureCatalogOptions {
|
||||
pub source_catalog_db: PathBuf,
|
||||
pub output_dir: PathBuf,
|
||||
pub unrar: PathBuf,
|
||||
pub packages: Vec<FixturePackage>,
|
||||
}
|
||||
|
||||
/// Builds an isolated `game.db` plus sibling `manifests/` from fixture bytes.
|
||||
///
|
||||
/// The output database retains the selected production catalog rows and their
|
||||
/// exact genre rows. Manifests are generated twice from each package before a
|
||||
/// fully checked staging directory replaces the requested output. Setting
|
||||
/// `FixturePackage::streamed_install` to false is reserved for synthetic
|
||||
/// transfer fixtures whose `.eti` files intentionally are not RAR archives;
|
||||
/// such manifests never authorize Stream Install.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for an unknown or duplicate game, unsafe package/output
|
||||
/// shape, database failure, non-reproducible package, or invalid manifest.
|
||||
pub async fn generate_fixture_catalog(
|
||||
options: &FixtureCatalogOptions,
|
||||
) -> eyre::Result<Vec<ManifestReport>> {
|
||||
let source_catalog = load_catalog_games(&options.source_catalog_db).await?;
|
||||
let packages = validate_packages(&source_catalog, &options.packages)?;
|
||||
let staging = StagingDirectory::create(&options.output_dir)?;
|
||||
let staged_db = staging.path().join("game.db");
|
||||
let staged_manifests = staging.path().join("manifests");
|
||||
|
||||
copy_filtered_catalog(
|
||||
&options.source_catalog_db,
|
||||
&staged_db,
|
||||
packages.keys().map(String::as_str),
|
||||
)
|
||||
.await?;
|
||||
fs::create_dir(&staged_manifests).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to create fixture manifest directory {}",
|
||||
staged_manifests.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let staged_catalog = load_catalog_games(&staged_db).await?;
|
||||
let mut generated_manifests = Vec::with_capacity(packages.len());
|
||||
for (game_id, package) in &packages {
|
||||
let game = staged_catalog
|
||||
.get(game_id)
|
||||
.ok_or_else(|| eyre::eyre!("filtered catalog lost selected game ID: {game_id}"))?;
|
||||
let policy = if package.streamed_install {
|
||||
StreamedInstallPolicy::Required
|
||||
} else {
|
||||
StreamedInstallPolicy::DisabledForSyntheticFixture
|
||||
};
|
||||
let first = build_manifest_from_package_with_policy(
|
||||
game,
|
||||
&package.package_root,
|
||||
&options.unrar,
|
||||
policy,
|
||||
)
|
||||
.wrap_err_with(|| format!("failed to generate fixture manifest for {game_id}"))?;
|
||||
let second = build_manifest_from_package_with_policy(
|
||||
game,
|
||||
&package.package_root,
|
||||
&options.unrar,
|
||||
policy,
|
||||
)
|
||||
.wrap_err_with(|| format!("failed to verify fixture package for {game_id}"))?;
|
||||
if first != second {
|
||||
eyre::bail!("fixture package changed between hashing passes: {game_id}");
|
||||
}
|
||||
write_canonical_manifest_atomic(&staged_manifests.join(format!("{game_id}.json")), &first)?;
|
||||
generated_manifests.push(first);
|
||||
}
|
||||
let content_index = CatalogContentIndex::from_manifests(&generated_manifests)?;
|
||||
write_canonical_content_index_atomic(
|
||||
&staged_manifests.join(CATALOG_CONTENT_INDEX_NAME),
|
||||
&content_index,
|
||||
)?;
|
||||
|
||||
let reports = check_catalog_manifests(&CheckOptions {
|
||||
catalog_db: staged_db.clone(),
|
||||
manifests_dir: staged_manifests.clone(),
|
||||
selection: CatalogSelection::All,
|
||||
})
|
||||
.await?;
|
||||
load_catalog_bundle(&staged_db, &staged_manifests)
|
||||
.await
|
||||
.wrap_err("generated fixture catalog failed application loader validation")?;
|
||||
|
||||
staging.install(&options.output_dir)?;
|
||||
Ok(reports)
|
||||
}
|
||||
|
||||
fn validate_packages<'a>(
|
||||
catalog: &BTreeMap<String, CatalogGame>,
|
||||
packages: &'a [FixturePackage],
|
||||
) -> eyre::Result<BTreeMap<String, &'a FixturePackage>> {
|
||||
if packages.is_empty() {
|
||||
eyre::bail!("fixture catalog requires at least one package");
|
||||
}
|
||||
let mut selected = BTreeMap::new();
|
||||
let mut portable_ids = BTreeSet::new();
|
||||
for package in packages {
|
||||
let game = catalog
|
||||
.get(&package.game_id)
|
||||
.ok_or_else(|| eyre::eyre!("unknown fixture game ID: {}", package.game_id))?;
|
||||
if game.game_id != package.game_id {
|
||||
eyre::bail!("fixture game ID does not exactly match game.db");
|
||||
}
|
||||
if package
|
||||
.package_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
!= Some(package.game_id.as_str())
|
||||
{
|
||||
eyre::bail!(
|
||||
"fixture package root must be named exactly {}: {}",
|
||||
package.game_id,
|
||||
package.package_root.display()
|
||||
);
|
||||
}
|
||||
let portable = package.game_id.to_uppercase();
|
||||
if !portable_ids.insert(portable)
|
||||
|| selected.insert(package.game_id.clone(), package).is_some()
|
||||
{
|
||||
eyre::bail!(
|
||||
"duplicate or platform-alias fixture game ID: {}",
|
||||
package.game_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
async fn copy_filtered_catalog<'a>(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
selected_ids: impl Iterator<Item = &'a str>,
|
||||
) -> eyre::Result<()> {
|
||||
fs::copy(source, destination).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to copy source catalog {} to {}",
|
||||
source.display(),
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
let selected_ids = selected_ids.map(str::to_owned).collect::<BTreeSet<_>>();
|
||||
let all_games = load_catalog_games(destination).await?;
|
||||
let options = SqliteConnectOptions::new().filename(destination);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to open fixture catalog {}", destination.display()))?;
|
||||
let mutation_result = async {
|
||||
let mut transaction = pool.begin().await?;
|
||||
for game_id in all_games.keys() {
|
||||
if !selected_ids.contains(game_id) {
|
||||
sqlx::query("DELETE FROM games WHERE game_id = ?")
|
||||
.bind(game_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
sqlx::query(
|
||||
"DELETE FROM genre
|
||||
WHERE genre_id NOT IN (SELECT DISTINCT genre_id FROM games)",
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
sqlx::query("VACUUM").execute(&pool).await?;
|
||||
Ok::<(), sqlx::Error>(())
|
||||
}
|
||||
.await;
|
||||
pool.close().await;
|
||||
mutation_result.wrap_err("failed to filter fixture catalog database")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct StagingDirectory {
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl StagingDirectory {
|
||||
fn create(output: &Path) -> eyre::Result<Self> {
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
fs::create_dir_all(parent)?;
|
||||
validate_regular_directory(parent)?;
|
||||
if let Ok(metadata) = fs::symlink_metadata(output)
|
||||
&& (!metadata.is_dir() || is_link_or_reparse(&metadata))
|
||||
{
|
||||
eyre::bail!(
|
||||
"fixture catalog output is not a regular non-link directory: {}",
|
||||
output.display()
|
||||
);
|
||||
}
|
||||
let stem = output
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| eyre::eyre!("fixture catalog output needs a UTF-8 directory name"))?;
|
||||
for _ in 0..100 {
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let candidate = parent.join(format!(".{stem}.tmp-{}-{sequence}", std::process::id()));
|
||||
match fs::create_dir(&candidate) {
|
||||
Ok(()) => {
|
||||
return Ok(Self {
|
||||
path: Some(candidate),
|
||||
});
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
eyre::bail!("could not allocate a fixture catalog staging directory")
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
match self.path.as_deref() {
|
||||
Some(path) => path,
|
||||
None => panic!("staging directory is present until installation"),
|
||||
}
|
||||
}
|
||||
|
||||
fn install(mut self, output: &Path) -> eyre::Result<()> {
|
||||
let staging = self.path().to_path_buf();
|
||||
if !output.exists() {
|
||||
fs::rename(&staging, output)?;
|
||||
self.path = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
validate_regular_directory(output)?;
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let stem = output
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| eyre::eyre!("fixture catalog output needs a UTF-8 directory name"))?;
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let backup = parent.join(format!(".{stem}.old-{}-{sequence}", std::process::id()));
|
||||
fs::rename(output, &backup)?;
|
||||
if let Err(error) = fs::rename(&staging, output) {
|
||||
let restore = fs::rename(&backup, output);
|
||||
return match restore {
|
||||
Ok(()) => Err(error.into()),
|
||||
Err(restore_error) => Err(eyre::eyre!(
|
||||
"failed to install fixture catalog: {error}; failed to restore previous output: {restore_error}"
|
||||
)),
|
||||
};
|
||||
}
|
||||
self.path = None;
|
||||
fs::remove_dir_all(&backup).wrap_err_with(|| {
|
||||
format!(
|
||||
"installed fixture catalog but failed to remove backup {}",
|
||||
backup.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StagingDirectory {
|
||||
fn drop(&mut self) {
|
||||
if let Some(path) = self.path.take() {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
path::Path,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should follow epoch")
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"lanspread-fixture-catalog-{}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&path).expect("test directory should be created");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_source_catalog(path: &Path) {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.expect("source catalog should open");
|
||||
sqlx::query(
|
||||
"CREATE TABLE games (
|
||||
game_id TEXT, db_id INTEGER PRIMARY KEY, game_title TEXT,
|
||||
game_key TEXT, game_release TEXT, game_publisher TEXT,
|
||||
game_size NUMERIC, game_readme_de TEXT, game_readme_en TEXT,
|
||||
game_readme_fr TEXT, game_maxplayers INTEGER,
|
||||
game_master_req INTEGER, genre_id INTEGER, game_version TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("games table should be created");
|
||||
sqlx::query(
|
||||
"CREATE TABLE genre (
|
||||
genre_id INTEGER PRIMARY KEY, genre_de TEXT,
|
||||
genre_en TEXT, genre_fr TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("genre table should be created");
|
||||
sqlx::query(
|
||||
"INSERT INTO genre VALUES
|
||||
(1, 'Strategy', 'Strategy', 'Strategy'),
|
||||
(2, 'Shooter', 'Shooter', 'Shooter')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("genres should be inserted");
|
||||
sqlx::query(
|
||||
"INSERT INTO games VALUES
|
||||
('keep', 1, 'Keep', '', '', '', 1, '', '', '', 1, 0, 1, '20250101'),
|
||||
('drop', 2, 'Drop', '', '', '', 1, '', '', '', 1, 0, 2, '20250102')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("games should be inserted");
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generates_exact_reduced_catalog_and_replaces_it_coherently() {
|
||||
let root = TestDirectory::new();
|
||||
let source_db = root.path().join("source.db");
|
||||
create_source_catalog(&source_db).await;
|
||||
let package_root = root.path().join("packages/keep");
|
||||
fs::create_dir_all(&package_root).expect("package root should be created");
|
||||
fs::write(package_root.join("version.ini"), "20250101")
|
||||
.expect("fixture version should be written");
|
||||
fs::write(package_root.join("keep.eti"), b"not a RAR: first")
|
||||
.expect("fixture payload should be written");
|
||||
let output_dir = root.path().join("output");
|
||||
let options = FixtureCatalogOptions {
|
||||
source_catalog_db: source_db,
|
||||
output_dir: output_dir.clone(),
|
||||
unrar: root.path().join("unused-unrar"),
|
||||
packages: vec![FixturePackage {
|
||||
game_id: "keep".to_owned(),
|
||||
package_root: package_root.clone(),
|
||||
streamed_install: false,
|
||||
}],
|
||||
};
|
||||
|
||||
let first = generate_fixture_catalog(&options)
|
||||
.await
|
||||
.expect("first fixture catalog should generate");
|
||||
assert_eq!(first.len(), 1);
|
||||
let games = load_catalog_games(&output_dir.join("game.db"))
|
||||
.await
|
||||
.expect("generated catalog should load");
|
||||
assert_eq!(
|
||||
games.keys().map(String::as_str).collect::<Vec<_>>(),
|
||||
vec!["keep"]
|
||||
);
|
||||
let manifest_bytes = fs::read(output_dir.join("manifests/keep.json"))
|
||||
.expect("fixture manifest should be readable");
|
||||
let manifest = lanspread_db::content_manifest::CatalogContentManifest::from_json_slice(
|
||||
&manifest_bytes,
|
||||
)
|
||||
.expect("fixture manifest should parse");
|
||||
assert!(!manifest.supports_streamed_install());
|
||||
let first_index_bytes = fs::read(
|
||||
output_dir
|
||||
.join("manifests")
|
||||
.join(CATALOG_CONTENT_INDEX_NAME),
|
||||
)
|
||||
.expect("fixture content index should be readable");
|
||||
let first_index = CatalogContentIndex::from_json_slice(&first_index_bytes)
|
||||
.expect("fixture content index should parse");
|
||||
assert_eq!(
|
||||
first_index
|
||||
.content_identity("keep")
|
||||
.expect("fixture identity should be indexed")
|
||||
.content_id,
|
||||
manifest.content_id()
|
||||
);
|
||||
|
||||
fs::write(package_root.join("keep.eti"), b"not a RAR: second")
|
||||
.expect("fixture payload should change");
|
||||
let second = generate_fixture_catalog(&options)
|
||||
.await
|
||||
.expect("replacement fixture catalog should generate");
|
||||
assert_ne!(first[0].content_id, second[0].content_id);
|
||||
let second_index_bytes = fs::read(
|
||||
output_dir
|
||||
.join("manifests")
|
||||
.join(CATALOG_CONTENT_INDEX_NAME),
|
||||
)
|
||||
.expect("replacement content index should be readable");
|
||||
assert_ne!(first_index_bytes, second_index_bytes);
|
||||
check_catalog_manifests(&CheckOptions {
|
||||
catalog_db: output_dir.join("game.db"),
|
||||
manifests_dir: output_dir.join("manifests"),
|
||||
selection: CatalogSelection::All,
|
||||
})
|
||||
.await
|
||||
.expect("installed replacement profile should be coherent");
|
||||
}
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
//! Reproducible catalog-manifest publishing and checking.
|
||||
//!
|
||||
//! Production generation is deliberately a separate workflow from the peer
|
||||
//! runtime: only the canonical package corpus and `game.db` may establish the
|
||||
//! expected content hashes.
|
||||
|
||||
mod catalog;
|
||||
pub mod cli;
|
||||
pub mod fixture;
|
||||
mod package;
|
||||
mod unrar;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::fs::File;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
pub use catalog::{CatalogGame, load_catalog_games};
|
||||
use eyre::WrapErr;
|
||||
use lanspread_db::content_manifest::{
|
||||
CATALOG_CONTENT_INDEX_NAME,
|
||||
CATALOG_PUBLICATION_MARKER_NAME,
|
||||
CatalogContentIdentity,
|
||||
CatalogContentIndex,
|
||||
CatalogContentIndexEntry,
|
||||
CatalogContentManifest,
|
||||
CatalogManifestStore,
|
||||
reject_incomplete_catalog_publication,
|
||||
write_canonical_content_index_atomic,
|
||||
write_canonical_manifest_atomic,
|
||||
};
|
||||
pub use package::{build_manifest_from_package, verify_manifest_against_package};
|
||||
|
||||
/// The explicit set of catalog rows operated on by the publisher.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum CatalogSelection {
|
||||
/// Operate on every row in `game.db`.
|
||||
All,
|
||||
/// Operate on these exact game IDs.
|
||||
GameIds(BTreeSet<String>),
|
||||
}
|
||||
|
||||
impl CatalogSelection {
|
||||
fn select<'a>(
|
||||
&self,
|
||||
catalog: &'a BTreeMap<String, CatalogGame>,
|
||||
) -> eyre::Result<Vec<&'a CatalogGame>> {
|
||||
match self {
|
||||
Self::All => Ok(catalog.values().collect()),
|
||||
Self::GameIds(game_ids) => {
|
||||
if game_ids.is_empty() {
|
||||
eyre::bail!("catalog selection cannot be empty");
|
||||
}
|
||||
game_ids
|
||||
.iter()
|
||||
.map(|game_id| {
|
||||
catalog
|
||||
.get(game_id)
|
||||
.ok_or_else(|| eyre::eyre!("unknown catalog game ID: {game_id}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs for reproducibly generating one or more catalog manifests.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct GenerateOptions {
|
||||
pub catalog_db: PathBuf,
|
||||
pub packages_dir: PathBuf,
|
||||
pub manifests_dir: PathBuf,
|
||||
pub unrar: PathBuf,
|
||||
pub selection: CatalogSelection,
|
||||
}
|
||||
|
||||
/// Inputs for checking already-published manifest artifacts without packages.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct CheckOptions {
|
||||
pub catalog_db: PathBuf,
|
||||
pub manifests_dir: PathBuf,
|
||||
pub selection: CatalogSelection,
|
||||
}
|
||||
|
||||
/// Stable information reported after generating or checking one artifact.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManifestReport {
|
||||
pub game_id: String,
|
||||
pub game_version: String,
|
||||
pub content_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedManifest {
|
||||
game_id: String,
|
||||
artifact: PathBuf,
|
||||
manifest: CatalogContentManifest,
|
||||
}
|
||||
|
||||
/// Generates, independently rereads, and publishes selected manifests in
|
||||
/// game-ID order.
|
||||
///
|
||||
/// The second build is intentional: every selected package and archive output
|
||||
/// must reproduce its exact first-pass manifest before any artifact becomes
|
||||
/// visible. Publication is guarded by a durable marker, so an interrupted
|
||||
/// multi-artifact update makes subsequent checks fail closed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when catalog loading, package validation, independent
|
||||
/// verification, atomic publication, or published-artifact rereading fails.
|
||||
pub async fn generate_catalog_manifests(
|
||||
options: &GenerateOptions,
|
||||
) -> eyre::Result<Vec<ManifestReport>> {
|
||||
let catalog = load_catalog_games(&options.catalog_db).await?;
|
||||
let versions = catalog_versions(&catalog);
|
||||
reject_incomplete_publication(&options.manifests_dir)?;
|
||||
let selected = options.selection.select(&catalog)?;
|
||||
let prepared = selected
|
||||
.into_iter()
|
||||
.map(|game| prepare_manifest(game, options))
|
||||
.collect::<eyre::Result<Vec<_>>>()?;
|
||||
|
||||
// This is only an early usability check. It is deliberately discarded:
|
||||
// the authoritative incremental snapshot is loaded again after this
|
||||
// publisher owns the durable marker.
|
||||
if matches!(&options.selection, CatalogSelection::GameIds(_)) {
|
||||
let preflight = CatalogManifestStore::new(&options.manifests_dir, versions.clone())
|
||||
.wrap_err("incremental generation requires a complete indexed catalog corpus")?;
|
||||
preflight
|
||||
.validate_coverage()
|
||||
.wrap_err("incremental generation requires complete manifest coverage")?;
|
||||
}
|
||||
|
||||
publish_prepared_catalog_manifests(options, &catalog, versions, &prepared)
|
||||
}
|
||||
|
||||
fn publish_prepared_catalog_manifests(
|
||||
options: &GenerateOptions,
|
||||
catalog: &BTreeMap<String, CatalogGame>,
|
||||
versions: BTreeMap<String, String>,
|
||||
prepared: &[PreparedManifest],
|
||||
) -> eyre::Result<Vec<ManifestReport>> {
|
||||
fs::create_dir_all(&options.manifests_dir).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to create manifest directory {}",
|
||||
options.manifests_dir.display()
|
||||
)
|
||||
})?;
|
||||
catalog::validate_regular_directory(&options.manifests_dir)?;
|
||||
|
||||
// `create_new` on the marker is the writer-exclusion boundary. Every
|
||||
// source used for a mixed incremental index is acquired after this point.
|
||||
let marker = begin_publication(&options.manifests_dir, prepared)?;
|
||||
catalog::reject_unexpected_manifest_artifacts(&options.manifests_dir, catalog)?;
|
||||
|
||||
for prepared_manifest in prepared {
|
||||
package::validate_manifest_destination(&prepared_manifest.artifact)?;
|
||||
}
|
||||
let index_artifact = options.manifests_dir.join(CATALOG_CONTENT_INDEX_NAME);
|
||||
package::validate_manifest_destination(&index_artifact)?;
|
||||
|
||||
let unselected_identities = if matches!(&options.selection, CatalogSelection::All) {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
let store = CatalogManifestStore::new(&options.manifests_dir, versions.clone())
|
||||
.wrap_err("incremental generation requires a complete indexed catalog corpus")?;
|
||||
store
|
||||
.validate_coverage()
|
||||
.wrap_err("incremental generation requires complete manifest coverage")?;
|
||||
validated_unselected_identities(catalog, prepared, &store)?
|
||||
};
|
||||
let content_index = proposed_content_index(catalog, prepared, &unselected_identities)?;
|
||||
|
||||
for prepared_manifest in prepared {
|
||||
let game_id = &prepared_manifest.game_id;
|
||||
write_canonical_manifest_atomic(&prepared_manifest.artifact, &prepared_manifest.manifest)
|
||||
.wrap_err_with(|| format!("failed to publish manifest for {game_id}"))?;
|
||||
}
|
||||
write_canonical_content_index_atomic(&index_artifact, &content_index)
|
||||
.wrap_err("failed to publish catalog content index")?;
|
||||
|
||||
let store = CatalogManifestStore::new(&options.manifests_dir, versions)?;
|
||||
if matches!(&options.selection, CatalogSelection::All) {
|
||||
store.validate_all()?;
|
||||
} else {
|
||||
store.validate_coverage()?;
|
||||
}
|
||||
|
||||
let mut reports = Vec::with_capacity(prepared.len());
|
||||
for prepared_manifest in prepared {
|
||||
let published = store.load(&prepared_manifest.game_id).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to reread manifest for {}",
|
||||
prepared_manifest.game_id
|
||||
)
|
||||
})?;
|
||||
if published.as_ref() != &prepared_manifest.manifest {
|
||||
eyre::bail!(
|
||||
"published manifest differs from verified manifest for {}",
|
||||
prepared_manifest.game_id
|
||||
);
|
||||
}
|
||||
reports.push(report(&prepared_manifest.manifest));
|
||||
}
|
||||
|
||||
finish_publication(&marker)?;
|
||||
Ok(reports)
|
||||
}
|
||||
|
||||
/// Checks canonical JSON, content IDs, versions, filenames, and selected
|
||||
/// `game.db` coverage without requiring the production package corpus.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the catalog or any required artifact is invalid.
|
||||
pub async fn check_catalog_manifests(options: &CheckOptions) -> eyre::Result<Vec<ManifestReport>> {
|
||||
let catalog = load_catalog_games(&options.catalog_db).await?;
|
||||
catalog::validate_regular_directory(&options.manifests_dir)?;
|
||||
reject_incomplete_publication(&options.manifests_dir)?;
|
||||
let store = CatalogManifestStore::new(&options.manifests_dir, catalog_versions(&catalog))?;
|
||||
let selected = options.selection.select(&catalog)?;
|
||||
|
||||
let result = (|| {
|
||||
if matches!(&options.selection, CatalogSelection::All) {
|
||||
store.validate_all()?;
|
||||
} else {
|
||||
store.validate_coverage()?;
|
||||
}
|
||||
|
||||
selected
|
||||
.into_iter()
|
||||
.map(|game| {
|
||||
let manifest = store
|
||||
.load(&game.game_id)
|
||||
.wrap_err_with(|| format!("failed to check manifest for {}", game.game_id))?;
|
||||
Ok(report(&manifest))
|
||||
})
|
||||
.collect()
|
||||
})();
|
||||
reject_incomplete_publication(&options.manifests_dir)?;
|
||||
result
|
||||
}
|
||||
|
||||
fn proposed_content_index(
|
||||
catalog: &BTreeMap<String, CatalogGame>,
|
||||
prepared: &[PreparedManifest],
|
||||
validated_unselected: &BTreeMap<String, CatalogContentIdentity>,
|
||||
) -> eyre::Result<CatalogContentIndex> {
|
||||
let prepared = prepared
|
||||
.iter()
|
||||
.map(|prepared| (prepared.game_id.as_str(), &prepared.manifest))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut entries = Vec::with_capacity(catalog.len());
|
||||
for game in catalog.values() {
|
||||
let identity = if let Some(manifest) = prepared.get(game.game_id.as_str()) {
|
||||
CatalogContentIdentity::from_manifest(manifest)
|
||||
} else {
|
||||
validated_unselected
|
||||
.get(&game.game_id)
|
||||
.copied()
|
||||
.ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"complete catalog generation did not prepare game {}",
|
||||
game.game_id
|
||||
)
|
||||
})?
|
||||
};
|
||||
entries.push(CatalogContentIndexEntry {
|
||||
game_id: game.game_id.clone(),
|
||||
game_version: game.game_version.clone(),
|
||||
identity,
|
||||
});
|
||||
}
|
||||
CatalogContentIndex::from_entries(entries)
|
||||
}
|
||||
|
||||
fn validated_unselected_identities(
|
||||
catalog: &BTreeMap<String, CatalogGame>,
|
||||
prepared: &[PreparedManifest],
|
||||
store: &CatalogManifestStore,
|
||||
) -> eyre::Result<BTreeMap<String, CatalogContentIdentity>> {
|
||||
let selected = prepared
|
||||
.iter()
|
||||
.map(|prepared| prepared.game_id.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut identities = BTreeMap::new();
|
||||
for game_id in catalog
|
||||
.keys()
|
||||
.filter(|game_id| !selected.contains(game_id.as_str()))
|
||||
{
|
||||
let manifest = store
|
||||
.load(game_id)
|
||||
.wrap_err_with(|| format!("failed to validate unselected manifest for {game_id}"))?;
|
||||
identities.insert(
|
||||
game_id.clone(),
|
||||
CatalogContentIdentity::from_manifest(&manifest),
|
||||
);
|
||||
}
|
||||
Ok(identities)
|
||||
}
|
||||
|
||||
fn prepare_manifest(
|
||||
game: &CatalogGame,
|
||||
options: &GenerateOptions,
|
||||
) -> eyre::Result<PreparedManifest> {
|
||||
let package_root = options.packages_dir.join(&game.game_id);
|
||||
let manifest = build_manifest_from_package(game, &package_root, &options.unrar)
|
||||
.wrap_err_with(|| format!("failed to generate manifest for {}", game.game_id))?;
|
||||
verify_manifest_against_package(&manifest, &package_root, &options.unrar)
|
||||
.wrap_err_with(|| format!("independent verification failed for {}", game.game_id))?;
|
||||
Ok(PreparedManifest {
|
||||
game_id: game.game_id.clone(),
|
||||
artifact: options.manifests_dir.join(format!("{}.json", game.game_id)),
|
||||
manifest,
|
||||
})
|
||||
}
|
||||
|
||||
fn reject_incomplete_publication(root: &Path) -> eyre::Result<()> {
|
||||
reject_incomplete_catalog_publication(root)
|
||||
}
|
||||
|
||||
fn begin_publication(root: &Path, prepared: &[PreparedManifest]) -> eyre::Result<PathBuf> {
|
||||
reject_incomplete_publication(root)?;
|
||||
let marker = root.join(CATALOG_PUBLICATION_MARKER_NAME);
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&marker)
|
||||
.wrap_err_with(|| format!("failed to create publication marker {}", marker.display()))?;
|
||||
file.write_all(b"lanspread catalog publication v1\n")?;
|
||||
for prepared_manifest in prepared {
|
||||
writeln!(
|
||||
file,
|
||||
"{} {}",
|
||||
prepared_manifest.game_id,
|
||||
prepared_manifest.manifest.content_id()
|
||||
)?;
|
||||
}
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
sync_directory(root)?;
|
||||
Ok(marker)
|
||||
}
|
||||
|
||||
fn finish_publication(marker: &Path) -> eyre::Result<()> {
|
||||
fs::remove_file(marker)
|
||||
.wrap_err_with(|| format!("failed to remove publication marker {}", marker.display()))?;
|
||||
sync_directory(marker.parent().unwrap_or_else(|| Path::new(".")))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_directory(path: &Path) -> eyre::Result<()> {
|
||||
File::open(path)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_directory(_path: &Path) -> eyre::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn catalog_versions(catalog: &BTreeMap<String, CatalogGame>) -> BTreeMap<String, String> {
|
||||
catalog
|
||||
.iter()
|
||||
.map(|(game_id, game)| (game_id.clone(), game.game_version.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn report(manifest: &CatalogContentManifest) -> ManifestReport {
|
||||
ManifestReport {
|
||||
game_id: manifest.game_id().to_owned(),
|
||||
game_version: manifest.game_version().to_owned(),
|
||||
content_id: manifest.content_id().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default manifest directory beside `game.db`.
|
||||
#[must_use]
|
||||
pub fn default_manifests_dir(catalog_db: &Path) -> PathBuf {
|
||||
catalog_db
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join("manifests")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_db::content_manifest::Blake3Digest;
|
||||
|
||||
use super::*;
|
||||
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
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);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"lanspread-catalog-publisher-{}-{nanos}-{sequence}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&path).expect("temporary directory should be created");
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_path(relative: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../lanspread-peer-cli/fixtures")
|
||||
.join(relative)
|
||||
}
|
||||
|
||||
fn test_unrar() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let bundled = Path::new(env!("CARGO_MANIFEST_DIR")).join(
|
||||
"../lanspread-tauri-deno-ts/src-tauri/binaries/unrar-x86_64-unknown-linux-gnu",
|
||||
);
|
||||
if bundled.is_file() {
|
||||
return Some(bundled);
|
||||
}
|
||||
}
|
||||
["/usr/local/bin/unrar", "/usr/bin/unrar"]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.find(|path| path.is_file())
|
||||
}
|
||||
|
||||
fn simple_package(temp: &TempDir, game_id: &str, version: &str) -> PathBuf {
|
||||
let root = temp.0.join("packages").join(game_id);
|
||||
fs::create_dir_all(&root).expect("game root should be created");
|
||||
fs::write(root.join("version.ini"), version).expect("version should be written");
|
||||
fs::write(root.join("payload.bin"), b"payload").expect("payload should be written");
|
||||
root
|
||||
}
|
||||
|
||||
async fn create_catalog(path: &Path, games: &[(&str, &str)]) {
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.expect("temporary catalog 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("games table should be created");
|
||||
for (db_id, (game_id, game_version)) in games.iter().enumerate() {
|
||||
sqlx::query("INSERT INTO games (game_id, game_version, db_id) VALUES (?, ?, ?)")
|
||||
.bind(*game_id)
|
||||
.bind(*game_version)
|
||||
.bind(i64::try_from(db_id).expect("test catalog row count should fit i64"))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("catalog row should be inserted");
|
||||
}
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_rar_fixture_builds_and_reproduces_extracted_hashes() {
|
||||
let Some(unrar) = test_unrar() else {
|
||||
return;
|
||||
};
|
||||
let game = CatalogGame {
|
||||
game_id: "css".to_owned(),
|
||||
game_version: "20240623".to_owned(),
|
||||
};
|
||||
let root = fixture_path("fixture-persona/css");
|
||||
let manifest = build_manifest_from_package(&game, &root, &unrar)
|
||||
.expect("real RAR fixture should publish");
|
||||
|
||||
assert_eq!(manifest.files().len(), 2);
|
||||
assert_eq!(manifest.streamed_install_files().len(), 10);
|
||||
let readme = manifest
|
||||
.streamed_install_entry("readme.txt")
|
||||
.expect("readme should be described");
|
||||
assert_eq!(readme.size(), 17);
|
||||
assert_eq!(
|
||||
readme.file_blake3(),
|
||||
Some(Blake3Digest::hash(b"css game payload\n"))
|
||||
);
|
||||
verify_manifest_against_package(&manifest, &root, &unrar)
|
||||
.expect("independent real-RAR reread should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_solid_and_multi_archive_fixtures_are_supported() {
|
||||
let Some(unrar) = test_unrar() else {
|
||||
return;
|
||||
};
|
||||
let game = CatalogGame {
|
||||
game_id: "cnctw".to_owned(),
|
||||
game_version: "20160128".to_owned(),
|
||||
};
|
||||
for (fixture, expected_files) in [
|
||||
(
|
||||
"fixture-solid/cnctw",
|
||||
&["bin/cnctw-solid-payload.bin", "data/cnctw-solid-assets.dat"][..],
|
||||
),
|
||||
(
|
||||
"fixture-multi/cnctw",
|
||||
&["order/first.txt", "order/second.txt"][..],
|
||||
),
|
||||
] {
|
||||
let root = fixture_path(fixture);
|
||||
let manifest = build_manifest_from_package(&game, &root, &unrar)
|
||||
.expect("RAR fixture should publish");
|
||||
for path in expected_files {
|
||||
assert!(
|
||||
manifest.streamed_install_entry(path).is_some(),
|
||||
"missing {path} from {fixture}"
|
||||
);
|
||||
}
|
||||
verify_manifest_against_package(&manifest, &root, &unrar)
|
||||
.expect("RAR fixture should reproduce");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_bootstrap_and_indexed_incremental_generation_are_reproducible() {
|
||||
let Some(unrar) = test_unrar() else {
|
||||
return;
|
||||
};
|
||||
let temp = TempDir::new();
|
||||
let fixture_catalog_db = temp.0.join("game.db");
|
||||
create_catalog(&fixture_catalog_db, &[("css", "20240623")]).await;
|
||||
let manifests_dir = temp.0.join("manifests");
|
||||
let bootstrap_options = GenerateOptions {
|
||||
catalog_db: fixture_catalog_db,
|
||||
packages_dir: fixture_path("fixture-persona"),
|
||||
manifests_dir: manifests_dir.clone(),
|
||||
unrar,
|
||||
selection: CatalogSelection::All,
|
||||
};
|
||||
|
||||
let first = generate_catalog_manifests(&bootstrap_options)
|
||||
.await
|
||||
.expect("complete fixture should bootstrap");
|
||||
assert!(!manifests_dir.join(CATALOG_PUBLICATION_MARKER_NAME).exists());
|
||||
assert!(manifests_dir.join(CATALOG_CONTENT_INDEX_NAME).is_file());
|
||||
let incremental_options = GenerateOptions {
|
||||
selection: CatalogSelection::GameIds(BTreeSet::from(["css".to_owned()])),
|
||||
..bootstrap_options
|
||||
};
|
||||
let second = generate_catalog_manifests(&incremental_options)
|
||||
.await
|
||||
.expect("indexed atomic overwrite should regenerate");
|
||||
assert!(!manifests_dir.join(CATALOG_PUBLICATION_MARKER_NAME).exists());
|
||||
assert_eq!(first, second);
|
||||
|
||||
let checked = check_catalog_manifests(&CheckOptions {
|
||||
catalog_db: incremental_options.catalog_db.clone(),
|
||||
manifests_dir: manifests_dir.clone(),
|
||||
selection: incremental_options.selection.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("published fixture should check without packages");
|
||||
assert_eq!(checked, first);
|
||||
|
||||
let stdout = cli::execute(&cli::PublisherCommand::Check(CheckOptions {
|
||||
catalog_db: incremental_options.catalog_db.clone(),
|
||||
manifests_dir: manifests_dir.clone(),
|
||||
selection: incremental_options.selection.clone(),
|
||||
}))
|
||||
.await
|
||||
.expect("CLI check should succeed");
|
||||
assert_eq!(
|
||||
stdout,
|
||||
[
|
||||
format!(
|
||||
"checked game_id=css game_version=20240623 content_id={}",
|
||||
first[0].content_id
|
||||
),
|
||||
"checked total=1".to_owned(),
|
||||
]
|
||||
);
|
||||
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(manifests_dir.join("css.json"))
|
||||
.and_then(|mut file| std::io::Write::write_all(&mut file, b" "))
|
||||
.expect("artifact should be tampered");
|
||||
assert!(
|
||||
check_catalog_manifests(&CheckOptions {
|
||||
catalog_db: incremental_options.catalog_db,
|
||||
manifests_dir,
|
||||
selection: incremental_options.selection,
|
||||
})
|
||||
.await
|
||||
.is_err(),
|
||||
"noncanonical artifact must fail closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incremental_publication_rebases_after_completed_writer_interleaving() {
|
||||
let temp = TempDir::new();
|
||||
let catalog_db = temp.0.join("game.db");
|
||||
create_catalog(&catalog_db, &[("a", "20240101"), ("b", "20240102")]).await;
|
||||
simple_package(&temp, "a", "20240101");
|
||||
simple_package(&temp, "b", "20240102");
|
||||
let manifests_dir = temp.0.join("manifests");
|
||||
let bootstrap = GenerateOptions {
|
||||
catalog_db: catalog_db.clone(),
|
||||
packages_dir: temp.0.join("packages"),
|
||||
manifests_dir: manifests_dir.clone(),
|
||||
unrar: PathBuf::from("missing-unrar"),
|
||||
selection: CatalogSelection::All,
|
||||
};
|
||||
generate_catalog_manifests(&bootstrap)
|
||||
.await
|
||||
.expect("baseline corpus should generate");
|
||||
|
||||
fs::write(temp.0.join("packages/a/payload.bin"), b"prepared a update")
|
||||
.expect("selected package should change");
|
||||
let catalog = load_catalog_games(&catalog_db)
|
||||
.await
|
||||
.expect("test catalog should load");
|
||||
let a_options = GenerateOptions {
|
||||
catalog_db: catalog_db.clone(),
|
||||
packages_dir: temp.0.join("packages"),
|
||||
manifests_dir: manifests_dir.clone(),
|
||||
unrar: PathBuf::from("missing-unrar"),
|
||||
selection: CatalogSelection::GameIds(BTreeSet::from(["a".to_owned()])),
|
||||
};
|
||||
let prepared_a = prepare_manifest(
|
||||
catalog.get("a").expect("catalog should contain a"),
|
||||
&a_options,
|
||||
)
|
||||
.expect("a should prepare before the interleaving");
|
||||
|
||||
fs::write(
|
||||
temp.0.join("packages/b/payload.bin"),
|
||||
b"concurrent b update",
|
||||
)
|
||||
.expect("unselected package should change");
|
||||
let b_reports = generate_catalog_manifests(&GenerateOptions {
|
||||
selection: CatalogSelection::GameIds(BTreeSet::from(["b".to_owned()])),
|
||||
..a_options.clone()
|
||||
})
|
||||
.await
|
||||
.expect("completed concurrent publication should update b");
|
||||
let updated_b = b_reports[0].content_id.clone();
|
||||
|
||||
let a_reports = publish_prepared_catalog_manifests(
|
||||
&a_options,
|
||||
&catalog,
|
||||
catalog_versions(&catalog),
|
||||
&[prepared_a],
|
||||
)
|
||||
.expect("a publication should rebase after acquiring the marker");
|
||||
let checked = check_catalog_manifests(&CheckOptions {
|
||||
catalog_db,
|
||||
manifests_dir,
|
||||
selection: CatalogSelection::All,
|
||||
})
|
||||
.await
|
||||
.expect("rebased corpus should validate completely");
|
||||
let checked = checked
|
||||
.into_iter()
|
||||
.map(|report| (report.game_id, report.content_id))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(checked.get("a"), Some(&a_reports[0].content_id));
|
||||
assert_eq!(checked.get("b"), Some(&updated_b));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incremental_publication_rejects_unselected_body_index_drift_before_writes() {
|
||||
let temp = TempDir::new();
|
||||
let catalog_db = temp.0.join("game.db");
|
||||
create_catalog(&catalog_db, &[("a", "20240101"), ("b", "20240102")]).await;
|
||||
simple_package(&temp, "a", "20240101");
|
||||
let b_package = simple_package(&temp, "b", "20240102");
|
||||
let manifests_dir = temp.0.join("manifests");
|
||||
generate_catalog_manifests(&GenerateOptions {
|
||||
catalog_db: catalog_db.clone(),
|
||||
packages_dir: temp.0.join("packages"),
|
||||
manifests_dir: manifests_dir.clone(),
|
||||
unrar: PathBuf::from("missing-unrar"),
|
||||
selection: CatalogSelection::All,
|
||||
})
|
||||
.await
|
||||
.expect("baseline corpus should generate");
|
||||
|
||||
fs::write(temp.0.join("packages/a/payload.bin"), b"selected a update")
|
||||
.expect("selected package should change");
|
||||
fs::write(b_package.join("payload.bin"), b"unindexed b drift")
|
||||
.expect("unselected package should drift");
|
||||
let drifted_b = build_manifest_from_package(
|
||||
&CatalogGame {
|
||||
game_id: "b".to_owned(),
|
||||
game_version: "20240102".to_owned(),
|
||||
},
|
||||
&b_package,
|
||||
Path::new("missing-unrar"),
|
||||
)
|
||||
.expect("drifted b body should still be structurally valid");
|
||||
write_canonical_manifest_atomic(&manifests_dir.join("b.json"), &drifted_b)
|
||||
.expect("drifted unselected body should publish without its index");
|
||||
|
||||
let a_artifact = manifests_dir.join("a.json");
|
||||
let index_artifact = manifests_dir.join(CATALOG_CONTENT_INDEX_NAME);
|
||||
let a_before = fs::read(&a_artifact).expect("selected artifact should read");
|
||||
let index_before = fs::read(&index_artifact).expect("index should read");
|
||||
let error = generate_catalog_manifests(&GenerateOptions {
|
||||
catalog_db,
|
||||
packages_dir: temp.0.join("packages"),
|
||||
manifests_dir: manifests_dir.clone(),
|
||||
unrar: PathBuf::from("missing-unrar"),
|
||||
selection: CatalogSelection::GameIds(BTreeSet::from(["a".to_owned()])),
|
||||
})
|
||||
.await
|
||||
.expect_err("unselected body/index drift must stop incremental publication");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("failed to validate unselected manifest for b")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(a_artifact).expect("selected artifact should remain readable"),
|
||||
a_before
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(index_artifact).expect("index should remain readable"),
|
||||
index_before
|
||||
);
|
||||
assert!(
|
||||
manifests_dir
|
||||
.join(CATALOG_PUBLICATION_MARKER_NAME)
|
||||
.is_file(),
|
||||
"failed under-marker validation must remain visibly fail-closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn entire_selection_is_prepared_before_any_manifest_is_published() {
|
||||
let temp = TempDir::new();
|
||||
let catalog_db = temp.0.join("game.db");
|
||||
create_catalog(&catalog_db, &[("a", "20240101"), ("b", "20240102")]).await;
|
||||
simple_package(&temp, "a", "20240101");
|
||||
simple_package(&temp, "b", "wrong-version");
|
||||
let manifests_dir = temp.0.join("manifests");
|
||||
fs::create_dir(&manifests_dir).expect("manifest directory should be created");
|
||||
let existing = manifests_dir.join("a.json");
|
||||
fs::write(&existing, b"existing artifact").expect("existing artifact should be seeded");
|
||||
|
||||
let error = generate_catalog_manifests(&GenerateOptions {
|
||||
catalog_db,
|
||||
packages_dir: temp.0.join("packages"),
|
||||
manifests_dir: manifests_dir.clone(),
|
||||
unrar: PathBuf::from("missing-unrar"),
|
||||
selection: CatalogSelection::All,
|
||||
})
|
||||
.await
|
||||
.expect_err("late package failure should reject the whole selection");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("failed to generate manifest for b")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(existing).expect("existing artifact should remain readable"),
|
||||
b"existing artifact"
|
||||
);
|
||||
assert!(
|
||||
!manifests_dir.join(CATALOG_PUBLICATION_MARKER_NAME).exists(),
|
||||
"preparation failure must happen before publication starts"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incremental_generation_requires_an_existing_complete_indexed_corpus() {
|
||||
let temp = TempDir::new();
|
||||
let catalog_db = temp.0.join("game.db");
|
||||
create_catalog(&catalog_db, &[("a", "20240101")]).await;
|
||||
simple_package(&temp, "a", "20240101");
|
||||
let manifests_dir = temp.0.join("manifests");
|
||||
|
||||
let error = generate_catalog_manifests(&GenerateOptions {
|
||||
catalog_db,
|
||||
packages_dir: temp.0.join("packages"),
|
||||
manifests_dir: manifests_dir.clone(),
|
||||
unrar: PathBuf::from("missing-unrar"),
|
||||
selection: CatalogSelection::GameIds(BTreeSet::from(["a".to_owned()])),
|
||||
})
|
||||
.await
|
||||
.expect_err("incremental generation must not bootstrap partial authority");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("complete indexed catalog corpus")
|
||||
);
|
||||
assert!(!manifests_dir.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_multi_manifest_publication_makes_check_fail_closed() {
|
||||
let temp = TempDir::new();
|
||||
let catalog_db = temp.0.join("game.db");
|
||||
create_catalog(&catalog_db, &[("a", "20240101"), ("b", "20240102")]).await;
|
||||
let manifests_dir = temp.0.join("manifests");
|
||||
fs::create_dir(&manifests_dir).expect("manifest directory should be created");
|
||||
|
||||
let mut prepared = Vec::new();
|
||||
for game in [
|
||||
CatalogGame {
|
||||
game_id: "a".to_owned(),
|
||||
game_version: "20240101".to_owned(),
|
||||
},
|
||||
CatalogGame {
|
||||
game_id: "b".to_owned(),
|
||||
game_version: "20240102".to_owned(),
|
||||
},
|
||||
] {
|
||||
let package_root = simple_package(&temp, &game.game_id, &game.game_version);
|
||||
let manifest =
|
||||
build_manifest_from_package(&game, &package_root, Path::new("missing-unrar"))
|
||||
.expect("simple package should build");
|
||||
prepared.push(PreparedManifest {
|
||||
artifact: manifests_dir.join(format!("{}.json", game.game_id)),
|
||||
game_id: game.game_id,
|
||||
manifest,
|
||||
});
|
||||
}
|
||||
|
||||
let marker = begin_publication(&manifests_dir, &prepared)
|
||||
.expect("publication marker should be durable before writes");
|
||||
write_canonical_manifest_atomic(&prepared[0].artifact, &prepared[0].manifest)
|
||||
.expect("first artifact should publish before simulated interruption");
|
||||
|
||||
let error = check_catalog_manifests(&CheckOptions {
|
||||
catalog_db,
|
||||
manifests_dir,
|
||||
selection: CatalogSelection::All,
|
||||
})
|
||||
.await
|
||||
.expect_err("an interrupted publication must not be accepted");
|
||||
assert!(error.to_string().contains("publication is incomplete"));
|
||||
assert!(marker.is_file(), "failed publication marker must remain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_version_mismatch_is_rejected() {
|
||||
let temp = TempDir::new();
|
||||
let root = simple_package(&temp, "g", "20240101");
|
||||
let game = CatalogGame {
|
||||
game_id: "g".to_owned(),
|
||||
game_version: "20240102".to_owned(),
|
||||
};
|
||||
let error = build_manifest_from_package(&game, &root, Path::new("missing-unrar"))
|
||||
.expect_err("version skew should fail");
|
||||
assert!(error.to_string().contains("package version mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_verification_detects_changed_package_bytes() {
|
||||
let temp = TempDir::new();
|
||||
let root = simple_package(&temp, "g", "20240101");
|
||||
let game = CatalogGame {
|
||||
game_id: "g".to_owned(),
|
||||
game_version: "20240101".to_owned(),
|
||||
};
|
||||
let manifest = build_manifest_from_package(&game, &root, Path::new("missing-unrar"))
|
||||
.expect("simple package should build");
|
||||
fs::write(root.join("payload.bin"), b"changed").expect("payload should change");
|
||||
assert!(
|
||||
verify_manifest_against_package(&manifest, &root, Path::new("missing-unrar")).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn package_links_special_files_and_portable_aliases_fail_closed() {
|
||||
use std::os::unix::{fs::symlink, net::UnixListener};
|
||||
|
||||
let temp = TempDir::new();
|
||||
let root = simple_package(&temp, "g", "20240101");
|
||||
let game = CatalogGame {
|
||||
game_id: "g".to_owned(),
|
||||
game_version: "20240101".to_owned(),
|
||||
};
|
||||
|
||||
symlink(root.join("payload.bin"), root.join("alias.bin"))
|
||||
.expect("symlink should be created");
|
||||
assert!(build_manifest_from_package(&game, &root, Path::new("missing-unrar")).is_err());
|
||||
fs::remove_file(root.join("alias.bin")).expect("symlink should be removed");
|
||||
|
||||
let listener = UnixListener::bind(root.join("socket")).expect("socket should be created");
|
||||
assert!(build_manifest_from_package(&game, &root, Path::new("missing-unrar")).is_err());
|
||||
drop(listener);
|
||||
fs::remove_file(root.join("socket")).expect("socket should be removed");
|
||||
|
||||
fs::create_dir(root.join("Data")).expect("first alias directory should be created");
|
||||
fs::create_dir(root.join("data")).expect("second alias directory should be created");
|
||||
assert!(build_manifest_from_package(&game, &root, Path::new("missing-unrar")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
fs::{self, File},
|
||||
io::{Read, Take},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
use lanspread_db::content_manifest::{
|
||||
Blake3Digest,
|
||||
CATALOG_CHUNK_SIZE,
|
||||
CatalogContentManifest,
|
||||
CatalogContentManifestBody,
|
||||
CatalogFileEntry,
|
||||
MAX_CATALOG_ENTRIES,
|
||||
MAX_CATALOG_FILE_BYTES,
|
||||
MAX_CATALOG_TOTAL_BYTES,
|
||||
};
|
||||
|
||||
use super::{CatalogGame, catalog::validate_regular_directory, unrar::scan_extracted_files};
|
||||
|
||||
const HASH_BUFFER_SIZE: usize = 1024 * 1024;
|
||||
const MAX_VERSION_INI_BYTES: u64 = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum StreamedInstallPolicy {
|
||||
Required,
|
||||
DisabledForSyntheticFixture,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct FileHashes {
|
||||
pub file: Blake3Digest,
|
||||
pub chunks: Vec<Blake3Digest>,
|
||||
}
|
||||
|
||||
/// Builds one sealed manifest exclusively from a canonical package directory.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the package shape, version, path set, archive output,
|
||||
/// or file contents cannot be validated and hashed.
|
||||
pub fn build_manifest_from_package(
|
||||
game: &CatalogGame,
|
||||
package_root: &Path,
|
||||
unrar: &Path,
|
||||
) -> eyre::Result<CatalogContentManifest> {
|
||||
build_manifest_from_package_with_policy(
|
||||
game,
|
||||
package_root,
|
||||
unrar,
|
||||
StreamedInstallPolicy::Required,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_manifest_from_package_with_policy(
|
||||
game: &CatalogGame,
|
||||
package_root: &Path,
|
||||
unrar: &Path,
|
||||
streamed_install: StreamedInstallPolicy,
|
||||
) -> eyre::Result<CatalogContentManifest> {
|
||||
validate_regular_directory(package_root)?;
|
||||
validate_package_version(package_root, &game.game_version)?;
|
||||
|
||||
let files = scan_ordinary_package(package_root)?;
|
||||
let streamed_install_files = match streamed_install {
|
||||
StreamedInstallPolicy::Required => {
|
||||
let archives = root_eti_archives(package_root)?;
|
||||
scan_extracted_files(unrar, &archives)?
|
||||
}
|
||||
StreamedInstallPolicy::DisabledForSyntheticFixture => Vec::new(),
|
||||
};
|
||||
CatalogContentManifest::seal(CatalogContentManifestBody::new(
|
||||
&game.game_id,
|
||||
&game.game_version,
|
||||
files,
|
||||
streamed_install_files,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Independently rebuilds a manifest and requires byte-authority equivalence.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when rereading fails or produces a different manifest.
|
||||
pub fn verify_manifest_against_package(
|
||||
expected: &CatalogContentManifest,
|
||||
package_root: &Path,
|
||||
unrar: &Path,
|
||||
) -> eyre::Result<()> {
|
||||
let game = CatalogGame {
|
||||
game_id: expected.game_id().to_owned(),
|
||||
game_version: expected.game_version().to_owned(),
|
||||
};
|
||||
let actual = build_manifest_from_package(&game, package_root, unrar)?;
|
||||
if &actual != expected {
|
||||
eyre::bail!(
|
||||
"package reread did not reproduce content ID {} (reread {})",
|
||||
expected.content_id(),
|
||||
actual.content_id()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_manifest_destination(path: &Path) -> eyre::Result<()> {
|
||||
if let Some(parent) = path.parent().filter(|parent| parent.exists()) {
|
||||
validate_regular_directory(parent)?;
|
||||
}
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if is_link_or_reparse(&metadata) || !metadata.is_file() {
|
||||
eyre::bail!(
|
||||
"refusing to replace non-regular manifest artifact: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_package_version(package_root: &Path, expected: &str) -> eyre::Result<()> {
|
||||
let path = package_root.join("version.ini");
|
||||
let bytes = read_bounded_regular_file(&path, MAX_VERSION_INI_BYTES)?;
|
||||
let version = std::str::from_utf8(&bytes)
|
||||
.wrap_err("version.ini is not valid UTF-8")?
|
||||
.trim();
|
||||
if version != expected {
|
||||
eyre::bail!(
|
||||
"package version mismatch: game.db expects {expected}, version.ini contains {version:?}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_ordinary_package(root: &Path) -> eyre::Result<Vec<CatalogFileEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
let mut total_bytes = 0_u64;
|
||||
scan_directory(root, Path::new(""), &mut entries, &mut total_bytes)?;
|
||||
entries.sort_by(|left, right| left.canonical_path().cmp(right.canonical_path()));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn scan_directory(
|
||||
root: &Path,
|
||||
relative_dir: &Path,
|
||||
entries: &mut Vec<CatalogFileEntry>,
|
||||
total_bytes: &mut u64,
|
||||
) -> eyre::Result<()> {
|
||||
let directory = root.join(relative_dir);
|
||||
let before = fs::symlink_metadata(&directory)?;
|
||||
if is_link_or_reparse(&before) || !before.is_dir() {
|
||||
eyre::bail!(
|
||||
"package entry is not a regular non-link directory: {}",
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
|
||||
let mut children = fs::read_dir(&directory)?.collect::<Result<Vec<_>, _>>()?;
|
||||
children.sort_by_key(fs::DirEntry::file_name);
|
||||
for child in children {
|
||||
if entries.len() >= MAX_CATALOG_ENTRIES {
|
||||
eyre::bail!("package exceeds the {MAX_CATALOG_ENTRIES}-entry limit");
|
||||
}
|
||||
let name = child
|
||||
.file_name()
|
||||
.into_string()
|
||||
.map_err(|name| eyre::eyre!("package path is not valid UTF-8: {name:?}"))?;
|
||||
let relative_path = relative_dir.join(name);
|
||||
let canonical_path = path_to_catalog_string(&relative_path)?;
|
||||
let path = child.path();
|
||||
let metadata = fs::symlink_metadata(&path)?;
|
||||
if is_link_or_reparse(&metadata) {
|
||||
eyre::bail!(
|
||||
"package contains a link or reparse point: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
entries.push(CatalogFileEntry::directory(&canonical_path)?);
|
||||
scan_directory(root, &relative_path, entries, total_bytes)?;
|
||||
} else if metadata.is_file() {
|
||||
if metadata.len() > MAX_CATALOG_FILE_BYTES {
|
||||
eyre::bail!(
|
||||
"package file exceeds the {MAX_CATALOG_FILE_BYTES}-byte limit: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
*total_bytes = total_bytes
|
||||
.checked_add(metadata.len())
|
||||
.ok_or_else(|| eyre::eyre!("package byte total overflow"))?;
|
||||
if *total_bytes > MAX_CATALOG_TOTAL_BYTES {
|
||||
eyre::bail!("package exceeds the {MAX_CATALOG_TOTAL_BYTES}-byte total limit");
|
||||
}
|
||||
let hashes = hash_regular_file(&path, &metadata)?;
|
||||
entries.push(CatalogFileEntry::file(
|
||||
&canonical_path,
|
||||
metadata.len(),
|
||||
hashes.file,
|
||||
hashes.chunks,
|
||||
)?);
|
||||
} else {
|
||||
eyre::bail!("package contains a special file: {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
let after = fs::symlink_metadata(&directory)?;
|
||||
if is_link_or_reparse(&after) || !after.is_dir() || !same_file(&before, &after) {
|
||||
eyre::bail!(
|
||||
"package directory changed while scanning: {}",
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn root_eti_archives(root: &Path) -> eyre::Result<Vec<PathBuf>> {
|
||||
let mut archives = Vec::new();
|
||||
for entry in fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let metadata = fs::symlink_metadata(&path)?;
|
||||
if !is_link_or_reparse(&metadata)
|
||||
&& metadata.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == OsStr::new("eti"))
|
||||
{
|
||||
archives.push(path);
|
||||
}
|
||||
}
|
||||
archives.sort();
|
||||
Ok(archives)
|
||||
}
|
||||
|
||||
fn hash_regular_file(path: &Path, expected: &fs::Metadata) -> eyre::Result<FileHashes> {
|
||||
let mut file = File::open(path)
|
||||
.wrap_err_with(|| format!("failed to open package file {}", path.display()))?;
|
||||
let opened = file.metadata()?;
|
||||
if !opened.is_file() || !same_file(expected, &opened) || opened.len() != expected.len() {
|
||||
eyre::bail!(
|
||||
"package file changed shape while opening: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let hashes = hash_exact(&mut file, expected.len(), CATALOG_CHUNK_SIZE)?;
|
||||
let mut extra = [0_u8; 1];
|
||||
if file.read(&mut extra)? != 0 {
|
||||
eyre::bail!("package file grew while hashing: {}", path.display());
|
||||
}
|
||||
let after = file.metadata()?;
|
||||
if !after.is_file() || !same_file(expected, &after) || after.len() != expected.len() {
|
||||
eyre::bail!("package file changed while hashing: {}", path.display());
|
||||
}
|
||||
Ok(hashes)
|
||||
}
|
||||
|
||||
pub(super) fn hash_exact(
|
||||
reader: &mut impl Read,
|
||||
size: u64,
|
||||
chunk_size: u64,
|
||||
) -> eyre::Result<FileHashes> {
|
||||
if chunk_size == 0 {
|
||||
eyre::bail!("hash chunk size cannot be zero");
|
||||
}
|
||||
let mut remaining = size;
|
||||
let mut whole = blake3::Hasher::new();
|
||||
let mut chunk = blake3::Hasher::new();
|
||||
let mut chunk_bytes = 0_u64;
|
||||
let mut chunks = Vec::new();
|
||||
let mut buffer = vec![0_u8; HASH_BUFFER_SIZE];
|
||||
|
||||
while remaining > 0 {
|
||||
let wanted = usize::try_from(remaining.min(u64::try_from(buffer.len())?))?;
|
||||
let read = reader.read(&mut buffer[..wanted])?;
|
||||
if read == 0 {
|
||||
eyre::bail!("input ended with {remaining} expected byte(s) missing");
|
||||
}
|
||||
let bytes = &buffer[..read];
|
||||
whole.update(bytes);
|
||||
|
||||
let mut offset = 0;
|
||||
while offset < bytes.len() {
|
||||
let available = chunk_size - chunk_bytes;
|
||||
let take = usize::try_from(available.min(u64::try_from(bytes.len() - offset)?))?;
|
||||
chunk.update(&bytes[offset..offset + take]);
|
||||
offset += take;
|
||||
chunk_bytes += u64::try_from(take)?;
|
||||
if chunk_bytes == chunk_size {
|
||||
chunks.push(Blake3Digest::from_bytes(*chunk.finalize().as_bytes()));
|
||||
chunk = blake3::Hasher::new();
|
||||
chunk_bytes = 0;
|
||||
}
|
||||
}
|
||||
remaining -= u64::try_from(read)?;
|
||||
}
|
||||
|
||||
if chunk_bytes != 0 {
|
||||
chunks.push(Blake3Digest::from_bytes(*chunk.finalize().as_bytes()));
|
||||
}
|
||||
Ok(FileHashes {
|
||||
file: Blake3Digest::from_bytes(*whole.finalize().as_bytes()),
|
||||
chunks,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_bounded_regular_file(path: &Path, limit: u64) -> eyre::Result<Vec<u8>> {
|
||||
let before = fs::symlink_metadata(path)
|
||||
.wrap_err_with(|| format!("failed to inspect package file {}", path.display()))?;
|
||||
if is_link_or_reparse(&before) || !before.is_file() {
|
||||
eyre::bail!(
|
||||
"package file is not a regular non-link file: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
if before.len() > limit {
|
||||
eyre::bail!(
|
||||
"package file exceeds the {limit}-byte limit: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let mut file = File::open(path)?;
|
||||
let opened = file.metadata()?;
|
||||
if !opened.is_file() || !same_file(&before, &opened) || opened.len() > limit {
|
||||
eyre::bail!(
|
||||
"package file changed shape while opening: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(opened.len())?);
|
||||
let mut bounded: Take<&mut File> = file.by_ref().take(limit + 1);
|
||||
bounded.read_to_end(&mut bytes)?;
|
||||
if u64::try_from(bytes.len())? > limit {
|
||||
eyre::bail!(
|
||||
"package file exceeds the {limit}-byte limit: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let after = file.metadata()?;
|
||||
if !after.is_file() || !same_file(&before, &after) || after.len() != opened.len() {
|
||||
eyre::bail!("package file changed while reading: {}", path.display());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn path_to_catalog_string(path: &Path) -> eyre::Result<String> {
|
||||
let components = path
|
||||
.iter()
|
||||
.map(|component| {
|
||||
component
|
||||
.to_str()
|
||||
.ok_or_else(|| eyre::eyre!("package path is not valid UTF-8: {path:?}"))
|
||||
})
|
||||
.collect::<eyre::Result<Vec<_>>>()?;
|
||||
Ok(components.join("/"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn same_file(before: &fs::Metadata, after: &fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
before.dev() == after.dev() && before.ino() == after.ino()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn same_file(_before: &fs::Metadata, _after: &fs::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[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::io::Cursor;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hashes_whole_input_and_each_exact_chunk() {
|
||||
let bytes = b"abcdefghij";
|
||||
let hashes = hash_exact(&mut Cursor::new(bytes), 10, 4).expect("hashing should succeed");
|
||||
assert_eq!(hashes.file, Blake3Digest::hash(bytes));
|
||||
assert_eq!(
|
||||
hashes.chunks,
|
||||
[&b"abcd"[..], &b"efgh"[..], &b"ij"[..]].map(Blake3Digest::hash)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_has_a_whole_hash_and_no_chunks() {
|
||||
let hashes = hash_exact(&mut Cursor::new([]), 0, 4).expect("hashing should succeed");
|
||||
assert_eq!(hashes.file, Blake3Digest::hash(&[]));
|
||||
assert!(hashes.chunks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_input_is_rejected() {
|
||||
let error =
|
||||
hash_exact(&mut Cursor::new(b"abc"), 4, 4).expect_err("truncated hashing should fail");
|
||||
assert!(error.to_string().contains("1 expected byte"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io::{self, Read},
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, ChildStderr, ChildStdout, Command, ExitStatus, Stdio},
|
||||
sync::mpsc::{self, Receiver, TryRecvError},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
use lanspread_db::content_manifest::{
|
||||
CATALOG_CHUNK_SIZE,
|
||||
CanonicalCatalogPath,
|
||||
CatalogExtractedEntry,
|
||||
MAX_CATALOG_ENTRIES,
|
||||
MAX_CATALOG_FILE_BYTES,
|
||||
MAX_CATALOG_TOTAL_BYTES,
|
||||
};
|
||||
|
||||
use super::package::hash_exact;
|
||||
|
||||
// Retain enough technical listing data for the maximum catalog shape without
|
||||
// permitting a subprocess to grow publisher memory without bound.
|
||||
const MAX_UNRAR_LISTING_BYTES: usize = 128 * 1024 * 1024;
|
||||
const MAX_UNRAR_ERROR_BYTES: usize = 64 * 1024;
|
||||
const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(5);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RarEntryKind {
|
||||
File,
|
||||
Directory,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct RarEntry {
|
||||
path: String,
|
||||
kind: RarEntryKind,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RarEntryDraft {
|
||||
path: Option<String>,
|
||||
kind: Option<RarEntryKind>,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
enum ExtractedValue {
|
||||
Directory,
|
||||
File {
|
||||
size: u64,
|
||||
hash: lanspread_db::content_manifest::Blake3Digest,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn scan_extracted_files(
|
||||
program: &Path,
|
||||
archives: &[PathBuf],
|
||||
) -> eyre::Result<Vec<CatalogExtractedEntry>> {
|
||||
let mut outputs = BTreeMap::<String, ExtractedValue>::new();
|
||||
let mut listed_entries = 0_usize;
|
||||
let mut listed_bytes = 0_u64;
|
||||
for archive in archives {
|
||||
let entries = list_archive(program, archive)?;
|
||||
listed_entries = listed_entries
|
||||
.checked_add(entries.len())
|
||||
.ok_or_else(|| eyre::eyre!("RAR entry count overflow"))?;
|
||||
if listed_entries > MAX_CATALOG_ENTRIES {
|
||||
eyre::bail!("RAR inputs exceed the {MAX_CATALOG_ENTRIES}-entry limit");
|
||||
}
|
||||
for entry in &entries {
|
||||
listed_bytes = listed_bytes
|
||||
.checked_add(entry.size)
|
||||
.ok_or_else(|| eyre::eyre!("RAR extracted-byte total overflow"))?;
|
||||
if listed_bytes > MAX_CATALOG_TOTAL_BYTES {
|
||||
eyre::bail!("RAR inputs exceed the {MAX_CATALOG_TOTAL_BYTES}-byte extracted limit");
|
||||
}
|
||||
}
|
||||
hash_archive_outputs(program, archive, &entries, &mut outputs)?;
|
||||
}
|
||||
outputs
|
||||
.into_iter()
|
||||
.map(|(path, value)| match value {
|
||||
ExtractedValue::Directory => CatalogExtractedEntry::directory(path),
|
||||
ExtractedValue::File { size, hash } => CatalogExtractedEntry::file(path, size, hash),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn list_archive(program: &Path, archive: &Path) -> eyre::Result<Vec<RarEntry>> {
|
||||
let mut command = non_interactive_unrar_command(program, "lt");
|
||||
command.arg(archive);
|
||||
let output =
|
||||
capture_command_output(&mut command, MAX_UNRAR_LISTING_BYTES, MAX_UNRAR_ERROR_BYTES)
|
||||
.wrap_err_with(|| format!("failed to run unrar for {}", archive.display()))?;
|
||||
if output.stdout.truncated {
|
||||
eyre::bail!(
|
||||
"unrar listing exceeds the {MAX_UNRAR_LISTING_BYTES}-byte limit for {}",
|
||||
archive.display()
|
||||
);
|
||||
}
|
||||
if output.stderr.truncated {
|
||||
eyre::bail!(
|
||||
"unrar diagnostic output exceeds the {MAX_UNRAR_ERROR_BYTES}-byte limit for {}",
|
||||
archive.display()
|
||||
);
|
||||
}
|
||||
if !output.status.success() {
|
||||
eyre::bail!(
|
||||
"unrar listing failed for {} with status {}: {}",
|
||||
archive.display(),
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr.bytes).trim()
|
||||
);
|
||||
}
|
||||
let listing = std::str::from_utf8(&output.stdout.bytes)
|
||||
.wrap_err_with(|| format!("unrar listing is not UTF-8 for {}", archive.display()))?;
|
||||
parse_listing(listing)
|
||||
.wrap_err_with(|| format!("invalid unrar listing for {}", archive.display()))
|
||||
}
|
||||
|
||||
fn parse_listing(listing: &str) -> eyre::Result<Vec<RarEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
let mut draft = RarEntryDraft::default();
|
||||
for line in listing.lines() {
|
||||
let line = line.trim_start();
|
||||
if let Some(path) = line.strip_prefix("Name:") {
|
||||
push_entry(&mut entries, std::mem::take(&mut draft))?;
|
||||
draft.path = Some(path.strip_prefix(' ').unwrap_or(path).to_owned());
|
||||
} else if let Some(kind) = line.strip_prefix("Type:") {
|
||||
if draft.kind.is_some() {
|
||||
eyre::bail!("RAR entry repeats its Type field");
|
||||
}
|
||||
draft.kind = Some(match kind.trim() {
|
||||
"File" => RarEntryKind::File,
|
||||
"Directory" => RarEntryKind::Directory,
|
||||
unsupported => eyre::bail!("unsupported RAR entry type: {unsupported}"),
|
||||
});
|
||||
} else if let Some(size) = line.strip_prefix("Size:") {
|
||||
if draft.size.is_some() {
|
||||
eyre::bail!("RAR entry repeats its Size field");
|
||||
}
|
||||
draft.size = Some(size.trim().parse()?);
|
||||
}
|
||||
}
|
||||
push_entry(&mut entries, draft)?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn push_entry(entries: &mut Vec<RarEntry>, draft: RarEntryDraft) -> eyre::Result<()> {
|
||||
let Some(path) = draft.path else {
|
||||
if draft.kind.is_some() || draft.size.is_some() {
|
||||
eyre::bail!("RAR entry metadata appears before a Name field");
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
CanonicalCatalogPath::new(&path)?;
|
||||
let kind = draft
|
||||
.kind
|
||||
.ok_or_else(|| eyre::eyre!("RAR entry {path:?} has no Type field"))?;
|
||||
let size = match kind {
|
||||
RarEntryKind::File => draft
|
||||
.size
|
||||
.ok_or_else(|| eyre::eyre!("RAR file entry {path:?} has no Size field"))?,
|
||||
RarEntryKind::Directory => {
|
||||
if draft.size.is_some_and(|size| size != 0) {
|
||||
eyre::bail!("RAR directory entry {path:?} has a nonzero size");
|
||||
}
|
||||
0
|
||||
}
|
||||
};
|
||||
if size > MAX_CATALOG_FILE_BYTES {
|
||||
eyre::bail!("RAR file entry {path:?} exceeds the per-file size limit");
|
||||
}
|
||||
entries.push(RarEntry { path, kind, size });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_archive_outputs(
|
||||
program: &Path,
|
||||
archive: &Path,
|
||||
entries: &[RarEntry],
|
||||
outputs: &mut BTreeMap<String, ExtractedValue>,
|
||||
) -> eyre::Result<()> {
|
||||
let child = non_interactive_unrar_command(program, "p")
|
||||
.arg("-inul")
|
||||
.arg(archive)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.wrap_err_with(|| format!("failed to stream archive {} with unrar", archive.display()))?;
|
||||
let mut child = ChildGuard::new(child);
|
||||
let mut stdout = child
|
||||
.child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| eyre::eyre!("unrar stdout was not captured"))?;
|
||||
|
||||
for entry in entries {
|
||||
match entry.kind {
|
||||
RarEntryKind::Directory => {
|
||||
insert_output(outputs, &entry.path, ExtractedValue::Directory)?;
|
||||
}
|
||||
RarEntryKind::File => {
|
||||
let hashes = hash_exact(&mut stdout, entry.size, CATALOG_CHUNK_SIZE)
|
||||
.wrap_err_with(|| format!("failed to hash extracted file {}", entry.path))?;
|
||||
insert_output(
|
||||
outputs,
|
||||
&entry.path,
|
||||
ExtractedValue::File {
|
||||
size: entry.size,
|
||||
hash: hashes.file,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut extra = [0_u8; 1];
|
||||
if stdout.read(&mut extra)? != 0 {
|
||||
eyre::bail!(
|
||||
"unrar produced bytes not described by its listing for {}",
|
||||
archive.display()
|
||||
);
|
||||
}
|
||||
drop(stdout);
|
||||
let status = child.wait()?;
|
||||
if !status.success() {
|
||||
eyre::bail!(
|
||||
"unrar streaming failed for {} with status {status}",
|
||||
archive.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn non_interactive_unrar_command(program: &Path, mode: &str) -> Command {
|
||||
let mut command = Command::new(program);
|
||||
command
|
||||
.arg(mode)
|
||||
.arg("-cfg-")
|
||||
// Never prompt for an encrypted archive. A canonical package that
|
||||
// requires a password is unsupported and must fail closed.
|
||||
.arg("-p-")
|
||||
// Publisher processes must not consume the invoking terminal or build
|
||||
// runner's stdin, even if unrar encounters an unexpected prompt.
|
||||
.stdin(Stdio::null());
|
||||
command
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CapturedChildOutput {
|
||||
status: ExitStatus,
|
||||
stdout: CapturedPipe,
|
||||
stderr: CapturedPipe,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CapturedPipe {
|
||||
bytes: Vec<u8>,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
fn capture_command_output(
|
||||
command: &mut Command,
|
||||
stdout_limit: usize,
|
||||
stderr_limit: usize,
|
||||
) -> eyre::Result<CapturedChildOutput> {
|
||||
capture_command_output_with_readers(
|
||||
command,
|
||||
stdout_limit,
|
||||
stderr_limit,
|
||||
read_bounded_pipe,
|
||||
read_bounded_pipe,
|
||||
)
|
||||
}
|
||||
|
||||
fn capture_command_output_with_readers<StdoutReader, StderrReader>(
|
||||
command: &mut Command,
|
||||
stdout_limit: usize,
|
||||
stderr_limit: usize,
|
||||
stdout_reader: StdoutReader,
|
||||
stderr_reader: StderrReader,
|
||||
) -> eyre::Result<CapturedChildOutput>
|
||||
where
|
||||
StdoutReader: FnOnce(ChildStdout, usize) -> io::Result<CapturedPipe> + Send,
|
||||
StderrReader: FnOnce(ChildStderr, usize) -> io::Result<CapturedPipe> + Send,
|
||||
{
|
||||
let program = command.get_program().to_string_lossy().into_owned();
|
||||
let child = command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.wrap_err_with(|| format!("failed to spawn child process {program}"))?;
|
||||
let mut child = ChildGuard::new(child);
|
||||
let stdout = child.child.stdout.take().ok_or_else(|| {
|
||||
child.terminate_for_error(format!(
|
||||
"child process {program} started without its requested stdout pipe"
|
||||
))
|
||||
})?;
|
||||
let stderr = child.child.stderr.take().ok_or_else(|| {
|
||||
child.terminate_for_error(format!(
|
||||
"child process {program} started without its requested stderr pipe"
|
||||
))
|
||||
})?;
|
||||
|
||||
thread::scope(move |scope| {
|
||||
// `child` lives inside this scope so it is killed and reaped before an
|
||||
// unwind or early return can join a reader still waiting for pipe EOF.
|
||||
let mut child = child;
|
||||
let (stdout_tx, stdout_rx) = mpsc::sync_channel(1);
|
||||
let stdout_thread = thread::Builder::new()
|
||||
.name("lanspread-unrar-stdout".to_owned())
|
||||
.spawn_scoped(scope, move || {
|
||||
let _ = stdout_tx.send(stdout_reader(stdout, stdout_limit));
|
||||
})
|
||||
.map_err(|error| {
|
||||
child.terminate_for_error(format!(
|
||||
"failed to start stdout reader for child process {program}: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let (stderr_tx, stderr_rx) = mpsc::sync_channel(1);
|
||||
let stderr_thread = match thread::Builder::new()
|
||||
.name("lanspread-unrar-stderr".to_owned())
|
||||
.spawn_scoped(scope, move || {
|
||||
let _ = stderr_tx.send(stderr_reader(stderr, stderr_limit));
|
||||
}) {
|
||||
Ok(thread) => thread,
|
||||
Err(error) => {
|
||||
let error = child.terminate_for_error(format!(
|
||||
"failed to start stderr reader for child process {program}: {error}"
|
||||
));
|
||||
let _ = stdout_thread.join();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let result = collect_captured_output(&mut child, &stdout_rx, &stderr_rx, &program);
|
||||
let stdout_join = stdout_thread.join();
|
||||
let stderr_join = stderr_thread.join();
|
||||
match result {
|
||||
Ok(_output) if stdout_join.is_err() => Err(eyre::eyre!(
|
||||
"stdout reader for child process {program} panicked"
|
||||
)),
|
||||
Ok(_output) if stderr_join.is_err() => Err(eyre::eyre!(
|
||||
"stderr reader for child process {program} panicked"
|
||||
)),
|
||||
result => result,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_captured_output(
|
||||
child: &mut ChildGuard,
|
||||
stdout_rx: &Receiver<io::Result<CapturedPipe>>,
|
||||
stderr_rx: &Receiver<io::Result<CapturedPipe>>,
|
||||
program: &str,
|
||||
) -> eyre::Result<CapturedChildOutput> {
|
||||
let mut status = None;
|
||||
let mut stdout = None;
|
||||
let mut stderr = None;
|
||||
loop {
|
||||
poll_pipe_reader(child, stdout_rx, &mut stdout, "stdout", program)?;
|
||||
poll_pipe_reader(child, stderr_rx, &mut stderr, "stderr", program)?;
|
||||
|
||||
if status.is_none() {
|
||||
match child.try_wait() {
|
||||
Ok(Some(exit_status)) => status = Some(exit_status),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
return Err(child.terminate_for_error(format!(
|
||||
"failed to wait for child process {program}: {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if status.is_some() && stdout.is_some() && stderr.is_some() {
|
||||
return Ok(CapturedChildOutput {
|
||||
status: status
|
||||
.take()
|
||||
.ok_or_else(|| eyre::eyre!("child status disappeared"))?,
|
||||
stdout: stdout
|
||||
.take()
|
||||
.ok_or_else(|| eyre::eyre!("captured stdout disappeared"))?,
|
||||
stderr: stderr
|
||||
.take()
|
||||
.ok_or_else(|| eyre::eyre!("captured stderr disappeared"))?,
|
||||
});
|
||||
}
|
||||
thread::sleep(PROCESS_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_pipe_reader(
|
||||
child: &mut ChildGuard,
|
||||
receiver: &Receiver<io::Result<CapturedPipe>>,
|
||||
captured: &mut Option<CapturedPipe>,
|
||||
pipe_name: &str,
|
||||
program: &str,
|
||||
) -> eyre::Result<()> {
|
||||
if captured.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
match receiver.try_recv() {
|
||||
Ok(Ok(output)) => {
|
||||
*captured = Some(output);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(error)) => Err(child.terminate_for_error(format!(
|
||||
"failed to read {pipe_name} from child process {program}: {error}"
|
||||
))),
|
||||
Err(TryRecvError::Empty) => Ok(()),
|
||||
Err(TryRecvError::Disconnected) => Err(child.terminate_for_error(format!(
|
||||
"{pipe_name} reader for child process {program} ended without a result"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bounded_pipe(mut pipe: impl Read, max_bytes: usize) -> io::Result<CapturedPipe> {
|
||||
let mut bytes = Vec::with_capacity(max_bytes.min(8 * 1024));
|
||||
let mut buffer = [0_u8; 8 * 1024];
|
||||
let mut truncated = false;
|
||||
loop {
|
||||
let read = pipe.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
let remaining = max_bytes.saturating_sub(bytes.len());
|
||||
let retained = read.min(remaining);
|
||||
bytes.extend_from_slice(&buffer[..retained]);
|
||||
truncated |= retained != read;
|
||||
}
|
||||
Ok(CapturedPipe { bytes, truncated })
|
||||
}
|
||||
|
||||
fn insert_output(
|
||||
outputs: &mut BTreeMap<String, ExtractedValue>,
|
||||
path: &str,
|
||||
value: ExtractedValue,
|
||||
) -> eyre::Result<()> {
|
||||
if let Some(previous) = outputs.get(path) {
|
||||
match (previous, &value) {
|
||||
(ExtractedValue::Directory, ExtractedValue::Directory) => return Ok(()),
|
||||
(ExtractedValue::File { .. }, ExtractedValue::File { .. }) => {
|
||||
eyre::bail!("RAR archives emit extracted file more than once: {path}");
|
||||
}
|
||||
_ => {
|
||||
eyre::bail!("RAR archives change the file/directory shape of {path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
outputs.insert(path.to_owned(), value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ChildGuard {
|
||||
child: Child,
|
||||
waited: bool,
|
||||
}
|
||||
|
||||
impl ChildGuard {
|
||||
fn new(child: Child) -> Self {
|
||||
Self {
|
||||
child,
|
||||
waited: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
|
||||
let status = self.child.wait()?;
|
||||
self.waited = true;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
|
||||
let status = self.child.try_wait()?;
|
||||
if status.is_some() {
|
||||
self.waited = true;
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn terminate_for_error(&mut self, reason: impl std::fmt::Display) -> eyre::Report {
|
||||
if self.waited {
|
||||
return eyre::eyre!("{reason}");
|
||||
}
|
||||
|
||||
let kill_error = self.child.kill().err();
|
||||
match self.child.wait() {
|
||||
Ok(_status) => {
|
||||
self.waited = true;
|
||||
let kill_context = kill_error
|
||||
.map(|error| format!("; kill reported: {error}"))
|
||||
.unwrap_or_default();
|
||||
eyre::eyre!("{reason}{kill_context}")
|
||||
}
|
||||
Err(wait_error) => eyre::eyre!(
|
||||
"{reason}; failed to reap child: {wait_error}; kill error: {kill_error:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.waited {
|
||||
let kill_error = self.child.kill().err();
|
||||
if let Err(wait_error) = self.child.wait() {
|
||||
tracing::error!(
|
||||
"failed to reap guarded unrar child: {wait_error}; kill error: {kill_error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bounded_pipe_capture_drains_bytes_beyond_the_retention_limit() {
|
||||
let mut input = io::Cursor::new(b"abcdef".to_vec());
|
||||
|
||||
let captured = read_bounded_pipe(&mut input, 3).expect("pipe should be readable");
|
||||
|
||||
assert_eq!(captured.bytes, b"abc");
|
||||
assert!(captured.truncated);
|
||||
assert_eq!(input.position(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_file_and_directory_entries() {
|
||||
let listing = "\
|
||||
Name: bin/a.txt\n\
|
||||
Type: File\n\
|
||||
Size: 3\n\
|
||||
Name: bin\n\
|
||||
Type: Directory\n";
|
||||
assert_eq!(
|
||||
parse_listing(listing).expect("listing should parse"),
|
||||
vec![
|
||||
RarEntry {
|
||||
path: "bin/a.txt".to_owned(),
|
||||
kind: RarEntryKind::File,
|
||||
size: 3,
|
||||
},
|
||||
RarEntry {
|
||||
path: "bin".to_owned(),
|
||||
kind: RarEntryKind::Directory,
|
||||
size: 0,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_and_special_entries() {
|
||||
for listing in [
|
||||
"Name: ../escape\nType: File\nSize: 1\n",
|
||||
"Name: link\nType: Unix symlink\nSize: 1\n",
|
||||
"Name: file\nType: File\n",
|
||||
] {
|
||||
assert!(parse_listing(listing).is_err(), "accepted {listing:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_extracted_files_but_allows_repeated_directories() {
|
||||
let mut outputs = BTreeMap::new();
|
||||
insert_output(&mut outputs, "shared", ExtractedValue::Directory)
|
||||
.expect("first directory should be accepted");
|
||||
insert_output(&mut outputs, "shared", ExtractedValue::Directory)
|
||||
.expect("archives may repeat a directory entry");
|
||||
insert_output(
|
||||
&mut outputs,
|
||||
"shared/payload.bin",
|
||||
ExtractedValue::File {
|
||||
size: 1,
|
||||
hash: lanspread_db::content_manifest::Blake3Digest::hash(b"a"),
|
||||
},
|
||||
)
|
||||
.expect("first file should be accepted");
|
||||
|
||||
let error = insert_output(
|
||||
&mut outputs,
|
||||
"shared/payload.bin",
|
||||
ExtractedValue::File {
|
||||
size: 1,
|
||||
hash: lanspread_db::content_manifest::Blake3Digest::hash(b"b"),
|
||||
},
|
||||
)
|
||||
.expect_err("duplicate extracted file should be rejected");
|
||||
|
||||
assert!(error.to_string().contains("more than once"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn every_unrar_invocation_disables_passwords_and_parent_stdin() {
|
||||
use std::{
|
||||
fs,
|
||||
io::Write as _,
|
||||
os::unix::fs::PermissionsExt,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should follow epoch")
|
||||
.as_nanos();
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"lanspread-unrar-noninteractive-{}-{nanos}-{}",
|
||||
std::process::id(),
|
||||
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&root).expect("temporary directory should be created");
|
||||
let script = root.join("unrar");
|
||||
let mut script_file = fs::File::create(&script).expect("fake unrar should be created");
|
||||
script_file
|
||||
.write_all(
|
||||
"#!/bin/sh\n\
|
||||
case \" $* \" in *\" -p- \"*) ;; *) exit 80 ;; esac\n\
|
||||
test \"$(readlink /proc/$$/fd/0)\" = /dev/null || exit 81\n\
|
||||
case \"$1\" in\n\
|
||||
lt) printf 'Name: payload.bin\\nType: File\\nSize: 3\\n' ;;\n\
|
||||
p) printf 'abc' ;;\n\
|
||||
*) exit 82 ;;\n\
|
||||
esac\n"
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("fake unrar should be written");
|
||||
script_file
|
||||
.sync_all()
|
||||
.expect("fake unrar should be durable before execution");
|
||||
drop(script_file);
|
||||
let mut permissions = fs::metadata(&script)
|
||||
.expect("fake unrar should exist")
|
||||
.permissions();
|
||||
permissions.set_mode(0o700);
|
||||
fs::set_permissions(&script, permissions).expect("fake unrar should be executable");
|
||||
let archive = root.join("fixture.eti");
|
||||
fs::write(&archive, []).expect("archive placeholder should be written");
|
||||
|
||||
let entries = scan_extracted_files(&script, &[archive])
|
||||
.expect("both fake unrar invocations should be non-interactive");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].canonical_path().as_str(), "payload.bin");
|
||||
assert_eq!(
|
||||
entries[0].file_blake3(),
|
||||
Some(lanspread_db::content_manifest::Blake3Digest::hash(b"abc"))
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("temporary directory should be removed");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn listing_pipe_read_failure_kills_and_reaps_the_direct_child() {
|
||||
use std::{
|
||||
fs,
|
||||
io::Write as _,
|
||||
os::unix::fs::PermissionsExt,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should follow epoch")
|
||||
.as_nanos();
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"lanspread-unrar-read-failure-{}-{nanos}-{}",
|
||||
std::process::id(),
|
||||
SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&root).expect("temporary directory should be created");
|
||||
let script = root.join("controlled-child");
|
||||
let mut script_file = fs::File::create(&script).expect("child script should be created");
|
||||
script_file
|
||||
.write_all(b"#!/bin/sh\nset -eu\nprintf '%s\\n' \"$$\" > \"$1\"\nwhile :; do :; done\n")
|
||||
.expect("child script should be written");
|
||||
script_file
|
||||
.sync_all()
|
||||
.expect("child script should be durable before execution");
|
||||
drop(script_file);
|
||||
let mut permissions = fs::metadata(&script)
|
||||
.expect("child script should exist")
|
||||
.permissions();
|
||||
permissions.set_mode(0o700);
|
||||
fs::set_permissions(&script, permissions).expect("child script should be executable");
|
||||
|
||||
let pid_marker = root.join("pid");
|
||||
let marker_for_reader = pid_marker.clone();
|
||||
// Invoke the controlled script through the system shell. Some shared
|
||||
// build filesystems can transiently reject direct execution of a file
|
||||
// whose creation was just closed with ETXTBSY; the lifecycle under test
|
||||
// is the captured direct `sh` child either way.
|
||||
let mut command = Command::new("sh");
|
||||
command.arg(&script).arg(&pid_marker);
|
||||
let error = capture_command_output_with_readers(
|
||||
&mut command,
|
||||
1024,
|
||||
1024,
|
||||
move |_stdout, _limit| {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
if fs::read_to_string(&marker_for_reader)
|
||||
.ok()
|
||||
.and_then(|pid| pid.trim().parse::<u32>().ok())
|
||||
.is_some()
|
||||
{
|
||||
return Err(io::Error::other("injected stdout pipe read failure"));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"controlled child did not publish its PID",
|
||||
));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
},
|
||||
read_bounded_pipe,
|
||||
)
|
||||
.expect_err("injected pipe failure should fail capture");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("injected stdout pipe read failure"),
|
||||
"unexpected capture failure: {error:#}"
|
||||
);
|
||||
let pid = fs::read_to_string(&pid_marker)
|
||||
.expect("controlled child should publish its PID")
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.expect("published PID should parse");
|
||||
assert!(
|
||||
!Path::new(&format!("/proc/{pid}")).exists(),
|
||||
"capture returned before child PID {pid} was reaped"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("temporary directory should be removed");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn child_guard_kills_and_reaps_during_unwind() {
|
||||
let child = Command::new("sleep")
|
||||
.arg("30")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("controlled child should start");
|
||||
let pid = child.id();
|
||||
let process_path = PathBuf::from(format!("/proc/{pid}"));
|
||||
assert!(process_path.exists(), "controlled child should be live");
|
||||
|
||||
let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
||||
let _guard = ChildGuard::new(child);
|
||||
panic!("injected parent unwind");
|
||||
}));
|
||||
|
||||
assert!(unwind.is_err(), "injected unwind should be observed");
|
||||
assert!(
|
||||
!process_path.exists(),
|
||||
"guard unwind returned before child PID {pid} was reaped"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use std::path::Path;
|
||||
|
||||
use lanspread_db::db::{Availability, Game};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct EtiGame {
|
||||
@@ -26,7 +26,7 @@ pub async fn get_games(db: &Path) -> eyre::Result<Vec<EtiGame>> {
|
||||
let options = SqliteConnectOptions::new().filename(db).read_only(true);
|
||||
let pool = SqlitePoolOptions::new().connect_with(options).await?;
|
||||
|
||||
let mut games = sqlx::query_as::<_, EtiGame>(
|
||||
let query_result = sqlx::query_as::<_, EtiGame>(
|
||||
"SELECT
|
||||
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,
|
||||
@@ -36,7 +36,8 @@ pub async fn get_games(db: &Path) -> eyre::Result<Vec<EtiGame>> {
|
||||
JOIN genre ge ON g.genre_id = ge.genre_id",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
.await;
|
||||
let mut games = close_pool_after_query(&pool, query_result).await?;
|
||||
|
||||
games.sort_by(|a, b| a.game_title.cmp(&b.game_title));
|
||||
|
||||
@@ -48,6 +49,17 @@ pub async fn get_games(db: &Path) -> eyre::Result<Vec<EtiGame>> {
|
||||
Ok(games)
|
||||
}
|
||||
|
||||
async fn close_pool_after_query<T>(
|
||||
pool: &SqlitePool,
|
||||
query_result: Result<T, sqlx::Error>,
|
||||
) -> Result<T, sqlx::Error> {
|
||||
// `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
|
||||
}
|
||||
|
||||
impl From<EtiGame> for Game {
|
||||
fn from(eti_game: EtiGame) -> Self {
|
||||
Self {
|
||||
@@ -70,3 +82,22 @@ impl From<EtiGame> for Game {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
pub mod catalog_bundle;
|
||||
pub mod catalog_publisher;
|
||||
pub mod eti;
|
||||
|
||||
Reference in New Issue
Block a user