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:
@@ -3,13 +3,22 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs::Metadata,
|
||||
io::{ErrorKind, Write as _},
|
||||
io::{ErrorKind, Read as _, Write as _},
|
||||
path::{Path, PathBuf},
|
||||
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 tokio::sync::Mutex;
|
||||
|
||||
@@ -151,7 +160,7 @@ pub async fn local_download_matches_catalog(
|
||||
};
|
||||
|
||||
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 {
|
||||
Ok(Some(local_version)) if local_version == expected_version => true,
|
||||
Ok(Some(local_version)) => {
|
||||
@@ -175,6 +184,82 @@ pub async fn local_download_matches_catalog(
|
||||
// =============================================================================
|
||||
|
||||
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)]
|
||||
struct LibraryIndexRuntimeState {
|
||||
@@ -193,10 +278,17 @@ struct LibraryIndex {
|
||||
|
||||
/// Converts a legacy selected-root cache into app-owned state without carrying
|
||||
/// 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)?;
|
||||
validate_library_index_budget(&index)?;
|
||||
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)]
|
||||
@@ -273,39 +365,97 @@ fn sweep_stale_library_index_tmp(path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_library_index(path: &Path) -> LibraryIndex {
|
||||
sweep_stale_library_index_tmp(path);
|
||||
|
||||
let data = match std::fs::read_to_string(path) {
|
||||
Ok(data) => data,
|
||||
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) {
|
||||
Ok(index) => index,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to parse library index {}: {err}", path.display());
|
||||
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);
|
||||
|
||||
let Some(data) = read_bounded_regular_file(path, MAX_LOCAL_LIBRARY_INDEX_BYTES)? else {
|
||||
return Ok(empty_library_index());
|
||||
};
|
||||
|
||||
match serde_json::from_slice(&data) {
|
||||
Ok(index) => {
|
||||
validate_library_index_budget(&index)?;
|
||||
Ok(index)
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to parse library index {}: {err}", path.display());
|
||||
Ok(empty_library_index())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_library_index_with_floor(path: &Path, revision_floor: u64) -> (LibraryIndex, bool) {
|
||||
let mut index = load_library_index(path);
|
||||
fn load_library_index_with_floor(
|
||||
path: &Path,
|
||||
revision_floor: u64,
|
||||
) -> eyre::Result<(LibraryIndex, bool)> {
|
||||
let mut index = load_library_index(path)?;
|
||||
let revision_regressed = index.revision < 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<()> {
|
||||
@@ -317,6 +467,7 @@ fn advance_library_index_revision(index: &mut LibraryIndex) -> eyre::Result<()>
|
||||
}
|
||||
|
||||
fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> {
|
||||
validate_library_index_budget(index)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
// 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)?;
|
||||
}
|
||||
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 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 budget = TraversalBudget::new(TraversalLimits::SELECTED_ROOT);
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
budget.charge(1, "game-root fingerprint")?;
|
||||
let metadata = std::fs::symlink_metadata(entry.path())?;
|
||||
if is_link_or_reparse(&metadata) {
|
||||
eyre::bail!(
|
||||
@@ -411,8 +567,19 @@ fn fingerprint_game_dir(
|
||||
let version_path = game_path.join(VERSION_INI);
|
||||
let (version_mtime, version_contents) = match std::fs::symlink_metadata(&version_path) {
|
||||
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(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) => {
|
||||
log::warn!(
|
||||
"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<()> {
|
||||
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() {
|
||||
let entry = entry?;
|
||||
budget.charge(entry.depth(), "local game tree")?;
|
||||
if should_skip_root_entry(&entry) {
|
||||
entries.skip_current_dir();
|
||||
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> {
|
||||
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);
|
||||
|
||||
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 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() {
|
||||
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) {
|
||||
entries.skip_current_dir();
|
||||
continue;
|
||||
@@ -515,6 +707,11 @@ fn scan_game_size(game_id: &str, game_dir: &Path) -> Result<u64, PeerError> {
|
||||
total_size = total_size
|
||||
.checked_add(metadata.len())
|
||||
.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 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,
|
||||
Err(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 {
|
||||
Game {
|
||||
id: summary.id.clone(),
|
||||
@@ -674,7 +886,7 @@ fn clear_index_and_scan_empty(
|
||||
) -> eyre::Result<LocalLibraryScan> {
|
||||
let index_path = library_index_path(state_dir);
|
||||
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() {
|
||||
index.games.clear();
|
||||
advance_library_index_revision(&mut index)?;
|
||||
@@ -725,13 +937,25 @@ fn settle_library_scan(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum GameDirectoryDiscovery {
|
||||
Missing,
|
||||
Unsafe,
|
||||
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) {
|
||||
Ok(metadata) => metadata,
|
||||
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 budget = TraversalBudget::new(limits);
|
||||
for entry in std::fs::read_dir(game_dir)? {
|
||||
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 metadata = std::fs::symlink_metadata(&path)?;
|
||||
if !metadata.is_dir() || is_link_or_reparse(&metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(game_id) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !is_ignored_games_root_name(game_id) {
|
||||
game_ids.push(game_id.to_string());
|
||||
game_ids.push(game_id);
|
||||
if game_ids.len() > MAX_LIBRARY_GAMES {
|
||||
eyre::bail!(
|
||||
"selected game directory exceeds the {MAX_LIBRARY_GAMES}-game publication limit"
|
||||
);
|
||||
}
|
||||
}
|
||||
game_ids.sort();
|
||||
|
||||
Ok(GameDirectoryDiscovery::Games(game_ids))
|
||||
}
|
||||
@@ -801,13 +1034,19 @@ fn scan_discovered_games(
|
||||
|
||||
let index_path = library_index_path(state_dir);
|
||||
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 summaries = HashMap::new();
|
||||
let mut games = Vec::new();
|
||||
let mut changed = revision_regressed || runtime_state.rewrite_required;
|
||||
let scan_started = Instant::now();
|
||||
|
||||
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
|
||||
.get(game_id)
|
||||
.copied()
|
||||
@@ -831,6 +1070,12 @@ fn scan_discovered_games(
|
||||
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();
|
||||
index.games.retain(|game_id, _| seen_ids.contains(game_id));
|
||||
if index.games.len() != before {
|
||||
@@ -861,7 +1106,7 @@ fn rescan_game(
|
||||
) -> eyre::Result<LocalLibraryScan> {
|
||||
let index_path = library_index_path(state_dir);
|
||||
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(
|
||||
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 mut states = LIBRARY_INDEX_STATES.lock().await;
|
||||
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 {
|
||||
GameDirectoryDiscovery::Missing => {
|
||||
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]
|
||||
fn legacy_fingerprint_defaults_download_recovery_to_false() {
|
||||
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");
|
||||
|
||||
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!(!loaded.games.contains_key("game-a"));
|
||||
let game_b = loaded
|
||||
@@ -1123,13 +1387,106 @@ mod tests {
|
||||
write_file(&index_path, &data);
|
||||
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!(loaded.games.contains_key("game"));
|
||||
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]
|
||||
async fn failed_index_save_does_not_return_or_record_a_revision() {
|
||||
let games = TempDir::new("lanspread-local-games-save-failure");
|
||||
@@ -1154,7 +1511,44 @@ mod tests {
|
||||
.expect("scan should succeed once durable storage is available");
|
||||
|
||||
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]
|
||||
@@ -1352,7 +1746,9 @@ mod tests {
|
||||
);
|
||||
assert!(scan_game_size("game", games.path()).is_err());
|
||||
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,
|
||||
"a rejected tree must not advance the durable index"
|
||||
);
|
||||
@@ -1571,7 +1967,12 @@ mod tests {
|
||||
|
||||
assert_eq!(rebuilt.revision, second.revision + 1);
|
||||
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]
|
||||
@@ -1597,7 +1998,8 @@ mod tests {
|
||||
scan_a.expect("game-a 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);
|
||||
let game_a = index
|
||||
.games
|
||||
|
||||
Reference in New Issue
Block a user