diff --git a/crates/lanspread-peer/src/launch_settings.rs b/crates/lanspread-peer/src/launch_settings.rs index 2ac02b2..3223070 100644 --- a/crates/lanspread-peer/src/launch_settings.rs +++ b/crates/lanspread-peer/src/launch_settings.rs @@ -37,6 +37,7 @@ 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_TOTAL_READ_BYTES: u64 = 64 * 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); @@ -45,6 +46,7 @@ const MAX_LAUNCH_SETTINGS_SCAN_DURATION: Duration = Duration::from_mins(10); struct SearchLimits { entry_cap: usize, depth_ceiling: usize, + read_byte_ceiling: u64, time_budget: Duration, } @@ -52,6 +54,7 @@ impl SearchLimits { const PRODUCTION: Self = Self { entry_cap: MAX_LAUNCH_SETTINGS_ENTRIES, depth_ceiling: MAX_LAUNCH_SETTINGS_DEPTH, + read_byte_ceiling: MAX_LAUNCH_SETTINGS_TOTAL_READ_BYTES, time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION, }; } @@ -59,6 +62,7 @@ impl SearchLimits { struct SearchBudget { limits: SearchLimits, entries: usize, + bytes_read: u64, started: Instant, } @@ -67,6 +71,7 @@ impl SearchBudget { Self { limits, entries: 0, + bytes_read: 0, started: Instant::now(), } } @@ -88,6 +93,26 @@ impl SearchBudget { self.limits.depth_ceiling ); } + self.ensure_time_budget() + } + + fn charge_read(&mut self, bytes: usize) -> eyre::Result<()> { + let bytes = u64::try_from(bytes) + .map_err(|error| eyre::eyre!("launch-settings read size does not fit u64: {error}"))?; + self.bytes_read = self + .bytes_read + .checked_add(bytes) + .ok_or_else(|| eyre::eyre!("launch-settings aggregate read size overflow"))?; + if self.bytes_read > self.limits.read_byte_ceiling { + eyre::bail!( + "launch-settings inputs exceed the {}-byte aggregate read budget", + self.limits.read_byte_ceiling + ); + } + self.ensure_time_budget() + } + + fn ensure_time_budget(&self) -> eyre::Result<()> { if self.started.elapsed() >= self.limits.time_budget { eyre::bail!( "launch-settings search exceeds the {:?} scan-time budget", @@ -271,11 +296,27 @@ fn apply_launch_settings_to_tree_blocking( account_name: Option<&str>, language: Option<&str>, persona_name: Option<&str>, +) -> eyre::Result { + apply_launch_settings_to_tree_with_limits( + local_root, + account_name, + language, + persona_name, + SearchLimits::PRODUCTION, + ) +} + +fn apply_launch_settings_to_tree_with_limits( + local_root: &Path, + account_name: Option<&str>, + language: Option<&str>, + persona_name: Option<&str>, + limits: SearchLimits, ) -> 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 mut budget = SearchBudget::new(limits); let SettingsCandidates { account_names, languages, @@ -286,11 +327,12 @@ fn apply_launch_settings_to_tree_blocking( 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)?; + let content = read_bounded_settings_file(&path, &mut budget)?; if let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) { update = Some((path, rewritten)); break; } + budget.ensure_time_budget()?; } update } else { @@ -380,7 +422,7 @@ fn find_settings_candidates( Ok(candidates) } -fn read_bounded_settings_file(path: &Path) -> eyre::Result { +fn read_bounded_settings_file(path: &Path, budget: &mut SearchBudget) -> 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() { @@ -398,17 +440,34 @@ fn read_bounded_settings_file(path: &Path) -> eyre::Result { 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() - ); + let mut reader = file.take(MAX_LAUNCH_SETTINGS_FILE_BYTES + 1); + let mut bytes = Vec::new(); + let mut chunk = vec![0_u8; 64 * 1024]; + loop { + budget.ensure_time_budget()?; + let read = reader + .read(&mut chunk) + .wrap_err_with(|| format!("failed to read {}", path.display()))?; + if read == 0 { + break; + } + budget.charge_read(read)?; + if bytes.len().saturating_add(read) + > usize::try_from(MAX_LAUNCH_SETTINGS_FILE_BYTES).unwrap_or(usize::MAX) + { + eyre::bail!( + "launch-settings input exceeds the {MAX_LAUNCH_SETTINGS_FILE_BYTES}-byte limit: {}", + path.display() + ); + } + bytes.extend_from_slice(&chunk[..read]); } - Ok(content) + String::from_utf8(bytes).wrap_err_with(|| { + format!( + "launch-settings input is not valid UTF-8: {}", + path.display() + ) + }) } /// Rewrite the first `PersonaName` line in `content`, preserving its line ending. @@ -827,6 +886,7 @@ mod tests { let mut entry_budget = SearchBudget::new(SearchLimits { entry_cap: 1, depth_ceiling: MAX_LAUNCH_SETTINGS_DEPTH, + read_byte_ceiling: MAX_LAUNCH_SETTINGS_TOTAL_READ_BYTES, time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION, }); let entry_error = find_settings_candidates(tree.path(), &mut entry_budget) @@ -836,6 +896,7 @@ mod tests { let mut depth_budget = SearchBudget::new(SearchLimits { entry_cap: 16, depth_ceiling: 1, + read_byte_ceiling: MAX_LAUNCH_SETTINGS_TOTAL_READ_BYTES, time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION, }); let depth_error = find_settings_candidates(tree.path(), &mut depth_budget) @@ -843,6 +904,38 @@ mod tests { assert!(depth_error.to_string().contains("depth budget")); } + #[test] + fn aggregate_ini_read_budget_fails_before_any_settings_write() { + let tree = TempDir::new("lanspread-launch-read-budget"); + let account = tree.path().join(ACCOUNT_NAME_FILE); + let first = tree.path().join("a").join(SMART_STEAM_EMU_INI); + let second = tree.path().join("b").join(SMART_STEAM_EMU_INI); + write_file(&account, b"original-account"); + write_file(&first, b"Language=english\n"); + write_file(&second, b"Language=german\n"); + + let error = apply_launch_settings_to_tree_with_limits( + tree.path(), + Some("replacement"), + None, + Some("player"), + SearchLimits { + entry_cap: 16, + depth_ceiling: MAX_LAUNCH_SETTINGS_DEPTH, + read_byte_ceiling: 20, + time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION, + }, + ) + .expect_err("multiple bounded INI files must share one aggregate budget"); + + assert!(error.to_string().contains("aggregate read budget")); + assert_eq!( + std::fs::read_to_string(account).expect("account file should remain readable"), + "original-account", + "all candidate reads must finish before the first settings write" + ); + } + 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");