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
335 lines
11 KiB
Rust
335 lines
11 KiB
Rust
use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
|
|
|
|
use super::{
|
|
CatalogContentIdentity,
|
|
CatalogContentManifest,
|
|
CatalogManifestStore,
|
|
reject_incomplete_catalog_publication,
|
|
};
|
|
use crate::db::GameCatalog;
|
|
|
|
/// Immutable catalog authority pairing exact game versions with their
|
|
/// on-demand content manifests.
|
|
#[derive(Debug)]
|
|
pub struct CatalogBundle {
|
|
catalog: GameCatalog,
|
|
manifests: CatalogManifestStore,
|
|
manifests_root: Option<PathBuf>,
|
|
}
|
|
|
|
impl CatalogBundle {
|
|
/// Constructs one exact catalog authority and validates artifact coverage
|
|
/// without eagerly parsing manifest bodies.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error for invalid catalog identities, a non-regular manifest
|
|
/// root, or missing, unexpected, linked, or non-file manifest artifacts.
|
|
pub fn new(
|
|
manifests_root: impl Into<PathBuf>,
|
|
expected_versions: BTreeMap<String, String>,
|
|
) -> eyre::Result<Self> {
|
|
let manifests_root = manifests_root.into();
|
|
reject_incomplete_catalog_publication(&manifests_root)?;
|
|
let manifests = CatalogManifestStore::new(&manifests_root, expected_versions.clone())?;
|
|
manifests.validate_coverage()?;
|
|
reject_incomplete_catalog_publication(&manifests_root)?;
|
|
|
|
let mut catalog = GameCatalog::empty();
|
|
for (game_id, version) in expected_versions {
|
|
catalog.insert(game_id, Some(version));
|
|
}
|
|
Ok(Self {
|
|
catalog,
|
|
manifests,
|
|
manifests_root: Some(manifests_root),
|
|
})
|
|
}
|
|
|
|
/// Constructs an immutable authority from a complete in-memory manifest
|
|
/// set.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if any sealed manifest is invalid, if game IDs collide
|
|
/// exactly or under portable case-folding, or if catalog limits are
|
|
/// exceeded.
|
|
pub fn from_manifests(
|
|
manifests: impl IntoIterator<Item = CatalogContentManifest>,
|
|
) -> eyre::Result<Self> {
|
|
let manifests = CatalogManifestStore::from_manifests(manifests)?;
|
|
let mut catalog = GameCatalog::empty();
|
|
for (game_id, version) in manifests.expected_versions() {
|
|
catalog.insert(game_id.clone(), Some(version.clone()));
|
|
}
|
|
Ok(Self {
|
|
catalog,
|
|
manifests,
|
|
manifests_root: None,
|
|
})
|
|
}
|
|
|
|
/// Returns the exact ID/version catalog used by peer policy.
|
|
#[must_use]
|
|
pub const fn catalog(&self) -> &GameCatalog {
|
|
&self.catalog
|
|
}
|
|
|
|
/// Returns one catalog-owned content identity without filesystem access.
|
|
#[must_use]
|
|
pub fn content_identity(&self, game_id: &str) -> Option<CatalogContentIdentity> {
|
|
self.manifests.content_identity(game_id)
|
|
}
|
|
|
|
/// Loads and fully validates one manifest on demand.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the ID is unknown or its artifact is invalid.
|
|
pub fn manifest(&self, game_id: &str) -> eyre::Result<Arc<CatalogContentManifest>> {
|
|
self.reject_incomplete_disk_publication()?;
|
|
let result = self.manifests.load(game_id);
|
|
self.reject_incomplete_disk_publication()?;
|
|
result
|
|
}
|
|
|
|
/// Returns a previously validated manifest without performing filesystem
|
|
/// I/O or parsing.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error for an unknown or not-yet-loaded manifest.
|
|
pub fn cached_manifest(&self, game_id: &str) -> eyre::Result<Arc<CatalogContentManifest>> {
|
|
self.manifests.load_cached(game_id)
|
|
}
|
|
|
|
/// Eagerly validates exact coverage and every body against the compact
|
|
/// index, rejecting an overlapping or interrupted disk publication.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error for a publication marker, invalid coverage, an invalid
|
|
/// body, or an index/body identity mismatch.
|
|
pub fn validate_all(&self) -> eyre::Result<()> {
|
|
self.reject_incomplete_disk_publication()?;
|
|
let result = self.manifests.validate_all();
|
|
self.reject_incomplete_disk_publication()?;
|
|
result
|
|
}
|
|
|
|
fn reject_incomplete_disk_publication(&self) -> eyre::Result<()> {
|
|
if let Some(root) = &self.manifests_root {
|
|
reject_incomplete_catalog_publication(root)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
fs,
|
|
path::PathBuf,
|
|
sync::atomic::{AtomicU64, Ordering},
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use super::*;
|
|
use crate::content_manifest::{
|
|
Blake3Digest,
|
|
CATALOG_CONTENT_INDEX_NAME,
|
|
CatalogContentIndex,
|
|
CatalogContentManifestBody,
|
|
CatalogFileEntry,
|
|
write_canonical_content_index_atomic,
|
|
write_canonical_manifest_atomic,
|
|
};
|
|
|
|
fn manifest(id: &str) -> CatalogContentManifest {
|
|
let version = "20240101";
|
|
let digest = Blake3Digest::hash(version.as_bytes());
|
|
CatalogContentManifest::seal(
|
|
CatalogContentManifestBody::new(
|
|
id,
|
|
version,
|
|
vec![
|
|
CatalogFileEntry::file(
|
|
"version.ini",
|
|
u64::try_from(version.len()).expect("version length should fit u64"),
|
|
digest,
|
|
vec![digest],
|
|
)
|
|
.expect("version.ini entry should be valid"),
|
|
],
|
|
Vec::new(),
|
|
)
|
|
.expect("test body should be valid"),
|
|
)
|
|
.expect("test manifest should seal")
|
|
}
|
|
|
|
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-authority-{}-{nanos}-{sequence}",
|
|
std::process::id()
|
|
));
|
|
fs::create_dir(&path).expect("test directory should be created");
|
|
Self(path)
|
|
}
|
|
}
|
|
|
|
impl Drop for TestDir {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_pairs_exact_versions_without_eager_manifest_parsing() {
|
|
let root = TestDir::new();
|
|
let valid_manifest = manifest("g");
|
|
let index = CatalogContentIndex::from_manifests([&valid_manifest])
|
|
.expect("test index should validate");
|
|
write_canonical_content_index_atomic(&root.0.join(CATALOG_CONTENT_INDEX_NAME), &index)
|
|
.expect("test index should publish");
|
|
fs::write(root.0.join("g.json"), b"not JSON\n").expect("opaque artifact should write");
|
|
|
|
let bundle = CatalogBundle::new(
|
|
&root.0,
|
|
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
|
|
)
|
|
.expect("bundle construction should only validate coverage");
|
|
|
|
assert!(bundle.catalog().contains("g"));
|
|
assert_eq!(bundle.catalog().expected_version("g"), Some("20240101"));
|
|
assert_eq!(
|
|
bundle.content_identity("g"),
|
|
Some(CatalogContentIdentity::from_manifest(&valid_manifest))
|
|
);
|
|
assert!(bundle.cached_manifest("g").is_err());
|
|
assert!(bundle.manifest("g").is_err());
|
|
assert!(bundle.cached_manifest("g").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_rejects_missing_or_non_directory_manifest_root() {
|
|
let root = TestDir::new();
|
|
let expected = BTreeMap::from([("g".to_owned(), "20240101".to_owned())]);
|
|
assert!(CatalogBundle::new(root.0.join("missing"), expected.clone()).is_err());
|
|
|
|
let file = root.0.join("file");
|
|
fs::write(&file, b"not a directory\n").expect("file should write");
|
|
assert!(CatalogBundle::new(file, expected).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_rejects_incomplete_publication_without_parsing_manifests() {
|
|
let root = TestDir::new();
|
|
fs::write(root.0.join("g.json"), b"not JSON\n").expect("opaque artifact should write");
|
|
fs::write(
|
|
root.0.join(super::super::CATALOG_PUBLICATION_MARKER_NAME),
|
|
b"lanspread catalog publication v1\n",
|
|
)
|
|
.expect("publication marker should write");
|
|
|
|
let error = CatalogBundle::new(
|
|
&root.0,
|
|
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
|
|
)
|
|
.expect_err("runtime authority must reject an interrupted publication");
|
|
|
|
assert!(error.to_string().contains("publication is incomplete"));
|
|
}
|
|
|
|
#[test]
|
|
fn disk_bundle_rechecks_publication_marker_without_invalidating_cached_snapshot() {
|
|
let root = TestDir::new();
|
|
let valid_manifest = manifest("g");
|
|
write_canonical_manifest_atomic(&root.0.join("g.json"), &valid_manifest)
|
|
.expect("test manifest should publish");
|
|
let index = CatalogContentIndex::from_manifests([&valid_manifest])
|
|
.expect("test index should validate");
|
|
write_canonical_content_index_atomic(&root.0.join(CATALOG_CONTENT_INDEX_NAME), &index)
|
|
.expect("test index should publish");
|
|
let bundle = CatalogBundle::new(
|
|
&root.0,
|
|
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
|
|
)
|
|
.expect("complete disk bundle should construct");
|
|
let loaded = bundle.manifest("g").expect("manifest should preload");
|
|
|
|
fs::write(
|
|
root.0.join(super::super::CATALOG_PUBLICATION_MARKER_NAME),
|
|
b"lanspread catalog publication v1\n",
|
|
)
|
|
.expect("publication marker should write");
|
|
|
|
assert!(bundle.manifest("g").is_err());
|
|
assert!(bundle.validate_all().is_err());
|
|
assert!(Arc::ptr_eq(
|
|
&loaded,
|
|
&bundle
|
|
.cached_manifest("g")
|
|
.expect("explicit cache-only snapshot should remain immutable")
|
|
));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn bundle_rejects_symlink_manifest_root() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let root = TestDir::new();
|
|
let real = root.0.join("real");
|
|
fs::create_dir(&real).expect("real root should be created");
|
|
fs::write(real.join("g.json"), b"opaque\n").expect("artifact should write");
|
|
let linked = root.0.join("linked");
|
|
symlink(&real, &linked).expect("root symlink should be created");
|
|
|
|
assert!(
|
|
CatalogBundle::new(
|
|
linked,
|
|
BTreeMap::from([("g".to_owned(), "20240101".to_owned())])
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn in_memory_bundle_is_exact_and_loadable() {
|
|
let manifest = manifest("g");
|
|
|
|
let bundle = CatalogBundle::from_manifests([manifest.clone()])
|
|
.expect("complete in-memory authority should load");
|
|
|
|
assert_eq!(bundle.catalog().expected_version("g"), Some("20240101"));
|
|
assert_eq!(
|
|
bundle.content_identity("g"),
|
|
Some(CatalogContentIdentity::from_manifest(&manifest))
|
|
);
|
|
assert_eq!(
|
|
bundle
|
|
.manifest("g")
|
|
.expect("known in-memory manifest should load")
|
|
.as_ref(),
|
|
&manifest
|
|
);
|
|
assert!(bundle.manifest("missing").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn in_memory_bundle_rejects_portable_aliases() {
|
|
assert!(CatalogBundle::from_manifests([manifest("game"), manifest("GAME")]).is_err());
|
|
}
|
|
}
|