feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
@@ -0,0 +1,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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user