fix(peer): bound local library ingestion

Cap index reads, games, archive fingerprints, selected-root entries, recursive depth and bytes, scan time, and version.ini reads. Failed rescans retain the prior complete index and revision instead of publishing partial state.

Test Plan:
- just test
- just clippy
- focused oversize index, traversal, and prior-snapshot tests
- git diff --check
This commit is contained in:
2026-09-12 13:00:04 +02:00
parent 01d92b1922
commit cd50f78854
+450 -48
View File
@@ -3,13 +3,22 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
fs::Metadata, fs::Metadata,
io::{ErrorKind, Write as _}, io::{ErrorKind, Read as _, Write as _},
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::LazyLock, sync::LazyLock,
time::{SystemTime, UNIX_EPOCH}, time::{Duration, Instant, SystemTime, UNIX_EPOCH},
}; };
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB}; use lanspread_db::{
content_manifest::{
MAX_CATALOG_ENTRIES,
MAX_CATALOG_MANIFEST_BYTES,
MAX_CATALOG_PATH_BYTES,
MAX_CATALOG_TOTAL_BYTES,
},
db::{Availability, Game, GameCatalog, GameDB},
};
use lanspread_proto::MAX_LIBRARY_GAMES;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -151,7 +160,7 @@ pub async fn local_download_matches_catalog(
}; };
let game_path = game_dir.join(game_id); let game_path = game_dir.join(game_id);
let local_version = scoped_blocking(|| lanspread_db::db::read_version_from_ini(&game_path)); let local_version = scoped_blocking(|| read_version_from_ini_bounded(&game_path));
match local_version { match local_version {
Ok(Some(local_version)) if local_version == expected_version => true, Ok(Some(local_version)) if local_version == expected_version => true,
Ok(Some(local_version)) => { Ok(Some(local_version)) => {
@@ -175,6 +184,82 @@ pub async fn local_download_matches_catalog(
// ============================================================================= // =============================================================================
const LIBRARY_INDEX_FILE: &str = "library_index.json"; const LIBRARY_INDEX_FILE: &str = "library_index.json";
const MAX_LOCAL_LIBRARY_INDEX_BYTES: u64 = MAX_CATALOG_MANIFEST_BYTES;
const MAX_LOCAL_INDEX_ETI_FILES: usize = MAX_CATALOG_ENTRIES;
const MAX_SELECTED_ROOT_ENTRIES: usize = MAX_CATALOG_ENTRIES;
// A catalog can contain 100,000 explicit paths. Allow ample room for implicit
// parents and user-owned installed files without making one selected root an
// unbounded recursive workload.
const MAX_LOCAL_GAME_TREE_ENTRIES: usize = MAX_CATALOG_ENTRIES * 10 + 1;
// With non-empty components, a 900-byte canonical catalog path can contain at
// most 450 components. Reusing that ceiling avoids rejecting catalog-shaped
// installs while bounding traversal stacks for locally added content.
const MAX_LOCAL_GAME_TREE_DEPTH: usize = MAX_CATALOG_PATH_BYTES.div_ceil(2);
const MAX_LOCAL_SCAN_DURATION: Duration = Duration::from_mins(10);
const MAX_VERSION_INI_BYTES: u64 = 64 * 1024;
#[derive(Clone, Copy)]
struct TraversalLimits {
entry_cap: usize,
depth_ceiling: usize,
time_budget: Duration,
}
impl TraversalLimits {
const LOCAL_GAME_TREE: Self = Self {
entry_cap: MAX_LOCAL_GAME_TREE_ENTRIES,
depth_ceiling: MAX_LOCAL_GAME_TREE_DEPTH,
time_budget: MAX_LOCAL_SCAN_DURATION,
};
const SELECTED_ROOT: Self = Self {
entry_cap: MAX_SELECTED_ROOT_ENTRIES,
depth_ceiling: 1,
time_budget: MAX_LOCAL_SCAN_DURATION,
};
}
struct TraversalBudget {
limits: TraversalLimits,
entries: usize,
started: Instant,
}
impl TraversalBudget {
fn new(limits: TraversalLimits) -> Self {
Self {
limits,
entries: 0,
started: Instant::now(),
}
}
fn charge(&mut self, depth: usize, context: &str) -> eyre::Result<()> {
self.entries = self
.entries
.checked_add(1)
.ok_or_else(|| eyre::eyre!("{context} entry count overflow"))?;
if self.entries > self.limits.entry_cap {
eyre::bail!(
"{context} exceeds the {}-entry scan budget",
self.limits.entry_cap
);
}
if depth > self.limits.depth_ceiling {
eyre::bail!(
"{context} exceeds the {}-component depth budget",
self.limits.depth_ceiling
);
}
if self.started.elapsed() >= self.limits.time_budget {
eyre::bail!(
"{context} exceeds the {:?} scan-time budget",
self.limits.time_budget
);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
struct LibraryIndexRuntimeState { struct LibraryIndexRuntimeState {
@@ -193,10 +278,17 @@ struct LibraryIndex {
/// Converts a legacy selected-root cache into app-owned state without carrying /// Converts a legacy selected-root cache into app-owned state without carrying
/// its stale revision authority across the trust boundary. /// its stale revision authority across the trust boundary.
pub(crate) fn normalize_migrated_library_index(bytes: &[u8]) -> serde_json::Result<Vec<u8>> { pub(crate) fn normalize_migrated_library_index(bytes: &[u8]) -> eyre::Result<Vec<u8>> {
let mut index: LibraryIndex = serde_json::from_slice(bytes)?; let mut index: LibraryIndex = serde_json::from_slice(bytes)?;
validate_library_index_budget(&index)?;
index.revision = 0; index.revision = 0;
serde_json::to_vec(&index) let normalized = serde_json::to_vec(&index)?;
if u64::try_from(normalized.len()).unwrap_or(u64::MAX) > MAX_LOCAL_LIBRARY_INDEX_BYTES {
eyre::bail!(
"normalized local library index exceeds the {MAX_LOCAL_LIBRARY_INDEX_BYTES}-byte limit"
);
}
Ok(normalized)
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -273,39 +365,97 @@ fn sweep_stale_library_index_tmp(path: &Path) {
} }
} }
fn load_library_index(path: &Path) -> LibraryIndex { fn empty_library_index() -> LibraryIndex {
LibraryIndex {
revision: 0,
games: HashMap::new(),
}
}
fn validate_library_index_budget(index: &LibraryIndex) -> eyre::Result<()> {
if index.games.len() > MAX_LIBRARY_GAMES {
eyre::bail!("local library index exceeds the {MAX_LIBRARY_GAMES}-game publication limit");
}
let eti_files = index.games.values().try_fold(0_usize, |total, game| {
total
.checked_add(game.fingerprint.eti_files.len())
.ok_or_else(|| eyre::eyre!("local library index ETI count overflow"))
})?;
if eti_files > MAX_LOCAL_INDEX_ETI_FILES {
eyre::bail!(
"local library index exceeds the {MAX_LOCAL_INDEX_ETI_FILES}-ETI fingerprint limit"
);
}
Ok(())
}
fn read_bounded_regular_file(path: &Path, limit: u64) -> std::io::Result<Option<Vec<u8>>> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
if !metadata.is_file() || is_link_or_reparse(&metadata) {
return Err(std::io::Error::new(
ErrorKind::InvalidData,
format!("{} is not a non-link regular file", path.display()),
));
}
if metadata.len() > limit {
return Err(std::io::Error::new(
ErrorKind::InvalidData,
format!("{} exceeds the {limit}-byte read limit", path.display()),
));
}
let file = std::fs::File::open(path)?;
let opened = file.metadata()?;
if !opened.is_file() || opened.len() > limit {
return Err(std::io::Error::new(
ErrorKind::InvalidData,
format!("{} changed while opening", path.display()),
));
}
let capacity = usize::try_from(opened.len())
.map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?;
let mut bytes = Vec::with_capacity(capacity);
file.take(limit + 1).read_to_end(&mut bytes)?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
return Err(std::io::Error::new(
ErrorKind::InvalidData,
format!("{} exceeds the {limit}-byte read limit", path.display()),
));
}
Ok(Some(bytes))
}
fn load_library_index(path: &Path) -> eyre::Result<LibraryIndex> {
sweep_stale_library_index_tmp(path); sweep_stale_library_index_tmp(path);
let data = match std::fs::read_to_string(path) { let Some(data) = read_bounded_regular_file(path, MAX_LOCAL_LIBRARY_INDEX_BYTES)? else {
Ok(data) => data, return Ok(empty_library_index());
Err(err) => {
if err.kind() != ErrorKind::NotFound {
log::warn!("Failed to read library index {}: {err}", path.display());
}
return LibraryIndex {
revision: 0,
games: HashMap::new(),
};
}
}; };
match serde_json::from_str(&data) { match serde_json::from_slice(&data) {
Ok(index) => index, Ok(index) => {
validate_library_index_budget(&index)?;
Ok(index)
}
Err(err) => { Err(err) => {
log::warn!("Failed to parse library index {}: {err}", path.display()); log::warn!("Failed to parse library index {}: {err}", path.display());
LibraryIndex { Ok(empty_library_index())
revision: 0,
games: HashMap::new(),
}
} }
} }
} }
fn load_library_index_with_floor(path: &Path, revision_floor: u64) -> (LibraryIndex, bool) { fn load_library_index_with_floor(
let mut index = load_library_index(path); path: &Path,
revision_floor: u64,
) -> eyre::Result<(LibraryIndex, bool)> {
let mut index = load_library_index(path)?;
let revision_regressed = index.revision < revision_floor; let revision_regressed = index.revision < revision_floor;
index.revision = index.revision.max(revision_floor); index.revision = index.revision.max(revision_floor);
(index, revision_regressed) Ok((index, revision_regressed))
} }
fn advance_library_index_revision(index: &mut LibraryIndex) -> eyre::Result<()> { fn advance_library_index_revision(index: &mut LibraryIndex) -> eyre::Result<()> {
@@ -317,6 +467,7 @@ fn advance_library_index_revision(index: &mut LibraryIndex) -> eyre::Result<()>
} }
fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> { fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> {
validate_library_index_budget(index)?;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
// Persist `local_library/` in the state directory. Repeating this on // Persist `local_library/` in the state directory. Repeating this on
@@ -324,6 +475,9 @@ fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> {
sync_parent_dir(parent)?; sync_parent_dir(parent)?;
} }
let data = serde_json::to_vec_pretty(index)?; let data = serde_json::to_vec_pretty(index)?;
if u64::try_from(data.len()).unwrap_or(u64::MAX) > MAX_LOCAL_LIBRARY_INDEX_BYTES {
eyre::bail!("local library index exceeds the {MAX_LOCAL_LIBRARY_INDEX_BYTES}-byte limit");
}
let tmp_path = library_index_tmp_path(path); let tmp_path = library_index_tmp_path(path);
let mut file = std::fs::File::create(&tmp_path)?; let mut file = std::fs::File::create(&tmp_path)?;
@@ -369,8 +523,10 @@ fn root_eti_fingerprints(game_path: &Path) -> eyre::Result<Vec<EtiFingerprint>>
}; };
let mut eti_files = Vec::new(); let mut eti_files = Vec::new();
let mut budget = TraversalBudget::new(TraversalLimits::SELECTED_ROOT);
for entry in entries { for entry in entries {
let entry = entry?; let entry = entry?;
budget.charge(1, "game-root fingerprint")?;
let metadata = std::fs::symlink_metadata(entry.path())?; let metadata = std::fs::symlink_metadata(entry.path())?;
if is_link_or_reparse(&metadata) { if is_link_or_reparse(&metadata) {
eyre::bail!( eyre::bail!(
@@ -411,8 +567,19 @@ fn fingerprint_game_dir(
let version_path = game_path.join(VERSION_INI); let version_path = game_path.join(VERSION_INI);
let (version_mtime, version_contents) = match std::fs::symlink_metadata(&version_path) { let (version_mtime, version_contents) = match std::fs::symlink_metadata(&version_path) {
Ok(metadata) if metadata.is_file() && !is_link_or_reparse(&metadata) => { Ok(metadata) if metadata.is_file() && !is_link_or_reparse(&metadata) => {
let contents = match std::fs::read_to_string(&version_path) { let contents = match read_bounded_regular_file(&version_path, MAX_VERSION_INI_BYTES) {
Ok(contents) => Some(contents.trim().to_string()), Ok(Some(contents)) => match String::from_utf8(contents) {
Ok(contents) => Some(contents.trim().to_string()),
Err(err) => {
log::warn!(
"Failed to decode {} for fingerprinting: {err}",
version_path.display()
);
None
}
},
Ok(None) => None,
Err(err) if err.kind() == ErrorKind::InvalidData => return Err(err.into()),
Err(err) => { Err(err) => {
log::warn!( log::warn!(
"Failed to read {} for fingerprinting: {err}", "Failed to read {} for fingerprinting: {err}",
@@ -450,9 +617,20 @@ fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
} }
fn validate_game_tree_shape(game_path: &Path) -> eyre::Result<()> { fn validate_game_tree_shape(game_path: &Path) -> eyre::Result<()> {
let mut entries = walkdir::WalkDir::new(game_path).into_iter(); validate_game_tree_shape_with_limits(game_path, TraversalLimits::LOCAL_GAME_TREE)
}
fn validate_game_tree_shape_with_limits(
game_path: &Path,
limits: TraversalLimits,
) -> eyre::Result<()> {
let mut budget = TraversalBudget::new(limits);
let mut entries = walkdir::WalkDir::new(game_path)
.max_depth(limits.depth_ceiling.saturating_add(1))
.into_iter();
while let Some(entry) = entries.next() { while let Some(entry) = entries.next() {
let entry = entry?; let entry = entry?;
budget.charge(entry.depth(), "local game tree")?;
if should_skip_root_entry(&entry) { if should_skip_root_entry(&entry) {
entries.skip_current_dir(); entries.skip_current_dir();
continue; continue;
@@ -473,6 +651,14 @@ fn validate_game_tree_shape(game_path: &Path) -> eyre::Result<()> {
} }
fn scan_game_size(game_id: &str, game_dir: &Path) -> Result<u64, PeerError> { fn scan_game_size(game_id: &str, game_dir: &Path) -> Result<u64, PeerError> {
scan_game_size_with_limits(game_id, game_dir, TraversalLimits::LOCAL_GAME_TREE)
}
fn scan_game_size_with_limits(
game_id: &str,
game_dir: &Path,
limits: TraversalLimits,
) -> Result<u64, PeerError> {
let game_path = game_dir.join(game_id); let game_path = game_dir.join(game_id);
if !direct_game_root_is_safe_directory_sync(&game_path) { if !direct_game_root_is_safe_directory_sync(&game_path) {
@@ -483,9 +669,15 @@ fn scan_game_size(game_id: &str, game_dir: &Path) -> Result<u64, PeerError> {
} }
let mut total_size = 0_u64; let mut total_size = 0_u64;
let mut entries = walkdir::WalkDir::new(&game_path).into_iter(); let mut budget = TraversalBudget::new(limits);
let mut entries = walkdir::WalkDir::new(&game_path)
.max_depth(limits.depth_ceiling.saturating_add(1))
.into_iter();
while let Some(entry) = entries.next() { while let Some(entry) = entries.next() {
let entry = entry.map_err(|error| PeerError::Other(error.into()))?; let entry = entry.map_err(|error| PeerError::Other(error.into()))?;
budget
.charge(entry.depth(), "local game size scan")
.map_err(PeerError::Other)?;
if should_skip_root_entry(&entry) { if should_skip_root_entry(&entry) {
entries.skip_current_dir(); entries.skip_current_dir();
continue; continue;
@@ -515,6 +707,11 @@ fn scan_game_size(game_id: &str, game_dir: &Path) -> Result<u64, PeerError> {
total_size = total_size total_size = total_size
.checked_add(metadata.len()) .checked_add(metadata.len())
.ok_or_else(|| PeerError::Other(eyre::eyre!("local game size exceeds u64")))?; .ok_or_else(|| PeerError::Other(eyre::eyre!("local game size exceeds u64")))?;
if total_size > MAX_CATALOG_TOTAL_BYTES {
return Err(PeerError::Other(eyre::eyre!(
"local game size exceeds the {MAX_CATALOG_TOTAL_BYTES}-byte scan budget"
)));
}
} }
} }
@@ -534,7 +731,7 @@ fn build_game_summary(
let installed = !runtime_recovery_failed && local_dir_is_directory_sync(&game_path); let installed = !runtime_recovery_failed && local_dir_is_directory_sync(&game_path);
let eti_version = if downloaded { let eti_version = if downloaded {
match lanspread_db::db::read_version_from_ini(&game_path) { match read_version_from_ini_bounded(&game_path) {
Ok(version) => version, Ok(version) => version,
Err(e) => { Err(e) => {
log::warn!("Failed to read version.ini for downloaded game {game_id}: {e}"); log::warn!("Failed to read version.ini for downloaded game {game_id}: {e}");
@@ -563,6 +760,21 @@ fn build_game_summary(
}) })
} }
fn read_version_from_ini_bounded(game_path: &Path) -> eyre::Result<Option<String>> {
let version_path = game_path.join(VERSION_INI);
let Some(bytes) = read_bounded_regular_file(&version_path, MAX_VERSION_INI_BYTES)? else {
return Ok(None);
};
let content = String::from_utf8(bytes)?;
let version = content.trim();
if version.len() == 8 && version.chars().all(|character| character.is_ascii_digit()) {
Ok(Some(version.to_owned()))
} else {
log::warn!("Invalid version format in {}", version_path.display());
Ok(None)
}
}
pub(crate) fn game_from_summary(summary: &LocalGameSummary) -> Game { pub(crate) fn game_from_summary(summary: &LocalGameSummary) -> Game {
Game { Game {
id: summary.id.clone(), id: summary.id.clone(),
@@ -674,7 +886,7 @@ fn clear_index_and_scan_empty(
) -> eyre::Result<LocalLibraryScan> { ) -> eyre::Result<LocalLibraryScan> {
let index_path = library_index_path(state_dir); let index_path = library_index_path(state_dir);
let (mut index, revision_regressed) = let (mut index, revision_regressed) =
load_library_index_with_floor(&index_path, runtime_state.revision_floor); load_library_index_with_floor(&index_path, runtime_state.revision_floor)?;
if revision_regressed || runtime_state.rewrite_required || !index.games.is_empty() { if revision_regressed || runtime_state.rewrite_required || !index.games.is_empty() {
index.games.clear(); index.games.clear();
advance_library_index_revision(&mut index)?; advance_library_index_revision(&mut index)?;
@@ -725,13 +937,25 @@ fn settle_library_scan(
} }
} }
#[derive(Debug)]
enum GameDirectoryDiscovery { enum GameDirectoryDiscovery {
Missing, Missing,
Unsafe, Unsafe,
Games(Vec<String>), Games(Vec<String>),
} }
fn discover_game_ids(game_dir: &Path) -> eyre::Result<GameDirectoryDiscovery> { fn discover_game_ids(
game_dir: &Path,
catalog: &GameCatalog,
) -> eyre::Result<GameDirectoryDiscovery> {
discover_game_ids_with_limits(game_dir, catalog, TraversalLimits::SELECTED_ROOT)
}
fn discover_game_ids_with_limits(
game_dir: &Path,
catalog: &GameCatalog,
limits: TraversalLimits,
) -> eyre::Result<GameDirectoryDiscovery> {
let metadata = match std::fs::symlink_metadata(game_dir) { let metadata = match std::fs::symlink_metadata(game_dir) {
Ok(metadata) => metadata, Ok(metadata) => metadata,
Err(err) if err.kind() == ErrorKind::NotFound => { Err(err) if err.kind() == ErrorKind::NotFound => {
@@ -745,21 +969,30 @@ fn discover_game_ids(game_dir: &Path) -> eyre::Result<GameDirectoryDiscovery> {
} }
let mut game_ids = Vec::new(); let mut game_ids = Vec::new();
let mut budget = TraversalBudget::new(limits);
for entry in std::fs::read_dir(game_dir)? { for entry in std::fs::read_dir(game_dir)? {
let entry = entry?; let entry = entry?;
budget.charge(1, "selected game directory")?;
let Some(game_id) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue;
};
if is_ignored_games_root_name(&game_id) || !catalog.contains(&game_id) {
continue;
}
let path = entry.path(); let path = entry.path();
let metadata = std::fs::symlink_metadata(&path)?; let metadata = std::fs::symlink_metadata(&path)?;
if !metadata.is_dir() || is_link_or_reparse(&metadata) { if !metadata.is_dir() || is_link_or_reparse(&metadata) {
continue; continue;
} }
let Some(game_id) = path.file_name().and_then(|name| name.to_str()) else { game_ids.push(game_id);
continue; if game_ids.len() > MAX_LIBRARY_GAMES {
}; eyre::bail!(
if !is_ignored_games_root_name(game_id) { "selected game directory exceeds the {MAX_LIBRARY_GAMES}-game publication limit"
game_ids.push(game_id.to_string()); );
} }
} }
game_ids.sort();
Ok(GameDirectoryDiscovery::Games(game_ids)) Ok(GameDirectoryDiscovery::Games(game_ids))
} }
@@ -801,13 +1034,19 @@ fn scan_discovered_games(
let index_path = library_index_path(state_dir); let index_path = library_index_path(state_dir);
let (mut index, revision_regressed) = let (mut index, revision_regressed) =
load_library_index_with_floor(&index_path, runtime_state.revision_floor); load_library_index_with_floor(&index_path, runtime_state.revision_floor)?;
let mut seen_ids = HashSet::new(); let mut seen_ids = HashSet::new();
let mut summaries = HashMap::new(); let mut summaries = HashMap::new();
let mut games = Vec::new(); let mut games = Vec::new();
let mut changed = revision_regressed || runtime_state.rewrite_required; let mut changed = revision_regressed || runtime_state.rewrite_required;
let scan_started = Instant::now();
for game_id in game_ids { for game_id in game_ids {
if scan_started.elapsed() >= MAX_LOCAL_SCAN_DURATION {
eyre::bail!(
"local library exceeds the {MAX_LOCAL_SCAN_DURATION:?} aggregate scan-time budget"
);
}
let readiness = download_readiness let readiness = download_readiness
.get(game_id) .get(game_id)
.copied() .copied()
@@ -831,6 +1070,12 @@ fn scan_discovered_games(
games.push(game_from_summary(&summary)); games.push(game_from_summary(&summary));
} }
if scan_started.elapsed() >= MAX_LOCAL_SCAN_DURATION {
eyre::bail!(
"local library exceeds the {MAX_LOCAL_SCAN_DURATION:?} aggregate scan-time budget"
);
}
let before = index.games.len(); let before = index.games.len();
index.games.retain(|game_id, _| seen_ids.contains(game_id)); index.games.retain(|game_id, _| seen_ids.contains(game_id));
if index.games.len() != before { if index.games.len() != before {
@@ -861,7 +1106,7 @@ fn rescan_game(
) -> eyre::Result<LocalLibraryScan> { ) -> eyre::Result<LocalLibraryScan> {
let index_path = library_index_path(state_dir); let index_path = library_index_path(state_dir);
let (mut index, revision_regressed) = let (mut index, revision_regressed) =
load_library_index_with_floor(&index_path, runtime_state.revision_floor); load_library_index_with_floor(&index_path, runtime_state.revision_floor)?;
let update = update_index_for_game( let update = update_index_for_game(
game_dir, game_dir,
@@ -906,7 +1151,7 @@ pub(crate) async fn scan_local_library_with_recovery_failures(
let index_path = library_index_path(state_path); let index_path = library_index_path(state_path);
let mut states = LIBRARY_INDEX_STATES.lock().await; let mut states = LIBRARY_INDEX_STATES.lock().await;
let runtime_state = states.get(&index_path).copied().unwrap_or_default(); let runtime_state = states.get(&index_path).copied().unwrap_or_default();
let discovery = scoped_blocking(|| discover_game_ids(game_path))?; let discovery = scoped_blocking(|| discover_game_ids(game_path, catalog))?;
let game_ids = match discovery { let game_ids = match discovery {
GameDirectoryDiscovery::Missing => { GameDirectoryDiscovery::Missing => {
log::warn!( log::warn!(
@@ -1074,6 +1319,25 @@ mod tests {
); );
} }
#[test]
fn migrated_library_index_rejects_excess_game_entries() {
let mut template_index = test_library_index(1, "game", 1);
let template = template_index
.games
.remove("game")
.expect("template entry should exist");
let games = (0..=MAX_LIBRARY_GAMES)
.map(|index| (format!("game-{index}"), template.clone()))
.collect();
let oversized = LibraryIndex { revision: 9, games };
let bytes = serde_json::to_vec(&oversized).expect("oversized index should serialize");
let error = normalize_migrated_library_index(&bytes)
.expect_err("legacy index above the game-entry budget must be rejected");
assert!(error.to_string().contains("game publication limit"));
}
#[test] #[test]
fn legacy_fingerprint_defaults_download_recovery_to_false() { fn legacy_fingerprint_defaults_download_recovery_to_false() {
let fingerprint: GameFingerprint = serde_json::from_value(serde_json::json!({ let fingerprint: GameFingerprint = serde_json::from_value(serde_json::json!({
@@ -1102,7 +1366,7 @@ mod tests {
save_library_index(&index_path, &second).expect("replacement index write should succeed"); save_library_index(&index_path, &second).expect("replacement index write should succeed");
assert!(!tmp_path.exists()); assert!(!tmp_path.exists());
let loaded = load_library_index(&index_path); let loaded = load_library_index(&index_path).expect("saved index should load");
assert_eq!(loaded.revision, 2); assert_eq!(loaded.revision, 2);
assert!(!loaded.games.contains_key("game-a")); assert!(!loaded.games.contains_key("game-a"));
let game_b = loaded let game_b = loaded
@@ -1123,13 +1387,106 @@ mod tests {
write_file(&index_path, &data); write_file(&index_path, &data);
write_file(&tmp_path, b"{ not json"); write_file(&tmp_path, b"{ not json");
let loaded = load_library_index(&index_path); let loaded = load_library_index(&index_path).expect("index should load");
assert_eq!(loaded.revision, 7); assert_eq!(loaded.revision, 7);
assert!(loaded.games.contains_key("game")); assert!(loaded.games.contains_key("game"));
assert!(!tmp_path.exists()); assert!(!tmp_path.exists());
} }
#[test]
fn oversized_current_index_is_rejected_without_replacement() {
let state = TempDir::new("lanspread-local-index-budget");
let index_path = library_index_path(state.path());
std::fs::create_dir_all(index_path.parent().expect("index should have a parent"))
.expect("index parent should be created");
std::fs::File::create(&index_path)
.expect("index should be created")
.set_len(MAX_LOCAL_LIBRARY_INDEX_BYTES + 1)
.expect("index should become oversized");
let error = load_library_index(&index_path)
.expect_err("an oversized current index must fail closed");
assert!(error.to_string().contains("read limit"));
assert_eq!(
std::fs::metadata(&index_path)
.expect("oversized index should remain")
.len(),
MAX_LOCAL_LIBRARY_INDEX_BYTES + 1
);
}
#[test]
fn recursive_game_scans_enforce_entry_depth_and_time_budgets() {
let games = TempDir::new("lanspread-local-tree-budget");
write_file(&games.path().join("game/nested/payload.bin"), b"payload");
let entry_limits = TraversalLimits {
entry_cap: 1,
depth_ceiling: MAX_LOCAL_GAME_TREE_DEPTH,
time_budget: MAX_LOCAL_SCAN_DURATION,
};
assert!(
validate_game_tree_shape_with_limits(&games.path().join("game"), entry_limits)
.expect_err("a one-entry tree budget must reject a child")
.to_string()
.contains("entry scan budget")
);
assert!(scan_game_size_with_limits("game", games.path(), entry_limits).is_err());
let depth_limits = TraversalLimits {
entry_cap: 16,
depth_ceiling: 1,
time_budget: MAX_LOCAL_SCAN_DURATION,
};
assert!(
validate_game_tree_shape_with_limits(&games.path().join("game"), depth_limits)
.expect_err("a one-component tree budget must reject a nested file")
.to_string()
.contains("depth budget")
);
let time_limits = TraversalLimits {
entry_cap: 16,
depth_ceiling: MAX_LOCAL_GAME_TREE_DEPTH,
time_budget: Duration::ZERO,
};
assert!(
validate_game_tree_shape_with_limits(&games.path().join("game"), time_limits)
.expect_err("a zero-duration tree budget must fail immediately")
.to_string()
.contains("scan-time budget")
);
}
#[test]
fn selected_root_discovery_filters_before_retention_and_is_bounded() {
let games = TempDir::new("lanspread-local-root-budget");
write_file(&games.path().join("game/version.ini"), b"20250101");
write_file(&games.path().join("unknown/version.ini"), b"20250101");
let catalog = GameCatalog::from_ids(["game".to_string()]);
let GameDirectoryDiscovery::Games(discovered) =
discover_game_ids(games.path(), &catalog).expect("root discovery should succeed")
else {
panic!("existing root should be discovered");
};
assert_eq!(discovered, vec!["game"]);
let error = discover_game_ids_with_limits(
games.path(),
&catalog,
TraversalLimits {
entry_cap: 1,
depth_ceiling: 1,
time_budget: MAX_LOCAL_SCAN_DURATION,
},
)
.expect_err("two root entries must exceed a one-entry budget");
assert!(error.to_string().contains("entry scan budget"));
}
#[tokio::test] #[tokio::test]
async fn failed_index_save_does_not_return_or_record_a_revision() { async fn failed_index_save_does_not_return_or_record_a_revision() {
let games = TempDir::new("lanspread-local-games-save-failure"); let games = TempDir::new("lanspread-local-games-save-failure");
@@ -1154,7 +1511,44 @@ mod tests {
.expect("scan should succeed once durable storage is available"); .expect("scan should succeed once durable storage is available");
assert_eq!(retry.revision, 1); assert_eq!(retry.revision, 1);
assert_eq!(load_library_index(&index_path).revision, 1); assert_eq!(
load_library_index(&index_path)
.expect("saved index should load")
.revision,
1
);
}
#[tokio::test]
async fn oversized_version_read_keeps_the_previous_index_generation() {
let games = TempDir::new("lanspread-local-version-budget");
let state = TempDir::new("lanspread-local-version-budget-state");
let catalog = GameCatalog::from_ids(["game".to_string()]);
let version_path = games.path().join("game/version.ini");
write_file(&version_path, b"20250101");
let initial = scan_local_library(games.path(), state.path(), &catalog)
.await
.expect("initial scan should succeed");
std::fs::OpenOptions::new()
.write(true)
.open(&version_path)
.expect("version file should open")
.set_len(MAX_VERSION_INI_BYTES + 1)
.expect("version file should become oversized");
let error = rescan_local_game(games.path(), state.path(), &catalog, "game")
.await
.expect_err("oversized version input must reject the rescan");
assert!(error.to_string().contains("read limit"));
let persisted = load_library_index(&library_index_path(state.path()))
.expect("prior index should remain readable");
assert_eq!(persisted.revision, initial.revision);
assert_eq!(
persisted.games["game"].summary.eti_version.as_deref(),
Some("20250101")
);
} }
#[tokio::test] #[tokio::test]
@@ -1352,7 +1746,9 @@ mod tests {
); );
assert!(scan_game_size("game", games.path()).is_err()); assert!(scan_game_size("game", games.path()).is_err());
assert_eq!( assert_eq!(
load_library_index(&library_index_path(state.path())).revision, load_library_index(&library_index_path(state.path()))
.expect("saved index should load")
.revision,
initial.revision, initial.revision,
"a rejected tree must not advance the durable index" "a rejected tree must not advance the durable index"
); );
@@ -1571,7 +1967,12 @@ mod tests {
assert_eq!(rebuilt.revision, second.revision + 1); assert_eq!(rebuilt.revision, second.revision + 1);
assert!(rebuilt.summaries.contains_key("game")); assert!(rebuilt.summaries.contains_key("game"));
assert_eq!(load_library_index(&index_path).revision, rebuilt.revision); assert_eq!(
load_library_index(&index_path)
.expect("rebuilt index should load")
.revision,
rebuilt.revision
);
} }
#[tokio::test] #[tokio::test]
@@ -1597,7 +1998,8 @@ mod tests {
scan_a.expect("game-a rescan should succeed"); scan_a.expect("game-a rescan should succeed");
scan_b.expect("game-b rescan should succeed"); scan_b.expect("game-b rescan should succeed");
let index = load_library_index(&library_index_path(state.path())); let index = load_library_index(&library_index_path(state.path()))
.expect("concurrent index should load");
assert_eq!(index.revision, 3); assert_eq!(index.revision, 3);
let game_a = index let game_a = index
.games .games