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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 +1,2 @@
|
||||
pub mod content_manifest;
|
||||
pub mod db;
|
||||
|
||||
Reference in New Issue
Block a user