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,431 @@
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
};
use lanspread_compat::catalog_bundle::load_catalog_bundle;
const PRODUCTION_RESOURCES: [&str; 3] = ["assets/*", "game.db", "manifests/*"];
const DEVELOPMENT_RESOURCES: [(&str, &str); 3] = [
(
"../../lanspread-peer-cli/catalogs/default/game.db",
"game.db",
),
(
"../../lanspread-peer-cli/catalogs/default/manifests/",
"manifests/",
),
("assets/*", "assets/"),
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CatalogBuildMode {
FixtureDevelopment,
Production,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct CatalogGateInput<'a> {
pub(crate) base_config: &'a str,
pub(crate) config_override: Option<&'a str>,
pub(crate) fixture_development_opt_in: bool,
pub(crate) cargo_profile: Option<&'a str>,
pub(crate) out_dir: Option<&'a Path>,
}
/// Chooses whether the build may use fixture authority or must validate the
/// production catalog corpus.
///
/// Production is the default for every invocation, including Cargo's ordinary
/// `release` profile. Fixture authority requires both the exact checked-in
/// resource map and an explicit repository-development opt-in. A custom
/// `production` profile can never be downgraded by that opt-in.
pub(crate) fn select_catalog_build_mode(
input: CatalogGateInput<'_>,
) -> Result<CatalogBuildMode, String> {
let resources = effective_resources(input.base_config, input.config_override)?;
let forced_production = input.cargo_profile == Some("production")
|| input.out_dir.is_some_and(|out_dir| {
out_dir
.components()
.any(|component| component.as_os_str() == "production")
});
if input.fixture_development_opt_in && !forced_production {
if resources != ResourceAuthority::FixtureDevelopment {
return Err(
"fixture catalog opt-in requires the exact development resource map".to_owned(),
);
}
return Ok(CatalogBuildMode::FixtureDevelopment);
}
if resources != ResourceAuthority::Production {
let reason = if forced_production {
"the production Cargo profile"
} else {
"a build without the fixture catalog opt-in"
};
return Err(format!(
"{reason} requires exactly the production catalog resources"
));
}
Ok(CatalogBuildMode::Production)
}
/// Validates the exact catalog authority shipped by a production bundle.
///
/// This deliberately uses the same coherent database loader as the
/// application runtime before eagerly validating every manifest body. The
/// runtime loader catches database identity and join ambiguity; the final
/// pass is the release-time integrity gate for the complete manifest corpus.
pub(crate) fn validate_production_catalog(
game_db: impl AsRef<Path>,
manifests_root: impl AsRef<Path>,
) -> Result<(), String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("failed to create catalog validation runtime: {error}"))?;
let catalog = runtime
.block_on(load_catalog_bundle(
game_db.as_ref(),
manifests_root.as_ref(),
))
.map_err(|error| error.to_string())?;
catalog
.bundle()
.validate_all()
.map_err(|error| error.to_string())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ResourceAuthority {
FixtureDevelopment,
Production,
}
fn effective_resources(
base_config: &str,
config_override: Option<&str>,
) -> Result<ResourceAuthority, String> {
let base = parse_config(base_config, "base Tauri config")?;
let base_resources = base
.pointer("/bundle/resources")
.ok_or_else(|| "base Tauri config does not declare bundle.resources".to_owned())?;
if let Some(raw_override) = config_override {
let config_override = parse_config(raw_override, "TAURI_CONFIG override")?;
if let Some(resources) = config_override.pointer("/bundle/resources") {
return classify_resources(resources);
}
}
classify_resources(base_resources)
}
fn parse_config(raw: &str, label: &str) -> Result<serde_json::Value, String> {
serde_json::from_str(raw).map_err(|error| format!("failed to parse {label}: {error}"))
}
fn classify_resources(resources: &serde_json::Value) -> Result<ResourceAuthority, String> {
if let Some(resources) = resources.as_array() {
let actual = resources
.iter()
.map(|resource| {
resource
.as_str()
.ok_or_else(|| "production resource entries must be strings".to_owned())
})
.collect::<Result<Vec<_>, _>>()?;
let unique = actual.iter().copied().collect::<BTreeSet<_>>();
let expected = PRODUCTION_RESOURCES.into_iter().collect::<BTreeSet<_>>();
if actual.len() == PRODUCTION_RESOURCES.len() && unique == expected {
return Ok(ResourceAuthority::Production);
}
return Err(format!(
"production resource list must be exactly {expected:?}, got {actual:?}"
));
}
if let Some(resources) = resources.as_object() {
let actual = resources
.iter()
.map(|(source, destination)| {
destination
.as_str()
.map(|destination| (source.as_str(), destination))
.ok_or_else(|| "development resource destinations must be strings".to_owned())
})
.collect::<Result<BTreeMap<_, _>, _>>()?;
let expected = DEVELOPMENT_RESOURCES
.into_iter()
.collect::<BTreeMap<_, _>>();
if actual == expected {
return Ok(ResourceAuthority::FixtureDevelopment);
}
return Err(format!(
"development resource map must be exactly {expected:?}, got {actual:?}"
));
}
Err("bundle.resources must be an exact production list or development map".to_owned())
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use super::*;
static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const PRODUCTION: &str = r#"{
"bundle": {"resources": ["game.db", "manifests/*", "assets/*"]}
}"#;
const DEVELOPMENT: &str = r#"{
"bundle": {"resources": {
"../../lanspread-peer-cli/catalogs/default/game.db": "game.db",
"../../lanspread-peer-cli/catalogs/default/manifests/": "manifests/",
"assets/*": "assets/"
}}
}"#;
fn input(
config_override: Option<&str>,
fixture_development_opt_in: bool,
) -> CatalogGateInput<'_> {
CatalogGateInput {
base_config: PRODUCTION,
config_override,
fixture_development_opt_in,
cargo_profile: Some("release"),
out_dir: Some(Path::new("target/release/build/app/out")),
}
}
#[test]
fn ordinary_release_and_production_overrides_are_production_gated() {
assert_eq!(
select_catalog_build_mode(input(None, false)),
Ok(CatalogBuildMode::Production)
);
assert_eq!(
select_catalog_build_mode(input(Some(PRODUCTION), false)),
Ok(CatalogBuildMode::Production)
);
assert_eq!(
select_catalog_build_mode(input(Some(r#"{"build": {}}"#), false)),
Ok(CatalogBuildMode::Production)
);
}
#[test]
fn exact_development_map_requires_the_explicit_opt_in() {
assert_eq!(
select_catalog_build_mode(input(Some(DEVELOPMENT), true)),
Ok(CatalogBuildMode::FixtureDevelopment)
);
assert!(select_catalog_build_mode(input(Some(DEVELOPMENT), false)).is_err());
assert!(select_catalog_build_mode(input(None, true)).is_err());
}
#[test]
fn production_profile_cannot_be_downgraded_to_fixture_authority() {
for mut input in [
CatalogGateInput {
cargo_profile: Some("production"),
..input(Some(DEVELOPMENT), true)
},
CatalogGateInput {
out_dir: Some(Path::new("target/production/build/app/out")),
..input(Some(DEVELOPMENT), true)
},
] {
assert!(select_catalog_build_mode(input).is_err());
input.config_override = Some(PRODUCTION);
assert_eq!(
select_catalog_build_mode(input),
Ok(CatalogBuildMode::Production)
);
}
}
#[test]
fn incomplete_duplicated_or_unknown_resource_shapes_fail_closed() {
for config in [
r#"{"bundle":{"resources":["game.db","manifests/*"]}}"#,
r#"{"bundle":{"resources":["game.db","manifests/*","assets/*","assets/*"]}}"#,
r#"{"bundle":{"resources":{"fixture.db":"game.db"}}}"#,
r#"{"bundle":{"resources":true}}"#,
"not JSON",
] {
assert!(
select_catalog_build_mode(input(Some(config), false)).is_err(),
"accepted resource config: {config}"
);
}
}
#[test]
fn invalid_base_resources_fail_even_with_an_unrelated_override() {
let mut input = input(Some(r#"{"build": {}}"#), false);
input.base_config = r#"{"bundle":{"resources":["game.db"]}}"#;
assert!(select_catalog_build_mode(input).is_err());
}
#[derive(Clone, Copy)]
enum DatabaseCorruption {
DuplicateDbId,
MissingGenre,
DuplicateGenre,
}
impl DatabaseCorruption {
const fn expected_error(self) -> &'static str {
match self {
Self::DuplicateDbId => "duplicate raw game db_id",
Self::MissingGenre => "missing genre join expansion",
Self::DuplicateGenre => "duplicate genre join expansion",
}
}
}
#[test]
fn production_gate_rejects_malformed_runtime_database_authority() {
for corruption in [
DatabaseCorruption::DuplicateDbId,
DatabaseCorruption::MissingGenre,
DatabaseCorruption::DuplicateGenre,
] {
let fixture = MalformedCatalogFixture::new(corruption);
let error = validate_production_catalog(&fixture.game_db, &fixture.manifests)
.expect_err("the production gate must use the strict application loader");
assert!(
error.contains(corruption.expected_error()),
"unexpected error for {}: {error}",
corruption.expected_error()
);
}
}
struct MalformedCatalogFixture {
root: PathBuf,
game_db: PathBuf,
manifests: PathBuf,
}
impl MalformedCatalogFixture {
fn new(corruption: DatabaseCorruption) -> Self {
let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should follow the epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"lanspread-tauri-catalog-gate-{}-{nanos}-{sequence}",
std::process::id()
));
let game_db = root.join("game.db");
let manifests = root.join("manifests");
fs::create_dir(&root).expect("test root should be created");
fs::create_dir(&manifests).expect("manifest root should be created");
create_malformed_database(&game_db, corruption);
for game_id in ["g", "h"] {
fs::write(manifests.join(format!("{game_id}.json")), b"not parsed\n")
.expect("placeholder manifest should be created");
}
Self {
root,
game_db,
manifests,
}
}
}
impl Drop for MalformedCatalogFixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn create_malformed_database(path: &Path, corruption: DatabaseCorruption) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should be created");
runtime.block_on(async {
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");
let result = async {
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?;
sqlx::query(
"CREATE TABLE genre (genre_id INTEGER NOT NULL, genre_de TEXT NOT NULL)",
)
.execute(&pool)
.await?;
if !matches!(corruption, DatabaseCorruption::MissingGenre) {
sqlx::query("INSERT INTO genre VALUES (10, 'Strategy')")
.execute(&pool)
.await?;
}
if matches!(corruption, DatabaseCorruption::DuplicateGenre) {
sqlx::query("INSERT INTO genre VALUES (10, 'Duplicate')")
.execute(&pool)
.await?;
}
insert_game(&pool, 1, "g").await?;
if matches!(corruption, DatabaseCorruption::DuplicateDbId) {
insert_game(&pool, 1, "h").await?;
}
Ok::<(), sqlx::Error>(())
}
.await;
pool.close().await;
result.expect("malformed fixture database should be written");
});
}
async fn insert_game(
pool: &sqlx::SqlitePool,
db_id: i64,
game_id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO games VALUES (
?, ?, 'Game', 'key', '2024', 'publisher', 1.0,
'de', 'en', 'fr', 4, 0, 10, '20240101'
)",
)
.bind(game_id)
.bind(db_id)
.execute(pool)
.await?;
Ok(())
}
}