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::{
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user