diff --git a/crates/lanspread-peer/src/launch_settings.rs b/crates/lanspread-peer/src/launch_settings.rs index 718e4b9..2ac02b2 100644 --- a/crates/lanspread-peer/src/launch_settings.rs +++ b/crates/lanspread-peer/src/launch_settings.rs @@ -15,11 +15,13 @@ use std::{ ffi::OsStr, - io::ErrorKind, + io::{ErrorKind, Read as _}, path::{Path, PathBuf}, + time::{Duration, Instant}, }; use eyre::WrapErr; +use lanspread_db::content_manifest::{MAX_CATALOG_ENTRIES, MAX_CATALOG_PATH_BYTES}; use crate::{ game_paths::LOCAL_DIR, @@ -34,6 +36,67 @@ const PERSONA_NAME_KEY: &str = "PersonaName"; const DEFAULT_STREAM_INSTALL_NAME: &str = "Commander"; const DEFAULT_STREAM_INSTALL_LANGUAGE: &str = "english"; const MAX_STREAM_INSTALL_NAME_CHARS: usize = 24; +const MAX_LAUNCH_SETTINGS_FILE_BYTES: u64 = 1024 * 1024; +const MAX_LAUNCH_SETTINGS_ENTRIES: usize = MAX_CATALOG_ENTRIES; +const MAX_LAUNCH_SETTINGS_DEPTH: usize = MAX_CATALOG_PATH_BYTES.div_ceil(2); +const MAX_LAUNCH_SETTINGS_SCAN_DURATION: Duration = Duration::from_mins(10); + +#[derive(Clone, Copy)] +struct SearchLimits { + entry_cap: usize, + depth_ceiling: usize, + time_budget: Duration, +} + +impl SearchLimits { + const PRODUCTION: Self = Self { + entry_cap: MAX_LAUNCH_SETTINGS_ENTRIES, + depth_ceiling: MAX_LAUNCH_SETTINGS_DEPTH, + time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION, + }; +} + +struct SearchBudget { + limits: SearchLimits, + entries: usize, + started: Instant, +} + +impl SearchBudget { + fn new(limits: SearchLimits) -> Self { + Self { + limits, + entries: 0, + started: Instant::now(), + } + } + + fn charge(&mut self, depth: usize) -> eyre::Result<()> { + self.entries = self + .entries + .checked_add(1) + .ok_or_else(|| eyre::eyre!("launch-settings entry count overflow"))?; + if self.entries > self.limits.entry_cap { + eyre::bail!( + "launch-settings search exceeds the {}-entry budget", + self.limits.entry_cap + ); + } + if depth > self.limits.depth_ceiling { + eyre::bail!( + "launch-settings search exceeds the {}-component depth budget", + self.limits.depth_ceiling + ); + } + if self.started.elapsed() >= self.limits.time_budget { + eyre::bail!( + "launch-settings search exceeds the {:?} scan-time budget", + self.limits.time_budget + ); + } + Ok(()) + } +} /// Sanitized per-user values applied inside a verified Stream Install staging tree. #[derive(Clone, Debug, Eq, PartialEq)] @@ -209,58 +272,74 @@ fn apply_launch_settings_to_tree_blocking( language: Option<&str>, persona_name: Option<&str>, ) -> eyre::Result { + if account_name.is_none() && language.is_none() && persona_name.is_none() { + return Ok(LaunchSettingsOutcome::default()); + } + let mut budget = SearchBudget::new(SearchLimits::PRODUCTION); + let SettingsCandidates { + account_names, + languages, + persona_files, + } = find_settings_candidates(local_root, &mut budget)?; + let account_path = account_name.and(account_names.into_iter().next()); + let language_path = language.and(languages.into_iter().next()); + let persona_update = if let Some(persona_name) = persona_name { + let mut update = None; + for path in persona_files { + let content = read_bounded_settings_file(&path)?; + if let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) { + update = Some((path, rewritten)); + break; + } + } + update + } else { + None + }; + + // Finish all bounded traversal and input reads before the first write. A + // budget failure therefore cannot leave only a prefix of the settings set + // updated or mark that partial attempt as complete. + if let (Some(path), Some(value)) = (&account_path, account_name) { + std::fs::write(path, value) + .wrap_err_with(|| format!("failed to write {}", path.display()))?; + } + if let (Some(path), Some(value)) = (&language_path, language) { + std::fs::write(path, value) + .wrap_err_with(|| format!("failed to write {}", path.display()))?; + } + if let Some((path, rewritten)) = &persona_update { + std::fs::write(path, rewritten) + .wrap_err_with(|| format!("failed to write {}", path.display()))?; + } + Ok(LaunchSettingsOutcome { already_applied: false, - account_name_written: overwrite_first_file(local_root, ACCOUNT_NAME_FILE, account_name)?, - language_written: overwrite_first_file(local_root, LANGUAGE_FILE, language)?, - persona_name_written: rewrite_first_persona_name(local_root, persona_name)?, + account_name_written: account_path.is_some(), + language_written: language_path.is_some(), + persona_name_written: persona_update.is_some(), }) } -/// Overwrite the first file named `file_name` under `root` with `value`. -/// -/// Returns `false` without touching anything when `value` is `None` or no such -/// file exists. -fn overwrite_first_file(root: &Path, file_name: &str, value: Option<&str>) -> eyre::Result { - let Some(value) = value else { - return Ok(false); - }; - let Some(path) = find_first_file(root, file_name)? else { - return Ok(false); - }; - - std::fs::write(&path, value).wrap_err_with(|| format!("failed to write {}", path.display()))?; - Ok(true) +#[derive(Debug, Default)] +struct SettingsCandidates { + account_names: Vec, + languages: Vec, + persona_files: Vec, } -/// Rewrite the first `PersonaName` line found in any `SmartSteamEmu.ini` under `root`. -fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> eyre::Result { - let Some(persona_name) = persona_name else { - return Ok(false); - }; - - for path in find_files(root, SMART_STEAM_EMU_INI)? { - let content = std::fs::read_to_string(&path) - .wrap_err_with(|| format!("failed to read {}", path.display()))?; - let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else { - continue; - }; - - std::fs::write(&path, rewritten) - .wrap_err_with(|| format!("failed to write {}", path.display()))?; - return Ok(true); - } - - Ok(false) -} - -/// Find the first regular file named `file_name` anywhere under `root`. +/// Find every launcher-setting candidate in one bounded traversal. /// -/// A missing `root` (for example an uninstalled game with no `local/`) yields -/// `None`. Directories are visited in sorted order for deterministic results. -fn find_first_file(root: &Path, file_name: &str) -> eyre::Result> { - let mut pending_dirs = vec![root.to_path_buf()]; - while let Some(dir) = pending_dirs.pop() { +/// A missing `root` yields an empty set. Sorting complete relative paths keeps +/// the former deterministic first-match behavior without walking a large tree +/// three separate times. +fn find_settings_candidates( + root: &Path, + budget: &mut SearchBudget, +) -> eyre::Result { + let mut candidates = SettingsCandidates::default(); + let mut pending_dirs = vec![(root.to_path_buf(), 0_usize)]; + while let Some((dir, depth)) = pending_dirs.pop() { let entries = match std::fs::read_dir(&dir) { Ok(entries) => entries, Err(err) if err.kind() == ErrorKind::NotFound => continue, @@ -272,12 +351,21 @@ fn find_first_file(root: &Path, file_name: &str) -> eyre::Result let mut child_dirs = Vec::new(); for entry in entries { let entry = entry?; + let child_depth = depth.saturating_add(1); + budget.charge(child_depth)?; let file_type = entry.file_type()?; let path = entry.path(); if file_type.is_dir() { - child_dirs.push(path); - } else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) { - return Ok(Some(path)); + child_dirs.push((path, child_depth)); + } else if file_type.is_file() { + let name = entry.file_name(); + if name == OsStr::new(ACCOUNT_NAME_FILE) { + candidates.account_names.push(path); + } else if name == OsStr::new(LANGUAGE_FILE) { + candidates.languages.push(path); + } else if name == OsStr::new(SMART_STEAM_EMU_INI) { + candidates.persona_files.push(path); + } } } @@ -286,43 +374,41 @@ fn find_first_file(root: &Path, file_name: &str) -> eyre::Result pending_dirs.extend(child_dirs); } - Ok(None) + candidates.account_names.sort(); + candidates.languages.sort(); + candidates.persona_files.sort(); + Ok(candidates) } -/// Find every regular file named `file_name` anywhere under `root`. -/// -/// A missing `root` yields an empty list. Directories are visited in sorted -/// order for deterministic results. -fn find_files(root: &Path, file_name: &str) -> eyre::Result> { - let mut matches = Vec::new(); - let mut pending_dirs = vec![root.to_path_buf()]; - while let Some(dir) = pending_dirs.pop() { - let entries = match std::fs::read_dir(&dir) { - Ok(entries) => entries, - Err(err) if err.kind() == ErrorKind::NotFound => continue, - Err(err) => { - return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display())); - } - }; - - let mut child_dirs = Vec::new(); - for entry in entries { - let entry = entry?; - let file_type = entry.file_type()?; - let path = entry.path(); - if file_type.is_dir() { - child_dirs.push(path); - } else if file_type.is_file() && entry.file_name() == OsStr::new(file_name) { - matches.push(path); - } - } - - child_dirs.sort(); - child_dirs.reverse(); - pending_dirs.extend(child_dirs); +fn read_bounded_settings_file(path: &Path) -> eyre::Result { + let metadata = std::fs::symlink_metadata(path) + .wrap_err_with(|| format!("failed to inspect {}", path.display()))?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + eyre::bail!( + "launch-settings input is not a regular file: {}", + path.display() + ); + } + if metadata.len() > MAX_LAUNCH_SETTINGS_FILE_BYTES { + eyre::bail!( + "launch-settings input exceeds the {MAX_LAUNCH_SETTINGS_FILE_BYTES}-byte limit: {}", + path.display() + ); } - Ok(matches) + let file = + std::fs::File::open(path).wrap_err_with(|| format!("failed to read {}", path.display()))?; + let mut content = String::new(); + file.take(MAX_LAUNCH_SETTINGS_FILE_BYTES + 1) + .read_to_string(&mut content) + .wrap_err_with(|| format!("failed to read {}", path.display()))?; + if u64::try_from(content.len()).unwrap_or(u64::MAX) > MAX_LAUNCH_SETTINGS_FILE_BYTES { + eyre::bail!( + "launch-settings input exceeds the {MAX_LAUNCH_SETTINGS_FILE_BYTES}-byte limit: {}", + path.display() + ); + } + Ok(content) } /// Rewrite the first `PersonaName` line in `content`, preserving its line ending. @@ -698,6 +784,65 @@ mod tests { assert!(launch_settings_applied_path(state.path(), "game").is_file()); } + #[test] + fn oversized_persona_file_fails_before_rewrite_or_marker() { + let state = TempDir::new("lanspread-launch-state"); + let game = TempDir::new("lanspread-launch-game"); + let account = game.path().join(LOCAL_DIR).join(ACCOUNT_NAME_FILE); + let ini = game.path().join(LOCAL_DIR).join(SMART_STEAM_EMU_INI); + write_file(&account, b"original-account"); + write_file(&ini, b"PersonaName = original\n"); + std::fs::OpenOptions::new() + .write(true) + .open(&ini) + .expect("INI should open") + .set_len(MAX_LAUNCH_SETTINGS_FILE_BYTES + 1) + .expect("INI should become oversized"); + + let error = + apply_launch_settings_once(state.path(), game.path(), "game", Some("realuser"), None) + .expect_err("oversized launch settings must fail closed"); + + assert!(error.to_string().contains("byte limit")); + assert!(!launch_settings_applied_path(state.path(), "game").exists()); + assert_eq!( + std::fs::read_to_string(account).expect("account file should remain readable"), + "original-account", + "a later bounded-read failure must precede every settings write" + ); + assert_eq!( + std::fs::metadata(&ini) + .expect("oversized INI should remain") + .len(), + MAX_LAUNCH_SETTINGS_FILE_BYTES + 1 + ); + } + + #[test] + fn launch_settings_search_rejects_entry_and_depth_overflow() { + let tree = TempDir::new("lanspread-launch-budget"); + write_file(&tree.path().join("a/one.txt"), b"one"); + write_file(&tree.path().join("b/two.txt"), b"two"); + + let mut entry_budget = SearchBudget::new(SearchLimits { + entry_cap: 1, + depth_ceiling: MAX_LAUNCH_SETTINGS_DEPTH, + time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION, + }); + let entry_error = find_settings_candidates(tree.path(), &mut entry_budget) + .expect_err("a one-entry search budget must reject the second entry"); + assert!(entry_error.to_string().contains("entry budget")); + + let mut depth_budget = SearchBudget::new(SearchLimits { + entry_cap: 16, + depth_ceiling: 1, + time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION, + }); + let depth_error = find_settings_candidates(tree.path(), &mut depth_budget) + .expect_err("a one-component search budget must reject nested entries"); + assert!(depth_error.to_string().contains("depth budget")); + } + fn write_file(path: &Path, bytes: &[u8]) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("parent dir should be created");