use std::collections::{BTreeMap, HashSet}; use serde::{Deserialize, Serialize}; use super::{ CatalogContentManifest, ContentId, MAX_CATALOG_ENTRIES, path::{validate_game_id, validate_game_version}, }; /// The required compact identity index stored beside catalog manifests. /// /// The `.jsonl` suffix deliberately keeps this authority artifact outside the /// `.json` manifest-body namespace. pub const CATALOG_CONTENT_INDEX_NAME: &str = "catalog-content-index-v1.jsonl"; /// The only supported compact identity-index schema. pub const CATALOG_CONTENT_INDEX_SCHEMA_VERSION: u32 = 1; /// Maximum encoded size accepted for the compact identity index (128 MiB). pub const MAX_CATALOG_CONTENT_INDEX_BYTES: u64 = 128 * 1024 * 1024; /// Non-I/O content authority needed to join remote availability to the local /// catalog without loading a manifest body. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CatalogContentIdentity { pub content_id: ContentId, pub supports_streamed_install: bool, } impl CatalogContentIdentity { /// Derives the compact identity from a validated manifest. #[must_use] pub fn from_manifest(manifest: &CatalogContentManifest) -> Self { Self { content_id: manifest.content_id(), supports_streamed_install: manifest.supports_streamed_install(), } } } /// One exact game/version entry in the compact identity index. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CatalogContentIndexEntry { pub game_id: String, pub game_version: String, pub identity: CatalogContentIdentity, } impl CatalogContentIndexEntry { /// Builds one entry from an already validated manifest. #[must_use] pub fn from_manifest(manifest: &CatalogContentManifest) -> Self { Self { game_id: manifest.game_id().to_owned(), game_version: manifest.game_version().to_owned(), identity: CatalogContentIdentity::from_manifest(manifest), } } } /// Canonical, exact-coverage compact catalog identity authority. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CatalogContentIndex { entries: BTreeMap, } #[derive(Clone, Debug, Eq, PartialEq)] struct IndexedContent { game_version: String, identity: CatalogContentIdentity, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct RawCatalogContentIndex { schema_version: u32, games: BTreeMap, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct RawCatalogContentIndexEntry { game_version: String, content_id: ContentId, supports_streamed_install: bool, } impl CatalogContentIndex { /// Builds a validated compact index from exact entries. pub fn from_entries( entries: impl IntoIterator, ) -> eyre::Result { let mut indexed = BTreeMap::new(); let mut portable_ids = HashSet::new(); for entry in entries { validate_game_id(&entry.game_id)?; validate_game_version(&entry.game_version)?; if !portable_ids.insert(entry.game_id.to_uppercase()) { eyre::bail!( "catalog content index contains duplicate or platform-alias game ID: {}", entry.game_id ); } if indexed .insert( entry.game_id.clone(), IndexedContent { game_version: entry.game_version, identity: entry.identity, }, ) .is_some() { eyre::bail!( "catalog content index contains duplicate game ID: {}", entry.game_id ); } } if indexed.len() > MAX_CATALOG_ENTRIES { eyre::bail!("catalog content index exceeds the {MAX_CATALOG_ENTRIES}-game limit"); } Ok(Self { entries: indexed }) } /// Builds a validated compact index from sealed manifest bodies. pub fn from_manifests<'a>( manifests: impl IntoIterator, ) -> eyre::Result { let manifests = manifests.into_iter().collect::>(); for manifest in &manifests { manifest.validate()?; } Self::from_entries( manifests .into_iter() .map(CatalogContentIndexEntry::from_manifest), ) } /// Parses only the canonical, bounded JSON representation. pub fn from_json_slice(bytes: &[u8]) -> eyre::Result { if u64::try_from(bytes.len())? > MAX_CATALOG_CONTENT_INDEX_BYTES { eyre::bail!( "catalog content index exceeds the {MAX_CATALOG_CONTENT_INDEX_BYTES}-byte limit" ); } let raw: RawCatalogContentIndex = serde_json::from_slice(bytes)?; if raw.schema_version != CATALOG_CONTENT_INDEX_SCHEMA_VERSION { eyre::bail!( "unsupported catalog content index schema {}", raw.schema_version ); } let entries = raw .games .into_iter() .map(|(game_id, entry)| CatalogContentIndexEntry { game_id, game_version: entry.game_version, identity: CatalogContentIdentity { content_id: entry.content_id, supports_streamed_install: entry.supports_streamed_install, }, }) .collect::>(); let index = Self::from_entries(entries)?; if index.to_canonical_json()? != bytes { eyre::bail!("catalog content index JSON is not canonical"); } Ok(index) } /// Produces deterministic pretty JSON with exactly one trailing newline. pub fn to_canonical_json(&self) -> eyre::Result> { let games = self .entries .iter() .map(|(game_id, entry)| { ( game_id.clone(), RawCatalogContentIndexEntry { game_version: entry.game_version.clone(), content_id: entry.identity.content_id, supports_streamed_install: entry.identity.supports_streamed_install, }, ) }) .collect::>(); let mut bytes = serde_json::to_vec(&RawCatalogContentIndex { schema_version: CATALOG_CONTENT_INDEX_SCHEMA_VERSION, games, })?; if u64::try_from(bytes.len())? >= MAX_CATALOG_CONTENT_INDEX_BYTES { eyre::bail!( "canonical catalog content index exceeds the {MAX_CATALOG_CONTENT_INDEX_BYTES}-byte limit" ); } bytes.push(b'\n'); Ok(bytes) } /// Returns one expected compact identity without filesystem access. #[must_use] pub fn content_identity(&self, game_id: &str) -> Option { self.entries.get(game_id).map(|entry| entry.identity) } pub(super) fn validate_expected_versions( &self, expected_versions: &BTreeMap, ) -> eyre::Result<()> { if self.entries.len() != expected_versions.len() { eyre::bail!( "catalog content index coverage mismatch: expected {} games, found {}", expected_versions.len(), self.entries.len() ); } for (game_id, expected_version) in expected_versions { let entry = self.entries.get(game_id).ok_or_else(|| { eyre::eyre!("catalog content index is missing game ID: {game_id}") })?; if entry.game_version != *expected_version { eyre::bail!( "catalog content index version mismatch for {game_id}: expected {expected_version}, found {}", entry.game_version ); } } Ok(()) } pub(super) fn validate_manifest( &self, game_id: &str, manifest: &CatalogContentManifest, ) -> eyre::Result<()> { let expected = self .entries .get(game_id) .ok_or_else(|| eyre::eyre!("catalog content index is missing game ID: {game_id}"))?; let actual = CatalogContentIdentity::from_manifest(manifest); if actual != expected.identity { eyre::bail!( "catalog manifest identity mismatch for {game_id}: index expects {} (streamed install {}), manifest computes {} (streamed install {})", expected.identity.content_id, expected.identity.supports_streamed_install, actual.content_id, actual.supports_streamed_install, ); } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::content_manifest::{ Blake3Digest, CatalogContentManifestBody, CatalogExtractedEntry, CatalogFileEntry, }; fn manifest(game_id: &str, version: &str) -> CatalogContentManifest { let digest = Blake3Digest::hash(version.as_bytes()); CatalogContentManifest::seal( CatalogContentManifestBody::new( game_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 validate"), ], Vec::new(), ) .expect("manifest body should validate"), ) .expect("manifest should seal") } #[test] fn compact_index_has_one_canonical_encoding() { let index = CatalogContentIndex::from_entries([CatalogContentIndexEntry { game_id: "g".to_owned(), game_version: "20240101".to_owned(), identity: CatalogContentIdentity { content_id: ContentId::from_bytes([0xab; 32]), supports_streamed_install: false, }, }]) .expect("index entry should validate"); let expected = format!( "{{\"schema_version\":1,\"games\":{{\"g\":{{\"game_version\":\"20240101\",\"content_id\":\"{}\",\"supports_streamed_install\":false}}}}}}\n", "ab".repeat(32) ); let bytes = index.to_canonical_json().expect("index should encode"); assert_eq!(bytes, expected.as_bytes()); assert_eq!( CatalogContentIndex::from_json_slice(&bytes).expect("canonical index should parse"), index ); let mut noncanonical = bytes; noncanonical.insert(0, b' '); assert!(CatalogContentIndex::from_json_slice(&noncanonical).is_err()); } #[test] fn compact_index_rejects_duplicate_json_game_keys() { let entry = format!( "{{\"game_version\":\"20240101\",\"content_id\":\"{}\",\"supports_streamed_install\":false}}", "ab".repeat(32) ); let duplicate = format!("{{\"schema_version\":1,\"games\":{{\"g\":{entry},\"g\":{entry}}}}}\n"); assert!(CatalogContentIndex::from_json_slice(duplicate.as_bytes()).is_err()); } #[test] fn index_requires_exact_catalog_ids_and_versions() { let index = CatalogContentIndex::from_manifests([&manifest("g", "20240101")]) .expect("manifest-derived index should validate"); index .validate_expected_versions(&BTreeMap::from([("g".to_owned(), "20240101".to_owned())])) .expect("exact catalog should match"); assert!( index .validate_expected_versions(&BTreeMap::from([( "g".to_owned(), "20250101".to_owned(), )])) .is_err() ); assert!( index .validate_expected_versions(&BTreeMap::from([ ("g".to_owned(), "20240101".to_owned()), ("other".to_owned(), "20240101".to_owned()), ])) .is_err() ); } #[test] fn index_rejects_portable_aliases_and_manifest_identity_drift() { let expected = manifest("game", "20240101"); assert!( CatalogContentIndex::from_manifests([&expected, &manifest("GAME", "20240101"),]) .is_err() ); let index = CatalogContentIndex::from_manifests([&expected]).expect("single entry should validate"); assert!( index .validate_manifest("game", &manifest("game", "20250101")) .is_err() ); } #[test] fn index_rejects_stream_support_drift_with_the_correct_content_id() { let version = "20240101"; let version_digest = Blake3Digest::hash(version.as_bytes()); let extracted_digest = Blake3Digest::hash(b"payload"); let supported = CatalogContentManifest::seal( CatalogContentManifestBody::new( "g", version, vec![ CatalogFileEntry::file( "version.ini", u64::try_from(version.len()).expect("version length should fit u64"), version_digest, vec![version_digest], ) .expect("version.ini entry should validate"), ], vec![ CatalogExtractedEntry::file("payload.bin", 7, extracted_digest) .expect("extracted entry should validate"), ], ) .expect("manifest body should validate"), ) .expect("manifest should seal"); assert!(supported.supports_streamed_install()); let index = CatalogContentIndex::from_entries([CatalogContentIndexEntry { game_id: "g".to_owned(), game_version: version.to_owned(), identity: CatalogContentIdentity { content_id: supported.content_id(), supports_streamed_install: false, }, }]) .expect("index entry should validate"); assert!(index.validate_manifest("g", &supported).is_err()); } }