Files
lanspread/crates/lanspread-db/src/content_manifest/index.rs
T
ddidderr 60fd7ba0c2 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
2026-08-10 13:59:18 +02:00

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());
}
}