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, } 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, expected_versions: BTreeMap, ) -> eyre::Result { 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, ) -> eyre::Result { 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 { 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> { 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> { 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()); } }