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:
ddidderr committed 2026-08-10 13:59:18 +02:00
1 parent 36c4785775
commit 60fd7ba0c2
128 files changed
+51759 -10784

No files matched your search

@@ -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());
}
}