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
414 lines
14 KiB
Rust
414 lines
14 KiB
Rust
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
|
|
/// `<game_id>.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<String, IndexedContent>,
|
|
}
|
|
|
|
#[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<String, RawCatalogContentIndexEntry>,
|
|
}
|
|
|
|
#[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<Item = CatalogContentIndexEntry>,
|
|
) -> eyre::Result<Self> {
|
|
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<Item = &'a CatalogContentManifest>,
|
|
) -> eyre::Result<Self> {
|
|
let manifests = manifests.into_iter().collect::<Vec<_>>();
|
|
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<Self> {
|
|
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::<Vec<_>>();
|
|
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<Vec<u8>> {
|
|
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::<BTreeMap<_, _>>();
|
|
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<CatalogContentIdentity> {
|
|
self.entries.get(game_id).map(|entry| entry.identity)
|
|
}
|
|
|
|
pub(super) fn validate_expected_versions(
|
|
&self,
|
|
expected_versions: &BTreeMap<String, String>,
|
|
) -> 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());
|
|
}
|
|
}
|