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:
2026-08-10 13:59:18 +02:00
parent 36c4785775
commit 60fd7ba0c2
128 changed files with 51759 additions and 10784 deletions
@@ -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"
);
}
}
+34 -3
View File
@@ -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());
}
}
+2
View File
@@ -1 +1,3 @@
pub mod catalog_bundle;
pub mod catalog_publisher;
pub mod eti;