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
This commit is contained in:
2026-08-10 13:59:18 +02:00
parent 36c4785775
commit 60fd7ba0c2
128 changed files with 51759 additions and 10784 deletions
+2
View File
@@ -7,10 +7,12 @@ edition = "2024"
doctest = false
[dependencies]
blake3 = { workspace = true }
eyre = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
unicode-normalization = { workspace = true }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
@@ -0,0 +1,334 @@
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());
}
}
@@ -0,0 +1,165 @@
use std::{fmt, str::FromStr};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
const DIGEST_BYTES: usize = 32;
const DIGEST_HEX_BYTES: usize = DIGEST_BYTES * 2;
macro_rules! digest_type {
($name:ident, $description:literal) => {
#[doc = $description]
#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct $name([u8; DIGEST_BYTES]);
impl $name {
/// Constructs a value from its exact binary representation.
#[must_use]
pub const fn from_bytes(bytes: [u8; DIGEST_BYTES]) -> Self {
Self(bytes)
}
/// Returns the exact binary representation.
#[must_use]
pub const fn as_bytes(&self) -> &[u8; DIGEST_BYTES] {
&self.0
}
/// Consumes the value and returns its exact binary representation.
#[must_use]
pub const fn into_bytes(self) -> [u8; DIGEST_BYTES] {
self.0
}
}
impl fmt::Debug for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, formatter)
}
}
impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in self.0 {
write!(formatter, "{byte:02x}")?;
}
Ok(())
}
}
impl FromStr for $name {
type Err = eyre::Report;
fn from_str(value: &str) -> Result<Self, Self::Err> {
parse_lower_hex(value).map(Self)
}
}
impl Serialize for $name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(de::Error::custom)
}
}
};
}
digest_type!(
Blake3Digest,
"A BLAKE3 byte digest stored as 64 lowercase hex characters in JSON."
);
digest_type!(
ContentId,
"The catalog-owned identity of one complete content manifest."
);
impl Blake3Digest {
/// Hashes one byte slice.
#[must_use]
pub fn hash(bytes: &[u8]) -> Self {
Self(*blake3::hash(bytes).as_bytes())
}
}
fn parse_lower_hex(value: &str) -> eyre::Result<[u8; DIGEST_BYTES]> {
let encoded = value.as_bytes();
if encoded.len() != DIGEST_HEX_BYTES {
eyre::bail!(
"digest must contain exactly {DIGEST_HEX_BYTES} lowercase hexadecimal characters"
);
}
let mut decoded = [0_u8; DIGEST_BYTES];
for (output, pair) in decoded.iter_mut().zip(encoded.chunks_exact(2)) {
*output = (decode_nibble(pair[0])? << 4) | decode_nibble(pair[1])?;
}
Ok(decoded)
}
fn decode_nibble(value: u8) -> eyre::Result<u8> {
match value {
b'0'..=b'9' => Ok(value - b'0'),
b'a'..=b'f' => Ok(value - b'a' + 10),
_ => eyre::bail!("digest must use lowercase hexadecimal characters"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn digest_json_is_exact_lowercase_hex() {
let digest = Blake3Digest::from_bytes([0xab; 32]);
let encoded = serde_json::to_string(&digest).expect("digest should serialize");
assert_eq!(encoded, format!("\"{}\"", "ab".repeat(32)));
assert_eq!(
serde_json::from_str::<Blake3Digest>(&encoded).expect("digest should deserialize"),
digest
);
}
#[test]
fn digest_json_rejects_noncanonical_hex() {
for encoded in [
format!("\"{}\"", "AB".repeat(32)),
format!("\"{}\"", "a".repeat(63)),
format!("\"{}g\"", "a".repeat(63)),
] {
assert!(serde_json::from_str::<Blake3Digest>(&encoded).is_err());
}
}
#[test]
fn digest_types_deserialize_from_owned_json_values() {
let encoded = "ab".repeat(32);
assert_eq!(
serde_json::from_value::<Blake3Digest>(serde_json::Value::String(encoded.clone()))
.expect("owned digest string should deserialize"),
Blake3Digest::from_bytes([0xab; 32])
);
assert_eq!(
serde_json::from_value::<ContentId>(serde_json::Value::String(encoded))
.expect("owned content-ID string should deserialize"),
ContentId::from_bytes([0xab; 32])
);
}
#[test]
fn digest_and_content_id_are_distinct_types() {
let digest = Blake3Digest::from_bytes([7; 32]);
let content_id = ContentId::from_bytes(digest.into_bytes());
assert_eq!(content_id.to_string(), digest.to_string());
}
}
@@ -0,0 +1,161 @@
use super::{
CatalogContentManifestBody,
CatalogEntryKind,
CatalogExtractedEntry,
CatalogFileEntry,
ContentId,
};
const CONTENT_ID_DOMAIN: &[u8] = b"lanspread/catalog-content-manifest/content-id";
trait TranscriptSink {
fn put(&mut self, bytes: &[u8]);
}
impl TranscriptSink for blake3::Hasher {
fn put(&mut self, bytes: &[u8]) {
self.update(bytes);
}
}
impl TranscriptSink for Vec<u8> {
fn put(&mut self, bytes: &[u8]) {
self.extend_from_slice(bytes);
}
}
pub(super) fn compute_content_id(body: &CatalogContentManifestBody) -> eyre::Result<ContentId> {
compute_content_id_fields(
body.schema_version,
&body.game_id,
&body.game_version,
body.chunk_size,
&body.files,
&body.streamed_install_files,
)
}
pub(super) fn compute_content_id_fields(
schema_version: u32,
game_id: &str,
game_version: &str,
chunk_size: u64,
files: &[CatalogFileEntry],
streamed_install_files: &[CatalogExtractedEntry],
) -> eyre::Result<ContentId> {
let mut hasher = blake3::Hasher::new();
encode_fields(
schema_version,
game_id,
game_version,
chunk_size,
files,
streamed_install_files,
&mut hasher,
)?;
Ok(ContentId::from_bytes(*hasher.finalize().as_bytes()))
}
fn encode_fields(
schema_version: u32,
game_id: &str,
game_version: &str,
chunk_size: u64,
files: &[CatalogFileEntry],
streamed_install_files: &[CatalogExtractedEntry],
sink: &mut impl TranscriptSink,
) -> eyre::Result<()> {
put_bytes(sink, CONTENT_ID_DOMAIN)?;
put_u32(sink, schema_version);
put_bytes(sink, game_id.as_bytes())?;
put_bytes(sink, game_version.as_bytes())?;
put_u64(sink, chunk_size);
put_len(sink, files.len())?;
for entry in files {
encode_file_entry(sink, entry)?;
}
put_len(sink, streamed_install_files.len())?;
for entry in streamed_install_files {
encode_extracted_entry(sink, entry)?;
}
Ok(())
}
fn encode_file_entry(sink: &mut impl TranscriptSink, entry: &CatalogFileEntry) -> eyre::Result<()> {
put_bytes(sink, entry.canonical_path.as_str().as_bytes())?;
put_kind(sink, entry.kind);
put_u64(sink, entry.size);
put_optional_digest(sink, entry.file_blake3.as_ref())?;
put_len(sink, entry.chunk_blake3.len())?;
for digest in &entry.chunk_blake3 {
put_bytes(sink, digest.as_bytes())?;
}
Ok(())
}
fn encode_extracted_entry(
sink: &mut impl TranscriptSink,
entry: &CatalogExtractedEntry,
) -> eyre::Result<()> {
put_bytes(sink, entry.canonical_path.as_str().as_bytes())?;
put_kind(sink, entry.kind);
put_u64(sink, entry.size);
put_optional_digest(sink, entry.file_blake3.as_ref())
}
fn put_kind(sink: &mut impl TranscriptSink, kind: CatalogEntryKind) {
sink.put(&[match kind {
CatalogEntryKind::Directory => 0,
CatalogEntryKind::File => 1,
}]);
}
fn put_optional_digest(
sink: &mut impl TranscriptSink,
digest: Option<&super::Blake3Digest>,
) -> eyre::Result<()> {
match digest {
None => sink.put(&[0]),
Some(digest) => {
sink.put(&[1]);
put_bytes(sink, digest.as_bytes())?;
}
}
Ok(())
}
fn put_bytes(sink: &mut impl TranscriptSink, bytes: &[u8]) -> eyre::Result<()> {
put_len(sink, bytes.len())?;
sink.put(bytes);
Ok(())
}
fn put_len(sink: &mut impl TranscriptSink, len: usize) -> eyre::Result<()> {
put_u64(sink, u64::try_from(len)?);
Ok(())
}
fn put_u32(sink: &mut impl TranscriptSink, value: u32) {
sink.put(&value.to_be_bytes());
}
fn put_u64(sink: &mut impl TranscriptSink, value: u64) {
sink.put(&value.to_be_bytes());
}
#[cfg(test)]
pub(super) fn content_id_transcript(body: &CatalogContentManifestBody) -> eyre::Result<Vec<u8>> {
let mut transcript = Vec::new();
encode_fields(
body.schema_version,
&body.game_id,
&body.game_version,
body.chunk_size,
&body.files,
&body.streamed_install_files,
&mut transcript,
)?;
Ok(transcript)
}
@@ -0,0 +1,413 @@
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());
}
}
@@ -0,0 +1,51 @@
//! Trusted catalog content manifests.
//!
//! Manifest JSON is a reproducible transport for catalog-publisher output. The
//! content identity is derived from the versioned binary transcript in
//! [`encoding`], never from JSON formatting. Sealed manifests deliberately do
//! not implement [`serde::Deserialize`]; untrusted bytes must pass through the
//! bounded canonical loader [`CatalogContentManifest::from_json_slice`].
#![allow(clippy::missing_errors_doc)]
mod bundle;
mod digest;
mod encoding;
mod index;
mod model;
mod path;
mod store;
pub use bundle::CatalogBundle;
pub use digest::{Blake3Digest, ContentId};
pub use index::{
CATALOG_CONTENT_INDEX_NAME,
CATALOG_CONTENT_INDEX_SCHEMA_VERSION,
CatalogContentIdentity,
CatalogContentIndex,
CatalogContentIndexEntry,
MAX_CATALOG_CONTENT_INDEX_BYTES,
};
pub use model::{
CATALOG_CHUNK_SIZE,
CATALOG_CONTENT_MANIFEST_SCHEMA_VERSION,
CatalogContentManifest,
CatalogContentManifestBody,
CatalogEntryKind,
CatalogExtractedEntry,
CatalogFileEntry,
MAX_CATALOG_COMPONENT_BYTES,
MAX_CATALOG_ENTRIES,
MAX_CATALOG_FILE_BYTES,
MAX_CATALOG_MANIFEST_BYTES,
MAX_CATALOG_PATH_BYTES,
MAX_CATALOG_TOTAL_BYTES,
};
pub use path::CanonicalCatalogPath;
pub use store::{
CATALOG_PUBLICATION_MARKER_NAME,
CatalogManifestStore,
reject_incomplete_catalog_publication,
write_canonical_content_index_atomic,
write_canonical_manifest_atomic,
};
@@ -0,0 +1,1174 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::{
CanonicalCatalogPath,
ContentId,
digest::Blake3Digest,
encoding::{compute_content_id, compute_content_id_fields},
path::{
is_download_protected_root_name,
is_stream_install_protected_root_name,
validate_game_id,
validate_game_version,
},
};
/// The only supported content-manifest schema.
pub const CATALOG_CONTENT_MANIFEST_SCHEMA_VERSION: u32 = 1;
/// The fixed ordinary-transfer chunk size (128 MiB).
pub const CATALOG_CHUNK_SIZE: u64 = 128 * 1024 * 1024;
/// Maximum encoded JSON size accepted by the strict loader.
pub const MAX_CATALOG_MANIFEST_BYTES: u64 = 128 * 1024 * 1024;
/// Maximum number of entries in either manifest entry list.
pub const MAX_CATALOG_ENTRIES: usize = 100_000;
/// Maximum size of one ordinary or extracted file (1 TiB).
pub const MAX_CATALOG_FILE_BYTES: u64 = 1024 * 1024 * 1024 * 1024;
/// Maximum sum of file sizes in either entry list (16 TiB).
pub const MAX_CATALOG_TOTAL_BYTES: u64 = 16 * MAX_CATALOG_FILE_BYTES;
/// Maximum encoded byte length of one root-relative path.
pub const MAX_CATALOG_PATH_BYTES: usize = 900;
/// Maximum encoded byte length of one path component.
pub const MAX_CATALOG_COMPONENT_BYTES: usize = 255;
const MAX_VERSION_INI_BYTES: u64 = 64 * 1024;
const MAX_CATALOG_CHUNK_DIGESTS: usize = 2_000_000;
const VERSION_INI: &str = "version.ini";
/// The filesystem shape of one catalog entry.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CatalogEntryKind {
Directory,
File,
}
/// One ordinary downloadable entry below the game root.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct CatalogFileEntry {
pub(super) canonical_path: CanonicalCatalogPath,
pub(super) kind: CatalogEntryKind,
pub(super) size: u64,
pub(super) file_blake3: Option<Blake3Digest>,
pub(super) chunk_blake3: Vec<Blake3Digest>,
}
impl CatalogFileEntry {
/// Constructs an explicit directory entry.
pub fn directory(path: impl Into<String>) -> eyre::Result<Self> {
Ok(Self {
canonical_path: CanonicalCatalogPath::new(path)?,
kind: CatalogEntryKind::Directory,
size: 0,
file_blake3: None,
chunk_blake3: Vec::new(),
})
}
/// Constructs a regular file entry and validates its hash shape.
pub fn file(
path: impl Into<String>,
size: u64,
file_blake3: Blake3Digest,
chunk_blake3: Vec<Blake3Digest>,
) -> eyre::Result<Self> {
let entry = Self {
canonical_path: CanonicalCatalogPath::new(path)?,
kind: CatalogEntryKind::File,
size,
file_blake3: Some(file_blake3),
chunk_blake3,
};
validate_ordinary_entry(&entry)?;
Ok(entry)
}
#[must_use]
pub fn canonical_path(&self) -> &CanonicalCatalogPath {
&self.canonical_path
}
#[must_use]
pub const fn kind(&self) -> CatalogEntryKind {
self.kind
}
#[must_use]
pub const fn size(&self) -> u64 {
self.size
}
#[must_use]
pub const fn file_blake3(&self) -> Option<Blake3Digest> {
self.file_blake3
}
#[must_use]
pub fn chunk_blake3(&self) -> &[Blake3Digest] {
&self.chunk_blake3
}
}
/// One catalog-owned final output entry for Stream Install.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct CatalogExtractedEntry {
pub(super) canonical_path: CanonicalCatalogPath,
pub(super) kind: CatalogEntryKind,
pub(super) size: u64,
pub(super) file_blake3: Option<Blake3Digest>,
}
impl CatalogExtractedEntry {
/// Constructs an optional extracted directory entry.
pub fn directory(path: impl Into<String>) -> eyre::Result<Self> {
Ok(Self {
canonical_path: CanonicalCatalogPath::new(path)?,
kind: CatalogEntryKind::Directory,
size: 0,
file_blake3: None,
})
}
/// Constructs an extracted regular file entry.
pub fn file(
path: impl Into<String>,
size: u64,
file_blake3: Blake3Digest,
) -> eyre::Result<Self> {
let entry = Self {
canonical_path: CanonicalCatalogPath::new(path)?,
kind: CatalogEntryKind::File,
size,
file_blake3: Some(file_blake3),
};
validate_extracted_entry(&entry)?;
Ok(entry)
}
#[must_use]
pub fn canonical_path(&self) -> &CanonicalCatalogPath {
&self.canonical_path
}
#[must_use]
pub const fn kind(&self) -> CatalogEntryKind {
self.kind
}
#[must_use]
pub const fn size(&self) -> u64 {
self.size
}
#[must_use]
pub const fn file_blake3(&self) -> Option<Blake3Digest> {
self.file_blake3
}
}
/// The content-ID-bearing portion of a manifest before it is sealed.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CatalogContentManifestBody {
pub(super) schema_version: u32,
pub(super) game_id: String,
pub(super) game_version: String,
pub(super) chunk_size: u64,
pub(super) files: Vec<CatalogFileEntry>,
pub(super) streamed_install_files: Vec<CatalogExtractedEntry>,
}
impl CatalogContentManifestBody {
/// Constructs and fully validates a schema-1 manifest body.
pub fn new(
game_id: impl Into<String>,
game_version: impl Into<String>,
files: Vec<CatalogFileEntry>,
streamed_install_files: Vec<CatalogExtractedEntry>,
) -> eyre::Result<Self> {
let body = Self {
schema_version: CATALOG_CONTENT_MANIFEST_SCHEMA_VERSION,
game_id: game_id.into(),
game_version: game_version.into(),
chunk_size: CATALOG_CHUNK_SIZE,
files,
streamed_install_files,
};
body.validate()?;
Ok(body)
}
fn validate(&self) -> eyre::Result<()> {
validate_header(
self.schema_version,
&self.game_id,
&self.game_version,
self.chunk_size,
)?;
validate_ordinary_entries(&self.files)?;
validate_extracted_entries(&self.streamed_install_files)
}
#[must_use]
pub fn game_id(&self) -> &str {
&self.game_id
}
#[must_use]
pub fn game_version(&self) -> &str {
&self.game_version
}
#[must_use]
pub fn files(&self) -> &[CatalogFileEntry] {
&self.files
}
/// Finds one ordinary entry by its exact canonical path.
#[must_use]
pub fn file_entry(&self, canonical_path: &str) -> Option<&CatalogFileEntry> {
self.files
.binary_search_by(|entry| entry.canonical_path.as_str().cmp(canonical_path))
.ok()
.map(|index| &self.files[index])
}
#[must_use]
pub fn streamed_install_files(&self) -> &[CatalogExtractedEntry] {
&self.streamed_install_files
}
/// Finds one extracted entry by its exact canonical path.
#[must_use]
pub fn streamed_install_entry(&self, canonical_path: &str) -> Option<&CatalogExtractedEntry> {
self.streamed_install_files
.binary_search_by(|entry| entry.canonical_path.as_str().cmp(canonical_path))
.ok()
.map(|index| &self.streamed_install_files[index])
}
/// Returns whether this game has catalog-owned Stream Install output.
#[must_use]
pub fn supports_streamed_install(&self) -> bool {
!self.streamed_install_files.is_empty()
}
}
/// A validated catalog content manifest with a verified content identity.
///
/// This type intentionally does not implement [`Deserialize`]. Read manifest
/// artifacts with [`Self::from_json_slice`] so size and canonical-encoding
/// checks cannot be bypassed accidentally.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct CatalogContentManifest {
schema_version: u32,
game_id: String,
game_version: String,
chunk_size: u64,
files: Vec<CatalogFileEntry>,
streamed_install_files: Vec<CatalogExtractedEntry>,
content_id: ContentId,
}
impl CatalogContentManifest {
/// Validates a manifest body and seals it with its deterministic content ID.
pub fn seal(body: CatalogContentManifestBody) -> eyre::Result<Self> {
body.validate()?;
let content_id = compute_content_id(&body)?;
Ok(Self {
schema_version: body.schema_version,
game_id: body.game_id,
game_version: body.game_version,
chunk_size: body.chunk_size,
files: body.files,
streamed_install_files: body.streamed_install_files,
content_id,
})
}
/// 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_MANIFEST_BYTES {
eyre::bail!(
"catalog content manifest exceeds the {MAX_CATALOG_MANIFEST_BYTES}-byte limit"
);
}
let raw: RawCatalogContentManifest = serde_json::from_slice(bytes)?;
let manifest = Self::try_from(raw)?;
if manifest.to_canonical_json()? != bytes {
eyre::bail!("catalog content manifest JSON is not canonical");
}
Ok(manifest)
}
/// Produces deterministic pretty JSON with exactly one trailing newline.
pub fn to_canonical_json(&self) -> eyre::Result<Vec<u8>> {
self.validate()?;
let mut bytes = serde_json::to_vec_pretty(self)?;
if u64::try_from(bytes.len())? >= MAX_CATALOG_MANIFEST_BYTES {
eyre::bail!(
"canonical catalog manifest exceeds the {MAX_CATALOG_MANIFEST_BYTES}-byte limit"
);
}
bytes.push(b'\n');
Ok(bytes)
}
/// Revalidates every structural invariant and the stored content ID.
pub fn validate(&self) -> eyre::Result<()> {
validate_header(
self.schema_version,
&self.game_id,
&self.game_version,
self.chunk_size,
)?;
validate_ordinary_entries(&self.files)?;
validate_extracted_entries(&self.streamed_install_files)?;
let expected = compute_content_id_fields(
self.schema_version,
&self.game_id,
&self.game_version,
self.chunk_size,
&self.files,
&self.streamed_install_files,
)?;
if self.content_id != expected {
eyre::bail!(
"catalog content ID mismatch: stored {}, computed {expected}",
self.content_id
);
}
Ok(())
}
#[must_use]
pub const fn schema_version(&self) -> u32 {
self.schema_version
}
#[must_use]
pub fn game_id(&self) -> &str {
&self.game_id
}
#[must_use]
pub fn game_version(&self) -> &str {
&self.game_version
}
#[must_use]
pub const fn chunk_size(&self) -> u64 {
self.chunk_size
}
#[must_use]
pub fn files(&self) -> &[CatalogFileEntry] {
&self.files
}
/// Finds one ordinary entry by its exact canonical path.
#[must_use]
pub fn file_entry(&self, canonical_path: &str) -> Option<&CatalogFileEntry> {
self.files
.binary_search_by(|entry| entry.canonical_path.as_str().cmp(canonical_path))
.ok()
.map(|index| &self.files[index])
}
#[must_use]
pub fn streamed_install_files(&self) -> &[CatalogExtractedEntry] {
&self.streamed_install_files
}
/// Finds one extracted entry by its exact canonical path.
#[must_use]
pub fn streamed_install_entry(&self, canonical_path: &str) -> Option<&CatalogExtractedEntry> {
self.streamed_install_files
.binary_search_by(|entry| entry.canonical_path.as_str().cmp(canonical_path))
.ok()
.map(|index| &self.streamed_install_files[index])
}
/// Returns whether this game has catalog-owned Stream Install output.
#[must_use]
pub fn supports_streamed_install(&self) -> bool {
!self.streamed_install_files.is_empty()
}
#[must_use]
pub const fn content_id(&self) -> ContentId {
self.content_id
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCatalogContentManifest {
schema_version: u32,
game_id: String,
game_version: String,
chunk_size: u64,
files: Vec<RawCatalogFileEntry>,
streamed_install_files: Vec<RawCatalogExtractedEntry>,
content_id: ContentId,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCatalogFileEntry {
canonical_path: CanonicalCatalogPath,
kind: CatalogEntryKind,
size: u64,
#[serde(deserialize_with = "deserialize_required_nullable")]
file_blake3: Option<Blake3Digest>,
chunk_blake3: Vec<Blake3Digest>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCatalogExtractedEntry {
canonical_path: CanonicalCatalogPath,
kind: CatalogEntryKind,
size: u64,
#[serde(deserialize_with = "deserialize_required_nullable")]
file_blake3: Option<Blake3Digest>,
}
fn deserialize_required_nullable<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
// The field-level `deserialize_with` annotation is the requiredness gate:
// Serde reports an absent field before calling this function. For a field
// that is present, JSON `null` remains the canonical directory encoding.
Option::<T>::deserialize(deserializer)
}
impl TryFrom<RawCatalogContentManifest> for CatalogContentManifest {
type Error = eyre::Report;
fn try_from(raw: RawCatalogContentManifest) -> Result<Self, Self::Error> {
let manifest = Self {
schema_version: raw.schema_version,
game_id: raw.game_id,
game_version: raw.game_version,
chunk_size: raw.chunk_size,
files: raw.files.into_iter().map(Into::into).collect(),
streamed_install_files: raw
.streamed_install_files
.into_iter()
.map(Into::into)
.collect(),
content_id: raw.content_id,
};
manifest.validate()?;
Ok(manifest)
}
}
impl From<RawCatalogFileEntry> for CatalogFileEntry {
fn from(raw: RawCatalogFileEntry) -> Self {
Self {
canonical_path: raw.canonical_path,
kind: raw.kind,
size: raw.size,
file_blake3: raw.file_blake3,
chunk_blake3: raw.chunk_blake3,
}
}
}
impl From<RawCatalogExtractedEntry> for CatalogExtractedEntry {
fn from(raw: RawCatalogExtractedEntry) -> Self {
Self {
canonical_path: raw.canonical_path,
kind: raw.kind,
size: raw.size,
file_blake3: raw.file_blake3,
}
}
}
fn validate_header(
schema_version: u32,
game_id: &str,
game_version: &str,
chunk_size: u64,
) -> eyre::Result<()> {
if schema_version != CATALOG_CONTENT_MANIFEST_SCHEMA_VERSION {
eyre::bail!("unsupported catalog content manifest schema {schema_version}");
}
if chunk_size != CATALOG_CHUNK_SIZE {
eyre::bail!("schema 1 requires a {CATALOG_CHUNK_SIZE}-byte chunk size");
}
validate_game_id(game_id)?;
validate_game_version(game_version)
}
fn validate_ordinary_entries(entries: &[CatalogFileEntry]) -> eyre::Result<()> {
if entries.len() > MAX_CATALOG_ENTRIES {
eyre::bail!("ordinary manifest exceeds the {MAX_CATALOG_ENTRIES}-entry limit");
}
let mut total_bytes = 0_u64;
let mut total_chunks = 0_usize;
let mut version_count = 0_usize;
for entry in entries {
validate_ordinary_entry(entry)?;
total_bytes = account_size(total_bytes, entry.size)?;
total_chunks = total_chunks
.checked_add(entry.chunk_blake3.len())
.ok_or_else(|| eyre::eyre!("ordinary chunk count overflow"))?;
if total_chunks > MAX_CATALOG_CHUNK_DIGESTS {
eyre::bail!("ordinary manifest contains too many chunk digests");
}
if entry.canonical_path.as_str() == VERSION_INI && entry.kind == CatalogEntryKind::File {
version_count += 1;
if entry.size > MAX_VERSION_INI_BYTES {
eyre::bail!("root version.ini exceeds the {MAX_VERSION_INI_BYTES}-byte limit");
}
}
}
if version_count != 1 {
eyre::bail!("ordinary manifest must contain exactly one regular root version.ini");
}
validate_topology(
entries
.iter()
.map(|entry| (&entry.canonical_path, entry.kind)),
true,
is_download_protected_root_name,
)
}
fn validate_extracted_entries(entries: &[CatalogExtractedEntry]) -> eyre::Result<()> {
if entries.len() > MAX_CATALOG_ENTRIES {
eyre::bail!("extracted manifest exceeds the {MAX_CATALOG_ENTRIES}-entry limit");
}
let mut total_bytes = 0_u64;
for entry in entries {
validate_extracted_entry(entry)?;
total_bytes = account_size(total_bytes, entry.size)?;
}
validate_topology(
entries
.iter()
.map(|entry| (&entry.canonical_path, entry.kind)),
false,
is_stream_install_protected_root_name,
)
}
fn validate_ordinary_entry(entry: &CatalogFileEntry) -> eyre::Result<()> {
match entry.kind {
CatalogEntryKind::Directory => {
if entry.size != 0 || entry.file_blake3.is_some() || !entry.chunk_blake3.is_empty() {
eyre::bail!(
"catalog directory has file metadata: {}",
entry.canonical_path
);
}
}
CatalogEntryKind::File => {
validate_file_size_and_hash(entry.size, entry.file_blake3)?;
let expected_chunks = if entry.size == 0 {
0
} else {
usize::try_from(entry.size.div_ceil(CATALOG_CHUNK_SIZE))?
};
if entry.chunk_blake3.len() != expected_chunks {
eyre::bail!(
"catalog file {} has {} chunk hashes; expected {expected_chunks}",
entry.canonical_path,
entry.chunk_blake3.len()
);
}
if expected_chunks == 1 && entry.chunk_blake3.first() != entry.file_blake3.as_ref() {
eyre::bail!(
"single-chunk file hash does not match its chunk hash: {}",
entry.canonical_path
);
}
}
}
Ok(())
}
fn validate_extracted_entry(entry: &CatalogExtractedEntry) -> eyre::Result<()> {
match entry.kind {
CatalogEntryKind::Directory => {
if entry.size != 0 || entry.file_blake3.is_some() {
eyre::bail!(
"catalog directory has file metadata: {}",
entry.canonical_path
);
}
}
CatalogEntryKind::File => validate_file_size_and_hash(entry.size, entry.file_blake3)?,
}
Ok(())
}
fn validate_file_size_and_hash(size: u64, digest: Option<Blake3Digest>) -> eyre::Result<()> {
if size > MAX_CATALOG_FILE_BYTES {
eyre::bail!("catalog file exceeds the {MAX_CATALOG_FILE_BYTES}-byte limit");
}
let digest = digest.ok_or_else(|| eyre::eyre!("catalog file is missing its BLAKE3 digest"))?;
if size == 0 && digest != Blake3Digest::hash(&[]) {
eyre::bail!("empty catalog file has an incorrect BLAKE3 digest");
}
Ok(())
}
fn account_size(current: u64, size: u64) -> eyre::Result<u64> {
let total = current
.checked_add(size)
.ok_or_else(|| eyre::eyre!("catalog manifest byte total overflow"))?;
if total > MAX_CATALOG_TOTAL_BYTES {
eyre::bail!("catalog manifest exceeds the {MAX_CATALOG_TOTAL_BYTES}-byte total limit");
}
Ok(total)
}
fn validate_topology<'a>(
entries: impl IntoIterator<Item = (&'a CanonicalCatalogPath, CatalogEntryKind)>,
require_explicit_parents: bool,
is_protected_root_name: fn(&str) -> bool,
) -> eyre::Result<()> {
let entries = entries.into_iter().collect::<Vec<_>>();
for pair in entries.windows(2) {
if pair[0].0.as_str() >= pair[1].0.as_str() {
eyre::bail!("catalog entries are not strictly sorted by canonical path");
}
}
let mut exact_shapes = BTreeMap::new();
let mut alias_shapes = BTreeMap::new();
for (path, kind) in &entries {
let root = path.components().next().unwrap_or_default();
if is_protected_root_name(root) {
eyre::bail!("catalog path is reserved for application state: {path}");
}
if exact_shapes.insert(path.as_str(), *kind).is_some() {
eyre::bail!("duplicate catalog path: {path}");
}
if alias_shapes.insert(path.portable_alias(), *kind).is_some() {
eyre::bail!("duplicate or platform-alias catalog path: {path}");
}
}
for (path, _) in &entries {
for parent in path.parent_paths() {
match exact_shapes.get(parent) {
Some(CatalogEntryKind::File) => {
eyre::bail!("catalog path descends through a file: {path}");
}
None if require_explicit_parents => {
eyre::bail!("catalog path is missing explicit parent directory {parent}");
}
Some(CatalogEntryKind::Directory) | None => {}
}
}
let alias = path.portable_alias();
for (separator, _) in alias.match_indices('/') {
let parent = &alias[..separator];
if alias_shapes.get(parent) == Some(&CatalogEntryKind::File) {
eyre::bail!("catalog path descends through a platform-alias file: {path}");
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeSet, fmt::Write as _};
use serde_json::Value;
use super::*;
use crate::content_manifest::encoding::content_id_transcript;
const GOLDEN_CONTENT_ID: &str =
"507be1d8d72fde69e60e019ff0a10de7780c69a01cca81823447535eabcfadb0";
const GOLDEN_TRANSCRIPT_HEX: &str = concat!(
"000000000000002d6c616e7370726561642f636174616c6f672d636f6e74656e742d6d616e69666573742f636f6e7465",
"6e742d696400000001000000000000000b676f6c64656e2d67616d650000000000000007323032342e30310000000008",
"000000000000000000000500000000000000066173736574730000000000000000000000000000000000000000000000",
"00000b6173736574732f6461746100000000000000000000000000000000000000000000000000176173736574732f64",
"6174612f617263686976652e62696e010000000008000007010000000000000020111111111111111111111111111111",
"111111111111111111111111111111111100000000000000020000000000000020212121212121212121212121212121",
"212121212121212121212121212121212100000000000000202222222222222222222222222222222222222222222222",
"2222222222222222220000000000000009656d7074792e747874010000000000000000010000000000000020af1349b9",
"f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f32620000000000000000000000000000000b76657273",
"696f6e2e696e690100000000000000080100000000000000203361edb4b45d1743e25e893ed1f7926b9ae99e6688c46a",
"df1d24b8102065cef8000000000000000100000000000000203361edb4b45d1743e25e893ed1f7926b9ae99e6688c46a",
"df1d24b8102065cef800000000000000030000000000000013696e7374616c6c5f696e74656e742e6a736f6e01000000",
"0000000006010000000000000020f0877c0e1ab8f4c605052fb919df4dc8cb7ff4df5a9cd5e85e8796a5727533d90000",
"0000000000056c6f63616c0000000000000000000000000000000000116c6f63616c2f70726f66696c652e6461740100",
"000000000000040100000000000000205f6d3989c7fb86d3c961c0b217cb3b4939cff7416ed8980a18c7e662ec3e0761",
);
fn golden_body() -> CatalogContentManifestBody {
let version = Blake3Digest::hash(b"2024.01\n");
let empty = Blake3Digest::hash(&[]);
CatalogContentManifestBody::new(
"golden-game",
"2024.01",
vec![
CatalogFileEntry::directory("assets").expect("asset directory should validate"),
CatalogFileEntry::directory("assets/data").expect("data directory should validate"),
CatalogFileEntry::file(
"assets/data/archive.bin",
CATALOG_CHUNK_SIZE + 7,
Blake3Digest::from_bytes([0x11; 32]),
vec![
Blake3Digest::from_bytes([0x21; 32]),
Blake3Digest::from_bytes([0x22; 32]),
],
)
.expect("multi-chunk entry should validate"),
CatalogFileEntry::file("empty.txt", 0, empty, Vec::new())
.expect("empty entry should validate"),
CatalogFileEntry::file("version.ini", 8, version, vec![version])
.expect("version entry should validate"),
],
vec![
CatalogExtractedEntry::file(
"install_intent.json",
6,
Blake3Digest::hash(b"intent"),
)
.expect("extracted intent-named entry should validate"),
CatalogExtractedEntry::directory("local")
.expect("extracted local directory should validate"),
CatalogExtractedEntry::file("local/profile.dat", 4, Blake3Digest::hash(b"user"))
.expect("extracted profile should validate"),
],
)
.expect("golden body should validate")
}
fn golden_manifest() -> CatalogContentManifest {
CatalogContentManifest::seal(golden_body()).expect("golden manifest should seal")
}
fn decode_json_value(value: Value) -> eyre::Result<CatalogContentManifest> {
let raw = serde_json::from_value::<RawCatalogContentManifest>(value)?;
CatalogContentManifest::try_from(raw)
}
#[test]
fn golden_transcript_and_content_id_are_frozen() {
let body = golden_body();
let transcript = content_id_transcript(&body).expect("transcript should encode");
let mut transcript_hex = String::with_capacity(transcript.len() * 2);
for byte in transcript {
write!(&mut transcript_hex, "{byte:02x}").expect("writing to a String cannot fail");
}
assert_eq!(transcript_hex, GOLDEN_TRANSCRIPT_HEX);
assert_eq!(
CatalogContentManifest::seal(body)
.expect("manifest should seal")
.content_id()
.to_string(),
GOLDEN_CONTENT_ID
);
}
#[test]
fn canonical_json_round_trips_and_ends_in_one_newline() {
let manifest = golden_manifest();
let json = manifest
.to_canonical_json()
.expect("manifest should encode");
assert!(json.ends_with(b"\n"));
assert!(!json.ends_with(b"\n\n"));
assert_eq!(
CatalogContentManifest::from_json_slice(&json).expect("manifest should load"),
manifest
);
let compact = serde_json::to_vec(&manifest).expect("manifest should serialize");
assert!(CatalogContentManifest::from_json_slice(&compact).is_err());
}
#[test]
fn semantic_mutation_with_old_content_id_is_rejected() {
let manifest = golden_manifest();
let mut json: Value = serde_json::to_value(&manifest).expect("manifest should serialize");
json["game_version"] = Value::String("20250101".to_owned());
assert!(decode_json_value(json).is_err());
}
#[test]
fn every_transcript_field_affects_content_identity() {
let body = golden_body();
let baseline = compute_content_id(&body).expect("baseline should hash");
let mut mutations = Vec::new();
let mut changed = body.clone();
changed.schema_version = 2;
mutations.push(changed);
let mut changed = body.clone();
changed.game_id = "h".to_owned();
mutations.push(changed);
let mut changed = body.clone();
changed.game_version = "20240102".to_owned();
mutations.push(changed);
let mut changed = body.clone();
changed.chunk_size += 1;
mutations.push(changed);
let mut changed = body.clone();
changed.files[2].canonical_path =
CanonicalCatalogPath::new("assets/data/archive2.bin").expect("path should validate");
mutations.push(changed);
let mut changed = body.clone();
changed.files[2].kind = CatalogEntryKind::Directory;
mutations.push(changed);
let mut changed = body.clone();
changed.files[2].size += 1;
mutations.push(changed);
let mut changed = body.clone();
changed.files[2].file_blake3 = Some(Blake3Digest::hash(b"other whole file"));
mutations.push(changed);
let mut changed = body.clone();
changed.files[2].chunk_blake3[0] = Blake3Digest::hash(b"other chunk");
mutations.push(changed);
let mut changed = body.clone();
changed.files.clear();
mutations.push(changed);
let mut changed = body.clone();
changed.streamed_install_files[0].canonical_path =
CanonicalCatalogPath::new("bin/b.txt").expect("path should validate");
mutations.push(changed);
let mut changed = body.clone();
changed.streamed_install_files[0].kind = CatalogEntryKind::Directory;
mutations.push(changed);
let mut changed = body.clone();
changed.streamed_install_files[0].size += 1;
mutations.push(changed);
let mut changed = body.clone();
changed.streamed_install_files[0].file_blake3 =
Some(Blake3Digest::hash(b"other extracted file"));
mutations.push(changed);
let mut changed = body;
changed.streamed_install_files.clear();
mutations.push(changed);
let mut identities = BTreeSet::from([baseline]);
for mutation in mutations {
let identity = compute_content_id(&mutation).expect("mutation should hash");
assert_ne!(identity, baseline);
assert!(
identities.insert(identity),
"mutations collided in test vector"
);
}
}
#[test]
fn unknown_and_missing_fields_are_rejected() {
let manifest = golden_manifest();
let mut unknown: Value =
serde_json::to_value(&manifest).expect("manifest should serialize");
unknown["surprise"] = Value::Bool(true);
assert!(serde_json::from_value::<RawCatalogContentManifest>(unknown).is_err());
let mut missing: Value =
serde_json::to_value(&manifest).expect("manifest should serialize");
missing["files"][0]
.as_object_mut()
.expect("file should be an object")
.remove("file_blake3");
assert!(serde_json::from_value::<RawCatalogContentManifest>(missing).is_err());
let canonical = String::from_utf8(
manifest
.to_canonical_json()
.expect("manifest should serialize"),
)
.expect("canonical JSON should be UTF-8");
let duplicate = canonical.replacen(
" \"schema_version\": 1,",
" \"schema_version\": 1,\n \"schema_version\": 1,",
1,
);
assert!(serde_json::from_str::<RawCatalogContentManifest>(&duplicate).is_err());
}
#[test]
fn nullable_hash_fields_require_presence_for_both_entry_shapes() {
let digest = Blake3Digest::hash(b"file");
let ordinary_directory =
CatalogFileEntry::directory("dir").expect("ordinary directory should validate");
let ordinary_null =
serde_json::to_value(&ordinary_directory).expect("ordinary directory should serialize");
assert!(
serde_json::from_value::<RawCatalogFileEntry>(ordinary_null.clone())
.expect("present null ordinary hash should deserialize")
.file_blake3
.is_none()
);
let ordinary_file = CatalogFileEntry::file("file.bin", 4, digest, vec![digest])
.expect("ordinary file should validate");
assert_eq!(
serde_json::from_value::<RawCatalogFileEntry>(
serde_json::to_value(&ordinary_file).expect("ordinary file should serialize"),
)
.expect("present ordinary hash should deserialize")
.file_blake3,
Some(digest)
);
let mut ordinary_missing = ordinary_null;
ordinary_missing
.as_object_mut()
.expect("ordinary entry should be an object")
.remove("file_blake3");
assert!(serde_json::from_value::<RawCatalogFileEntry>(ordinary_missing).is_err());
let extracted_directory =
CatalogExtractedEntry::directory("dir").expect("extracted directory should validate");
let extracted_null = serde_json::to_value(&extracted_directory)
.expect("extracted directory should serialize");
assert!(
serde_json::from_value::<RawCatalogExtractedEntry>(extracted_null.clone())
.expect("present null extracted hash should deserialize")
.file_blake3
.is_none()
);
let extracted_file = CatalogExtractedEntry::file("file.bin", 4, digest)
.expect("extracted file should validate");
assert_eq!(
serde_json::from_value::<RawCatalogExtractedEntry>(
serde_json::to_value(&extracted_file).expect("extracted file should serialize"),
)
.expect("present extracted hash should deserialize")
.file_blake3,
Some(digest)
);
let mut extracted_missing = extracted_null;
extracted_missing
.as_object_mut()
.expect("extracted entry should be an object")
.remove("file_blake3");
assert!(serde_json::from_value::<RawCatalogExtractedEntry>(extracted_missing).is_err());
}
#[test]
fn rejects_unsupported_headers_and_unbounded_identity_text() {
let mut body = golden_body();
body.schema_version = 2;
assert!(body.validate().is_err());
let mut body = golden_body();
body.chunk_size = CATALOG_CHUNK_SIZE / 2;
assert!(body.validate().is_err());
let mut body = golden_body();
body.game_id = "../g".to_owned();
assert!(body.validate().is_err());
let mut body = golden_body();
body.game_id = "cafe\u{301}".to_owned();
assert!(body.validate().is_err());
let mut body = golden_body();
body.game_version = "v".repeat(256);
assert!(body.validate().is_err());
}
#[test]
fn ordinary_entries_require_explicit_parents() {
let version = Blake3Digest::hash(b"20240101");
let payload = Blake3Digest::hash(b"x");
let result = CatalogContentManifestBody::new(
"g",
"20240101",
vec![
CatalogFileEntry::file("bin/a", 1, payload, vec![payload])
.expect("file should validate alone"),
CatalogFileEntry::file("version.ini", 8, version, vec![version])
.expect("version should validate alone"),
],
Vec::new(),
);
assert!(result.is_err());
let body = CatalogContentManifestBody::new(
"g",
"20240101",
vec![
CatalogFileEntry::directory("bin").expect("directory should validate"),
CatalogFileEntry::file("bin/a", 1, payload, vec![payload])
.expect("file should validate alone"),
CatalogFileEntry::file("version.ini", 8, version, vec![version])
.expect("version should validate alone"),
],
Vec::new(),
)
.expect("explicit directory should make topology valid");
assert_eq!(
body.file_entry("bin/a").map(CatalogFileEntry::size),
Some(1)
);
}
#[test]
fn extracted_entries_allow_implicit_parents() {
let body = golden_body();
assert!(body.validate().is_ok());
assert!(body.supports_streamed_install());
assert_eq!(
body.streamed_install_entry("local/profile.dat")
.map(CatalogExtractedEntry::size),
Some(4)
);
}
#[test]
fn extracted_entries_allow_game_root_state_names_but_not_staging_marker() {
let body = golden_body();
assert!(body.streamed_install_entry("install_intent.json").is_some());
assert!(body.streamed_install_entry("local").is_some());
let mut protected = body;
protected.streamed_install_files = vec![
CatalogExtractedEntry::directory(".lanspread_owned")
.expect("path should validate independently"),
];
assert!(protected.validate().is_err());
}
#[test]
fn rejects_aliases_unsorted_entries_and_shape_conflicts() {
let version = Blake3Digest::hash(b"20240101");
let payload = Blake3Digest::hash(b"x");
let file = |path| {
CatalogFileEntry::file(path, 1, payload, vec![payload])
.expect("entry should validate alone")
};
let version_entry = || {
CatalogFileEntry::file("version.ini", 8, version, vec![version])
.expect("version should validate alone")
};
assert!(
CatalogContentManifestBody::new(
"g",
"20240101",
vec![file("z"), version_entry()],
Vec::new()
)
.is_err()
);
assert!(
CatalogContentManifestBody::new(
"g",
"20240101",
vec![file("A"), file("a"), version_entry()],
Vec::new()
)
.is_err()
);
assert!(
CatalogContentManifestBody::new(
"g",
"20240101",
vec![file("bin"), file("bin/a"), version_entry()],
Vec::new()
)
.is_err()
);
}
#[test]
fn enforces_file_hash_and_chunk_shapes() {
let empty = Blake3Digest::hash(&[]);
assert!(CatalogFileEntry::file("empty", 0, empty, Vec::new()).is_ok());
assert!(CatalogFileEntry::file("empty", 0, Blake3Digest::hash(b"x"), Vec::new()).is_err());
assert!(CatalogFileEntry::file("one", 1, Blake3Digest::hash(b"x"), Vec::new()).is_err());
assert!(
CatalogFileEntry::file(
"one",
1,
Blake3Digest::hash(b"x"),
vec![Blake3Digest::hash(b"y")]
)
.is_err()
);
let one_chunk = Blake3Digest::from_bytes([1; 32]);
assert!(
CatalogFileEntry::file("one-chunk", CATALOG_CHUNK_SIZE, one_chunk, vec![one_chunk])
.is_ok()
);
assert!(
CatalogFileEntry::file(
"two-chunks",
CATALOG_CHUNK_SIZE + 1,
Blake3Digest::from_bytes([2; 32]),
vec![
Blake3Digest::from_bytes([3; 32]),
Blake3Digest::from_bytes([4; 32])
]
)
.is_ok()
);
assert!(
CatalogFileEntry::file(
"exact-two-chunks",
2 * CATALOG_CHUNK_SIZE,
Blake3Digest::from_bytes([5; 32]),
vec![
Blake3Digest::from_bytes([6; 32]),
Blake3Digest::from_bytes([7; 32])
]
)
.is_ok()
);
assert!(
CatalogFileEntry::file(
"too-large",
MAX_CATALOG_FILE_BYTES + 1,
Blake3Digest::from_bytes([8; 32]),
Vec::new()
)
.is_err()
);
}
#[test]
fn enforces_directory_metadata_exact_version_sentinel_and_reserved_roots() {
let version = Blake3Digest::hash(b"20240101");
let mut body = golden_body();
body.files[4].kind = CatalogEntryKind::Directory;
assert!(body.validate().is_err());
let mut body = golden_body();
body.files.clear();
assert!(body.validate().is_err());
let mut body = golden_body();
body.files[4].canonical_path =
CanonicalCatalogPath::new("VERSION.INI").expect("path should validate");
assert!(body.validate().is_err());
assert!(
CatalogContentManifestBody::new(
"g",
"20240101",
vec![
CatalogFileEntry::file("local", 1, version, vec![version])
.expect("file should validate alone"),
CatalogFileEntry::file("version.ini", 8, version, vec![version])
.expect("version should validate alone"),
],
Vec::new()
)
.is_err()
);
}
}
@@ -0,0 +1,272 @@
use std::{fmt, str::FromStr};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use unicode_normalization::is_nfc;
use super::model::{MAX_CATALOG_COMPONENT_BYTES, MAX_CATALOG_PATH_BYTES};
/// A canonical, portable path relative to one catalog game root.
#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CanonicalCatalogPath(String);
impl CanonicalCatalogPath {
/// Validates and constructs a canonical catalog path.
pub fn new(path: impl Into<String>) -> eyre::Result<Self> {
let path = path.into();
validate_path(&path)?;
Ok(Self(path))
}
/// Returns the canonical `/`-separated representation.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
pub(crate) fn components(&self) -> impl Iterator<Item = &str> {
self.0.split('/')
}
pub(crate) fn portable_alias(&self) -> String {
self.components()
.map(portable_name_key)
.collect::<Vec<_>>()
.join("/")
}
pub(crate) fn parent_paths(&self) -> impl Iterator<Item = &str> {
self.0
.match_indices('/')
.map(|(separator, _)| &self.0[..separator])
}
}
impl fmt::Debug for CanonicalCatalogPath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("CanonicalCatalogPath")
.field(&self.0)
.finish()
}
}
impl fmt::Display for CanonicalCatalogPath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl AsRef<str> for CanonicalCatalogPath {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl FromStr for CanonicalCatalogPath {
type Err = eyre::Report;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl Serialize for CanonicalCatalogPath {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for CanonicalCatalogPath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(de::Error::custom)
}
}
pub(crate) fn validate_game_id(game_id: &str) -> eyre::Result<()> {
validate_bounded_text(game_id, 255, "catalog game ID")?;
if game_id.contains(['/', '\\']) {
eyre::bail!("catalog game ID must be one path component: {game_id}");
}
if !is_nfc(game_id) {
eyre::bail!("catalog game ID must use Unicode NFC normalization: {game_id}");
}
validate_component(game_id)?;
if is_download_protected_root_name(game_id) {
eyre::bail!("catalog game ID is reserved for application state: {game_id}");
}
Ok(())
}
pub(crate) fn validate_game_version(game_version: &str) -> eyre::Result<()> {
validate_bounded_text(game_version, 255, "catalog game version")
}
pub(crate) fn validate_bounded_text(value: &str, limit: usize, label: &str) -> eyre::Result<()> {
if value.is_empty() {
eyre::bail!("{label} cannot be empty");
}
if value.len() > limit {
eyre::bail!("{label} exceeds the {limit}-byte limit");
}
if value.chars().any(char::is_control) {
eyre::bail!("{label} contains a control character");
}
Ok(())
}
pub(crate) fn is_download_protected_root_name(name: &str) -> bool {
let key = portable_name_key(name);
key == "LOCAL"
|| key.starts_with(".LOCAL.")
|| key.starts_with(".VERSION.INI.")
|| matches!(
key.as_str(),
".SYNC"
| ".LANSPREAD"
| ".LANSPREAD.JSON"
| ".LANSPREAD.JSON.TMP"
| ".LANSPREAD_OWNED"
| ".SOFTLAN_FIRST_START_DONE"
| ".SOFTLAN_GAME_INSTALLED"
| "INSTALL_INTENT.JSON"
| "INSTALL_INTENT.JSON.TMP"
)
}
/// Returns whether an extracted path would overwrite Stream Install's staging marker.
pub(crate) fn is_stream_install_protected_root_name(name: &str) -> bool {
portable_name_key(name) == ".LANSPREAD_OWNED"
}
fn validate_path(path: &str) -> eyre::Result<()> {
if path.is_empty() || path.starts_with('/') || path.ends_with('/') {
eyre::bail!("catalog path is not canonical: {path:?}");
}
if path.len() > MAX_CATALOG_PATH_BYTES {
eyre::bail!("catalog path exceeds the {MAX_CATALOG_PATH_BYTES}-byte limit");
}
if !is_nfc(path) {
eyre::bail!("catalog path must use Unicode NFC normalization: {path}");
}
if path.contains('\\') {
eyre::bail!("catalog path must use canonical '/' separators: {path}");
}
for component in path.split('/') {
validate_component(component)?;
}
Ok(())
}
fn validate_component(component: &str) -> eyre::Result<()> {
if component.is_empty() || matches!(component, "." | "..") {
eyre::bail!("catalog path contains a non-canonical component: {component:?}");
}
if component.len() > MAX_CATALOG_COMPONENT_BYTES {
eyre::bail!("catalog path component exceeds the {MAX_CATALOG_COMPONENT_BYTES}-byte limit");
}
if component.ends_with([' ', '.']) {
eyre::bail!("catalog path component has a trailing dot or space: {component}");
}
if component.chars().any(|character| {
character <= '\u{1f}' || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*')
}) {
eyre::bail!("catalog path component is not portable: {component}");
}
let device_stem = component.split('.').next().unwrap_or_default().trim_end();
if is_windows_device_name(device_stem) {
eyre::bail!("catalog path uses a Windows device name: {component}");
}
if looks_like_dos_short_name(component) {
eyre::bail!("catalog path resembles a Windows short-name alias: {component}");
}
Ok(())
}
fn portable_name_key(name: &str) -> String {
name.to_uppercase()
}
fn is_windows_device_name(stem: &str) -> bool {
let upper = stem.to_ascii_uppercase();
matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| upper
.strip_prefix("COM")
.or_else(|| upper.strip_prefix("LPT"))
.is_some_and(|number| {
(number.len() == 1 && number.as_bytes()[0].is_ascii_digit())
|| matches!(number, "¹" | "²" | "³")
})
}
fn looks_like_dos_short_name(component: &str) -> bool {
let stem = component.split('.').next().unwrap_or_default();
stem.rsplit_once('~').is_some_and(|(prefix, suffix)| {
!prefix.is_empty()
&& !suffix.is_empty()
&& suffix.len() <= 6
&& suffix.bytes().all(|byte| byte.is_ascii_digit())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_canonical_portable_paths() {
for path in ["version.ini", "bin/a.txt", "Données/été.dat"] {
assert!(CanonicalCatalogPath::new(path).is_ok(), "rejected {path}");
}
}
#[test]
fn rejects_noncanonical_or_nonportable_paths() {
for path in [
"",
"/absolute",
"trailing/",
"two//parts",
"./relative",
"../escape",
"back\\slash",
"trailing. ",
"bad:name",
"NUL.txt",
"com1",
"LPT¹.log",
"LOCAL~1/file",
"Donne\u{301}es/file",
] {
assert!(
CanonicalCatalogPath::new(path).is_err(),
"accepted {path:?}"
);
}
}
#[test]
fn aliases_are_conservative_across_platforms() {
let left = CanonicalCatalogPath::new("Straße/FILE").expect("path should validate");
let right = CanonicalCatalogPath::new("STRASSE/file").expect("path should validate");
assert_eq!(left.portable_alias(), right.portable_alias());
assert!(is_download_protected_root_name(".ſync"));
}
#[test]
fn extracted_staging_policy_only_reserves_its_ownership_marker() {
for allowed in ["local", "install_intent.json", ".local.installing"] {
assert!(!is_stream_install_protected_root_name(allowed));
}
for protected in [".lanspread_owned", ".LANSPREAD_OWNED", ".lanſpread_owned"] {
assert!(is_stream_install_protected_root_name(protected));
}
}
}
@@ -0,0 +1,1065 @@
use std::{
collections::{BTreeMap, HashMap, HashSet},
ffi::OsStr,
fs::{self, File, OpenOptions},
io::{Read, Write},
path::{Path, PathBuf},
sync::{
Arc,
RwLock,
atomic::{AtomicU64, Ordering},
},
};
use eyre::WrapErr;
use super::{
CATALOG_CONTENT_INDEX_NAME,
CatalogContentIdentity,
CatalogContentIndex,
CatalogContentManifest,
MAX_CATALOG_CONTENT_INDEX_BYTES,
MAX_CATALOG_ENTRIES,
MAX_CATALOG_MANIFEST_BYTES,
path::{validate_game_id, validate_game_version},
};
static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
/// Durable marker left visible while a multi-manifest publication is in flight.
///
/// Runtime authority loaders reject a root containing this marker. The
/// publisher intentionally keeps ordinary store validation usable while the
/// marker exists so it can verify the complete new set before removing the
/// marker as its final commit step.
pub const CATALOG_PUBLICATION_MARKER_NAME: &str = ".lanspread-catalog-publication-in-progress";
/// Rejects a manifest root whose publisher transaction did not finish.
///
/// A missing root is left for the caller's normal coverage validation to
/// diagnose. When the root exists, it must be a regular non-link directory.
///
/// # Errors
///
/// Returns an error for an unsafe root, an incomplete-publication marker, or a
/// filesystem inspection failure.
pub fn reject_incomplete_catalog_publication(root: &Path) -> eyre::Result<()> {
match fs::symlink_metadata(root) {
Ok(_) => validate_regular_directory(root)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
}
let marker = root.join(CATALOG_PUBLICATION_MARKER_NAME);
match fs::symlink_metadata(&marker) {
Ok(_) => eyre::bail!(
"catalog manifest publication is incomplete; reconcile and remove {} before continuing",
marker.display()
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
/// On-demand access to the exact manifest set described by `game.db`.
#[derive(Debug)]
pub struct CatalogManifestStore {
source: CatalogManifestSource,
expected_versions: BTreeMap<String, String>,
content_index: CatalogContentIndex,
cache: RwLock<HashMap<String, Arc<CatalogContentManifest>>>,
}
#[derive(Debug)]
enum CatalogManifestSource {
Disk(PathBuf),
Memory(BTreeMap<String, CatalogContentManifest>),
}
impl CatalogManifestStore {
/// Constructs a store for an exact game-ID/version map.
pub fn new(
root: impl Into<PathBuf>,
expected_versions: BTreeMap<String, String>,
) -> eyre::Result<Self> {
validate_expected_versions(&expected_versions)?;
let root = root.into();
validate_regular_directory(&root)?;
let index_path = root.join(CATALOG_CONTENT_INDEX_NAME);
let index_bytes = read_bounded_regular_file(
&index_path,
MAX_CATALOG_CONTENT_INDEX_BYTES,
"catalog content index",
)?;
let content_index = CatalogContentIndex::from_json_slice(&index_bytes)
.wrap_err_with(|| format!("invalid catalog content index {}", index_path.display()))?;
content_index.validate_expected_versions(&expected_versions)?;
Ok(Self {
source: CatalogManifestSource::Disk(root),
expected_versions,
content_index,
cache: RwLock::new(HashMap::new()),
})
}
/// Constructs an exact in-memory store from already sealed manifests.
///
/// Every manifest is revalidated and duplicate or platform-alias game IDs
/// are rejected. Unlike the disk-backed store, all manifest bodies are
/// necessarily present at construction time.
pub fn from_manifests(
manifests: impl IntoIterator<Item = CatalogContentManifest>,
) -> eyre::Result<Self> {
let manifests = manifests.into_iter().collect::<Vec<_>>();
let content_index = CatalogContentIndex::from_manifests(&manifests)?;
let mut in_memory = BTreeMap::new();
let mut expected_versions = BTreeMap::new();
for manifest in manifests {
let game_id = manifest.game_id().to_owned();
let game_version = manifest.game_version().to_owned();
if in_memory.insert(game_id.clone(), manifest).is_some() {
eyre::bail!("duplicate in-memory catalog manifest for {game_id}");
}
expected_versions.insert(game_id, game_version);
}
validate_expected_versions(&expected_versions)?;
Ok(Self {
source: CatalogManifestSource::Memory(in_memory),
expected_versions,
content_index,
cache: RwLock::new(HashMap::new()),
})
}
pub(super) const fn expected_versions(&self) -> &BTreeMap<String, String> {
&self.expected_versions
}
/// Returns one catalog-owned content identity without filesystem access.
#[must_use]
pub fn content_identity(&self, game_id: &str) -> Option<CatalogContentIdentity> {
self.content_index.content_identity(game_id)
}
fn validate_expected_versions(
expected_versions: &BTreeMap<String, String>,
) -> eyre::Result<()> {
if expected_versions.len() > MAX_CATALOG_ENTRIES {
eyre::bail!("catalog exceeds the {MAX_CATALOG_ENTRIES}-game limit");
}
let mut portable_ids = HashSet::new();
for (game_id, version) in expected_versions {
validate_game_id(game_id)?;
validate_game_version(version)?;
if !portable_ids.insert(game_id.to_uppercase()) {
eyre::bail!("catalog contains duplicate or platform-alias game ID: {game_id}");
}
}
Ok(())
}
/// Loads and validates one known manifest on demand.
pub fn load(&self, game_id: &str) -> eyre::Result<Arc<CatalogContentManifest>> {
let expected_version = self
.expected_versions
.get(game_id)
.ok_or_else(|| eyre::eyre!("unknown catalog game ID: {game_id}"))?;
if let Some(manifest) = self
.cache
.read()
.map_err(|_| eyre::eyre!("catalog manifest cache lock is poisoned"))?
.get(game_id)
.cloned()
{
return Ok(manifest);
}
let manifest = Arc::new(self.load_uncached(game_id, expected_version)?);
let mut cache = self
.cache
.write()
.map_err(|_| eyre::eyre!("catalog manifest cache lock is poisoned"))?;
Ok(cache
.entry(game_id.to_owned())
.or_insert_with(|| Arc::clone(&manifest))
.clone())
}
/// Returns a previously validated manifest without falling back to disk.
///
/// # Errors
///
/// Returns an error when the game ID is unknown, the cache lock is
/// poisoned, or the known manifest has not been loaded yet.
pub fn load_cached(&self, game_id: &str) -> eyre::Result<Arc<CatalogContentManifest>> {
if !self.expected_versions.contains_key(game_id) {
eyre::bail!("unknown catalog game ID: {game_id}");
}
self.cache
.read()
.map_err(|_| eyre::eyre!("catalog manifest cache lock is poisoned"))?
.get(game_id)
.cloned()
.ok_or_else(|| eyre::eyre!("catalog manifest is not preloaded for {game_id}"))
}
/// Validates exact catalog coverage and every artifact from disk.
pub fn validate_all(&self) -> eyre::Result<()> {
self.cache
.write()
.map_err(|_| eyre::eyre!("catalog manifest cache lock is poisoned"))?
.clear();
let discovered = self.discover_coverage()?;
let mut validated = HashMap::new();
for (game_id, expected_version) in &self.expected_versions {
let manifest = Arc::new(self.load_uncached(game_id, expected_version)?);
validated.insert(game_id.clone(), manifest);
}
*self
.cache
.write()
.map_err(|_| eyre::eyre!("catalog manifest cache lock is poisoned"))? = validated;
debug_assert_eq!(discovered.len(), self.expected_versions.len());
Ok(())
}
/// Validates the manifest root and exact artifact filename coverage without
/// reading or parsing any manifest body.
pub fn validate_coverage(&self) -> eyre::Result<()> {
self.discover_coverage().map(|_| ())
}
fn discover_coverage(&self) -> eyre::Result<HashSet<String>> {
let CatalogManifestSource::Disk(root) = &self.source else {
let discovered = match &self.source {
CatalogManifestSource::Memory(manifests) => {
manifests.keys().cloned().collect::<HashSet<_>>()
}
CatalogManifestSource::Disk(_) => unreachable!(),
};
if discovered.len() != self.expected_versions.len()
|| self
.expected_versions
.keys()
.any(|game_id| !discovered.contains(game_id))
{
eyre::bail!("in-memory catalog manifest coverage is inconsistent");
}
return Ok(discovered);
};
validate_regular_directory(root)?;
let mut discovered = HashSet::new();
let mut entries = fs::read_dir(root)
.wrap_err_with(|| format!("failed to read manifest directory {}", root.display()))?
.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
if !has_json_extension(&path) {
continue;
}
let file_name = entry
.file_name()
.into_string()
.map_err(|_| eyre::eyre!("manifest filename is not valid UTF-8"))?;
let game_id = file_name.strip_suffix(".json").ok_or_else(|| {
eyre::eyre!("manifest suffix must be lowercase .json: {file_name}")
})?;
if !self.expected_versions.contains_key(game_id) {
eyre::bail!("unexpected catalog manifest artifact: {file_name}");
}
let metadata = fs::symlink_metadata(&path).wrap_err_with(|| {
format!("failed to inspect catalog manifest {}", path.display())
})?;
if is_link_or_reparse(&metadata) || !metadata.is_file() {
eyre::bail!(
"catalog manifest is not a regular non-link file: {}",
path.display()
);
}
if !discovered.insert(game_id.to_owned()) {
eyre::bail!("duplicate catalog manifest artifact: {file_name}");
}
}
for game_id in self.expected_versions.keys() {
if !discovered.contains(game_id) {
eyre::bail!("missing catalog manifest artifact for {game_id}");
}
}
Ok(discovered)
}
fn load_uncached(
&self,
game_id: &str,
expected_version: &str,
) -> eyre::Result<CatalogContentManifest> {
let CatalogManifestSource::Disk(root) = &self.source else {
let CatalogManifestSource::Memory(manifests) = &self.source else {
unreachable!();
};
let manifest = manifests
.get(game_id)
.ok_or_else(|| eyre::eyre!("missing in-memory catalog manifest for {game_id}"))?
.clone();
manifest.validate()?;
if manifest.game_version() != expected_version {
eyre::bail!(
"in-memory manifest version mismatch for {game_id}: expected {expected_version}, found {}",
manifest.game_version()
);
}
self.content_index.validate_manifest(game_id, &manifest)?;
return Ok(manifest);
};
validate_regular_directory(root)?;
// The membership check occurs before this function, so untrusted input
// never becomes a filename.
let path = root.join(format!("{game_id}.json"));
let bytes =
read_bounded_regular_file(&path, MAX_CATALOG_MANIFEST_BYTES, "catalog manifest")?;
let manifest = CatalogContentManifest::from_json_slice(&bytes)
.wrap_err_with(|| format!("invalid catalog manifest {}", path.display()))?;
if manifest.game_id() != game_id {
eyre::bail!(
"manifest filename/body game ID mismatch: expected {game_id}, found {}",
manifest.game_id()
);
}
if manifest.game_version() != expected_version {
eyre::bail!(
"manifest version mismatch for {game_id}: expected {expected_version}, found {}",
manifest.game_version()
);
}
self.content_index.validate_manifest(game_id, &manifest)?;
Ok(manifest)
}
}
fn validate_expected_versions(expected_versions: &BTreeMap<String, String>) -> eyre::Result<()> {
CatalogManifestStore::validate_expected_versions(expected_versions)
}
/// Atomically publishes the canonical JSON representation of one manifest.
pub fn write_canonical_manifest_atomic(
path: &Path,
manifest: &CatalogContentManifest,
) -> eyre::Result<()> {
write_catalog_artifact_atomic(path, &manifest.to_canonical_json()?, "manifest")
}
/// Atomically publishes the canonical compact content-identity index.
pub fn write_canonical_content_index_atomic(
path: &Path,
index: &CatalogContentIndex,
) -> eyre::Result<()> {
write_catalog_artifact_atomic(path, &index.to_canonical_json()?, "content index")
}
fn write_catalog_artifact_atomic(path: &Path, bytes: &[u8], label: &str) -> eyre::Result<()> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)?;
validate_regular_directory(parent)?;
let temp_path = unique_temp_path(path)?;
let mut cleanup = TempFileCleanup::new(temp_path.clone());
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path)
.wrap_err_with(|| {
format!(
"failed to create temporary catalog {label} {}",
temp_path.display()
)
})?;
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
fs::rename(&temp_path, path).wrap_err_with(|| {
format!(
"failed to atomically publish catalog {label} {} to {}",
temp_path.display(),
path.display()
)
})?;
cleanup.published = true;
sync_directory(parent)?;
Ok(())
}
fn read_bounded_regular_file(path: &Path, max_bytes: u64, label: &str) -> eyre::Result<Vec<u8>> {
let link_metadata = fs::symlink_metadata(path)
.wrap_err_with(|| format!("failed to inspect {label} {}", path.display()))?;
if is_link_or_reparse(&link_metadata) || !link_metadata.is_file() {
eyre::bail!("{label} is not a regular non-link file: {}", path.display());
}
if link_metadata.len() > max_bytes {
eyre::bail!("{label} exceeds size limit: {}", path.display());
}
let mut file = File::open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.len() > max_bytes || !same_file(&link_metadata, &metadata) {
eyre::bail!("{label} changed shape while opening: {}", path.display());
}
let capacity = usize::try_from(metadata.len())?;
let mut bytes = Vec::with_capacity(capacity);
Read::by_ref(&mut file)
.take(max_bytes + 1)
.read_to_end(&mut bytes)?;
if u64::try_from(bytes.len())? > max_bytes {
eyre::bail!("{label} exceeds size limit: {}", path.display());
}
Ok(bytes)
}
#[cfg(unix)]
fn same_file(before_open: &fs::Metadata, after_open: &fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
before_open.dev() == after_open.dev() && before_open.ino() == after_open.ino()
}
#[cfg(not(unix))]
fn same_file(_before_open: &fs::Metadata, _after_open: &fs::Metadata) -> bool {
true
}
fn validate_regular_directory(path: &Path) -> eyre::Result<()> {
let metadata = fs::symlink_metadata(path)
.wrap_err_with(|| format!("failed to inspect directory {}", path.display()))?;
if is_link_or_reparse(&metadata) || !metadata.is_dir() {
eyre::bail!(
"catalog manifest root is not a regular non-link directory: {}",
path.display()
);
}
Ok(())
}
fn has_json_extension(path: &Path) -> bool {
path.extension()
.and_then(OsStr::to_str)
.is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
}
fn unique_temp_path(path: &Path) -> eyre::Result<PathBuf> {
let file_name = path
.file_name()
.and_then(OsStr::to_str)
.ok_or_else(|| eyre::eyre!("manifest destination filename is not valid UTF-8"))?;
let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
Ok(path.with_file_name(format!(
".{file_name}.{}.{}.tmp",
std::process::id(),
sequence
)))
}
struct TempFileCleanup {
path: PathBuf,
published: bool,
}
impl TempFileCleanup {
fn new(path: PathBuf) -> Self {
Self {
path,
published: false,
}
}
}
impl Drop for TempFileCleanup {
fn drop(&mut self) {
if !self.published {
let _ = fs::remove_file(&self.path);
}
}
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> eyre::Result<()> {
File::open(path)?.sync_all()?;
Ok(())
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> eyre::Result<()> {
Ok(())
}
#[cfg(unix)]
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
metadata.file_type().is_symlink()
}
#[cfg(windows)]
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
metadata.file_type().is_symlink()
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(any(unix, windows)))]
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
metadata.file_type().is_symlink()
}
#[cfg(test)]
mod tests {
use std::{
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use super::*;
use crate::content_manifest::{
Blake3Digest,
CatalogContentIndexEntry,
CatalogContentManifestBody,
CatalogFileEntry,
ContentId,
};
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-db-manifest-{}-{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);
}
}
fn manifest(game_id: &str, version: &str) -> CatalogContentManifest {
let version_hash = Blake3Digest::hash(version.as_bytes());
CatalogContentManifest::seal(
CatalogContentManifestBody::new(
game_id,
version,
vec![
CatalogFileEntry::file(
"version.ini",
u64::try_from(version.len()).expect("version should fit u64"),
version_hash,
vec![version_hash],
)
.expect("version file should validate"),
],
Vec::new(),
)
.expect("body should validate"),
)
.expect("manifest should seal")
}
fn write_index(root: &Path, manifests: &[CatalogContentManifest]) {
let index = CatalogContentIndex::from_manifests(manifests)
.expect("test content index should validate");
write_canonical_content_index_atomic(&root.join(CATALOG_CONTENT_INDEX_NAME), &index)
.expect("test content index should publish");
}
fn write_synthetic_index(root: &Path, games: &[(&str, &str)]) {
let entries = games
.iter()
.enumerate()
.map(
|(position, (game_id, game_version))| CatalogContentIndexEntry {
game_id: (*game_id).to_owned(),
game_version: (*game_version).to_owned(),
identity: CatalogContentIdentity {
content_id: ContentId::from_bytes(
[u8::try_from(position + 1).expect("test position should fit u8"); 32],
),
supports_streamed_install: false,
},
},
);
let index = CatalogContentIndex::from_entries(entries)
.expect("synthetic test index should validate");
write_canonical_content_index_atomic(&root.join(CATALOG_CONTENT_INDEX_NAME), &index)
.expect("synthetic test index should publish");
}
#[test]
fn store_loads_known_exact_manifest_and_validates_coverage() {
let root = TestDir::new();
let manifest = manifest("g", "20240101");
write_canonical_manifest_atomic(&root.0.join("g.json"), &manifest)
.expect("manifest should publish");
write_index(&root.0, std::slice::from_ref(&manifest));
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
store.validate_all().expect("coverage should validate");
assert_eq!(
store.load("g").expect("manifest should load").content_id(),
manifest.content_id()
);
assert!(store.load("../g").is_err());
}
#[test]
fn content_identity_is_eager_while_manifest_body_remains_lazy() {
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
fs::write(root.0.join("g.json"), b"not JSON\n").expect("malformed body should write");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("canonical index should construct the store");
assert_eq!(
store.content_identity("g"),
Some(CatalogContentIdentity {
content_id: ContentId::from_bytes([1; 32]),
supports_streamed_install: false,
})
);
assert_eq!(store.content_identity("unknown"), None);
assert!(store.load_cached("g").is_err());
assert!(store.load("g").is_err());
assert!(store.load_cached("g").is_err());
}
#[test]
fn lazy_load_recomputes_against_the_index_captured_at_construction() {
let root = TestDir::new();
let manifest = manifest("g", "20240101");
write_synthetic_index(&root.0, &[("g", "20240101")]);
write_canonical_manifest_atomic(&root.0.join("g.json"), &manifest)
.expect("manifest should publish");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("synthetic index should construct the store");
write_index(&root.0, std::slice::from_ref(&manifest));
assert!(
store.load("g").is_err(),
"replacing the disk index must not replace captured authority"
);
assert!(store.validate_all().is_err());
}
#[test]
fn store_requires_a_canonical_exact_index_at_construction() {
let root = TestDir::new();
let expected = BTreeMap::from([("g".to_owned(), "20240101".to_owned())]);
assert!(CatalogManifestStore::new(&root.0, expected.clone()).is_err());
fs::write(root.0.join(CATALOG_CONTENT_INDEX_NAME), b"not JSON\n")
.expect("malformed index should write");
assert!(CatalogManifestStore::new(&root.0, expected.clone()).is_err());
write_synthetic_index(&root.0, &[("g", "20240101")]);
OpenOptions::new()
.append(true)
.open(root.0.join(CATALOG_CONTENT_INDEX_NAME))
.and_then(|mut file| file.write_all(b" "))
.expect("index should become noncanonical");
assert!(CatalogManifestStore::new(&root.0, expected.clone()).is_err());
let oversized = File::create(root.0.join(CATALOG_CONTENT_INDEX_NAME))
.expect("oversized index should be created");
oversized
.set_len(MAX_CATALOG_CONTENT_INDEX_BYTES + 1)
.expect("sparse index should resize");
assert!(CatalogManifestStore::new(&root.0, expected.clone()).is_err());
drop(oversized);
write_synthetic_index(&root.0, &[("g", "20250101")]);
assert!(CatalogManifestStore::new(&root.0, expected).is_err());
}
#[test]
fn cached_load_is_fail_closed_until_a_known_manifest_is_preloaded() {
let store = CatalogManifestStore::from_manifests([manifest("g", "20240101")])
.expect("in-memory store should construct");
assert!(store.load_cached("unknown").is_err());
assert!(store.load_cached("g").is_err());
let loaded = store.load("g").expect("known manifest should preload");
let cached = store
.load_cached("g")
.expect("preloaded manifest should be cache-readable");
assert!(Arc::ptr_eq(&loaded, &cached));
}
#[test]
fn store_rejects_platform_alias_catalog_ids_before_path_lookup() {
assert!(
CatalogManifestStore::new(
".",
BTreeMap::from([
("Game".to_owned(), "20240101".to_owned()),
("game".to_owned(), "20240101".to_owned()),
])
)
.is_err()
);
}
#[test]
fn validate_all_rejects_missing_and_unexpected_json() {
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
assert!(store.validate_all().is_err());
write_canonical_manifest_atomic(&root.0.join("g.json"), &manifest("g", "20240101"))
.expect("manifest should publish");
fs::write(root.0.join("other.json"), b"{}\n").expect("unexpected file should write");
assert!(store.validate_all().is_err());
}
#[test]
fn validate_coverage_checks_shape_without_parsing_manifest_bodies() {
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
fs::write(root.0.join("g.json"), b"not JSON\n")
.expect("opaque manifest artifact should write");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
store
.validate_coverage()
.expect("coverage validation must not parse manifest bodies");
assert!(store.load("g").is_err());
}
#[test]
fn validate_coverage_rejects_non_file_json_artifact() {
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
fs::create_dir(root.0.join("g.json")).expect("artifact directory should be created");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
assert!(store.validate_coverage().is_err());
}
#[test]
fn validate_coverage_rejects_filename_case_aliases() {
for alias in ["G.json", "g.JSON"] {
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
fs::write(root.0.join(alias), b"opaque\n").expect("alias artifact should write");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
assert!(store.validate_coverage().is_err(), "accepted alias {alias}");
}
}
#[test]
fn validate_coverage_allows_unrelated_non_json_packaging_files() {
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
fs::write(root.0.join("g.json"), b"opaque\n").expect("manifest artifact should write");
fs::write(root.0.join("README.txt"), b"packaging metadata\n")
.expect("packaging metadata should write");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
store
.validate_coverage()
.expect("unrelated non-JSON packaging files should be ignored");
}
#[test]
fn store_rejects_body_game_id_mismatch() {
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
write_canonical_manifest_atomic(&root.0.join("g.json"), &manifest("other", "20240101"))
.expect("manifest should publish");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
assert!(store.load("g").is_err());
}
#[test]
fn store_rejects_expected_version_mismatch() {
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20250101")]);
write_canonical_manifest_atomic(&root.0.join("g.json"), &manifest("g", "20240101"))
.expect("manifest should publish");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20250101".to_owned())]),
)
.expect("store should construct");
assert!(store.load("g").is_err());
}
#[test]
fn store_rejects_oversized_artifact_before_json_parsing() {
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
let file = File::create(root.0.join("g.json")).expect("artifact should be created");
file.set_len(MAX_CATALOG_MANIFEST_BYTES + 1)
.expect("sparse artifact should resize");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
assert!(store.load("g").is_err());
}
#[test]
fn validate_all_rechecks_disk_and_drops_a_stale_cache_on_failure() {
let root = TestDir::new();
let artifact = root.0.join("g.json");
let manifest = manifest("g", "20240101");
write_canonical_manifest_atomic(&artifact, &manifest).expect("manifest should publish");
write_index(&root.0, std::slice::from_ref(&manifest));
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
store.load("g").expect("manifest should populate cache");
fs::write(&artifact, b"not JSON\n").expect("artifact should be corrupted");
assert!(store.validate_all().is_err());
assert!(store.load("g").is_err());
}
#[test]
fn atomic_writer_emits_exact_canonical_bytes() {
let root = TestDir::new();
let artifact = root.0.join("g.json");
let manifest = manifest("g", "20240101");
write_canonical_manifest_atomic(&artifact, &manifest).expect("manifest should publish");
assert_eq!(
fs::read(&artifact).expect("artifact should read"),
manifest
.to_canonical_json()
.expect("manifest should encode")
);
assert_eq!(
fs::read_dir(&root.0)
.expect("directory should read")
.count(),
1
);
}
#[test]
fn atomic_writer_replaces_an_existing_manifest() {
let root = TestDir::new();
let artifact = root.0.join("g.json");
let initial = manifest("g", "20240101");
let replacement = manifest("g", "20250101");
write_canonical_manifest_atomic(&artifact, &initial)
.expect("initial manifest should publish");
write_canonical_manifest_atomic(&artifact, &replacement)
.expect("replacement manifest should publish");
assert_eq!(
fs::read(&artifact).expect("replacement artifact should read"),
replacement
.to_canonical_json()
.expect("replacement manifest should encode")
);
assert_eq!(
fs::read_dir(&root.0)
.expect("directory should read")
.count(),
1
);
}
#[cfg(unix)]
#[test]
fn store_rejects_symlink_manifest_artifact() {
use std::os::unix::fs::symlink;
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
let target = root.0.join("target");
fs::write(
&target,
manifest("g", "20240101")
.to_canonical_json()
.expect("JSON should encode"),
)
.expect("target should write");
symlink(&target, root.0.join("g.json")).expect("symlink should be created");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
assert!(store.load("g").is_err());
}
#[cfg(unix)]
#[test]
fn store_rejects_symlink_content_index_artifact() {
use std::os::unix::fs::symlink;
let root = TestDir::new();
let target = root.0.join("index-target");
let index = CatalogContentIndex::from_entries([CatalogContentIndexEntry {
game_id: "g".to_owned(),
game_version: "20240101".to_owned(),
identity: CatalogContentIdentity {
content_id: ContentId::from_bytes([1; 32]),
supports_streamed_install: false,
},
}])
.expect("test content index should validate");
write_canonical_content_index_atomic(&target, &index).expect("index target should publish");
symlink(&target, root.0.join(CATALOG_CONTENT_INDEX_NAME))
.expect("index symlink should be created");
assert!(
CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.is_err()
);
}
#[cfg(unix)]
#[test]
fn store_rejects_symlink_manifest_root_during_index_loading() {
use std::os::unix::fs::symlink;
let parent = TestDir::new();
let real_root = parent.0.join("real");
fs::create_dir(&real_root).expect("real root should be created");
let manifest = manifest("g", "20240101");
write_canonical_manifest_atomic(&real_root.join("g.json"), &manifest)
.expect("manifest should publish");
write_index(&real_root, std::slice::from_ref(&manifest));
let linked_root = parent.0.join("linked");
symlink(&real_root, &linked_root).expect("root symlink should be created");
assert!(
CatalogManifestStore::new(
&linked_root,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.is_err()
);
}
#[cfg(unix)]
#[test]
fn validate_coverage_rejects_symlink_manifest_artifact() {
use std::os::unix::fs::symlink;
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
let target = root.0.join("target");
fs::write(&target, b"opaque\n").expect("target should write");
symlink(&target, root.0.join("g.json")).expect("symlink should be created");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
assert!(store.validate_coverage().is_err());
}
#[cfg(unix)]
#[test]
fn load_rejects_manifest_root_replaced_with_symlink_after_coverage_check() {
use std::os::unix::fs::symlink;
let parent = TestDir::new();
let root = parent.0.join("manifests");
fs::create_dir(&root).expect("manifest root should be created");
let manifest = manifest("g", "20240101");
write_canonical_manifest_atomic(&root.join("g.json"), &manifest)
.expect("manifest should publish");
write_index(&root, std::slice::from_ref(&manifest));
let store = CatalogManifestStore::new(
&root,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
store
.validate_coverage()
.expect("initial coverage should validate");
let moved = parent.0.join("moved");
fs::rename(&root, &moved).expect("root should move");
symlink(&moved, &root).expect("replacement symlink should be created");
assert!(store.load("g").is_err());
}
#[cfg(unix)]
#[test]
fn validate_coverage_rejects_special_manifest_artifact() {
use std::os::unix::net::UnixListener;
let root = TestDir::new();
write_synthetic_index(&root.0, &[("g", "20240101")]);
let _socket = UnixListener::bind(root.0.join("g.json"))
.expect("Unix socket artifact should be created");
let store = CatalogManifestStore::new(
&root.0,
BTreeMap::from([("g".to_owned(), "20240101".to_owned())]),
)
.expect("store should construct");
assert!(store.validate_coverage().is_err());
}
}
+1 -74
View File
@@ -252,46 +252,11 @@ impl GameCatalog {
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct GameFileDescription {
pub game_id: String,
pub relative_path: String,
pub is_dir: bool,
pub size: u64,
}
impl GameFileDescription {
#[must_use]
pub fn is_version_ini(&self) -> bool {
let expected = format!("{}/version.ini", self.game_id);
self.relative_path.replace('\\', "/") == expected
}
#[must_use]
pub fn file_size(&self) -> u64 {
if self.is_dir { 0 } else { self.size }
}
}
impl fmt::Debug for GameFileDescription {
#[allow(clippy::cast_precision_loss)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}: [{}] path:{} size:{}",
self.game_id,
if self.is_dir { 'D' } else { 'F' },
self.relative_path,
self.size,
)
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{Availability, Game, GameFileDescription};
use super::{Availability, Game};
fn game_fixture() -> Game {
Game {
@@ -364,42 +329,4 @@ mod tests {
game.downloaded = true;
assert_eq!(game.normalized_availability(), Availability::Ready);
}
#[test]
fn version_ini_predicate_matches_only_game_root_sentinel() {
let root = GameFileDescription {
game_id: "aoe2".to_string(),
relative_path: "aoe2/version.ini".to_string(),
is_dir: false,
size: 8,
};
assert!(root.is_version_ini());
let nested = GameFileDescription {
game_id: "aoe2".to_string(),
relative_path: "aoe2/local/version.ini".to_string(),
is_dir: false,
size: 8,
};
assert!(!nested.is_version_ini());
let other_game = GameFileDescription {
game_id: "aoe2".to_string(),
relative_path: "other/version.ini".to_string(),
is_dir: false,
size: 8,
};
assert!(!other_game.is_version_ini());
}
#[test]
fn version_ini_predicate_accepts_windows_separators() {
let root = GameFileDescription {
game_id: "aoe2".to_string(),
relative_path: r"aoe2\version.ini".to_string(),
is_dir: false,
size: 8,
};
assert!(root.is_version_ini());
}
}
+1
View File
@@ -1 +1,2 @@
pub mod content_manifest;
pub mod db;