fix(peer): preflight launch settings mutations
Bound recursive setting discovery and INI reads, and finish every scan/read before the first write. Budget exhaustion can no longer leave partial setting changes or a misleading completion marker. Test Plan: - just test - just clippy - focused traversal, oversized INI, and zero-partial-write tests - git diff --check
This commit is contained in:
@@ -15,11 +15,13 @@
|
|||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
ffi::OsStr,
|
ffi::OsStr,
|
||||||
io::ErrorKind,
|
io::{ErrorKind, Read as _},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use eyre::WrapErr;
|
use eyre::WrapErr;
|
||||||
|
use lanspread_db::content_manifest::{MAX_CATALOG_ENTRIES, MAX_CATALOG_PATH_BYTES};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
game_paths::LOCAL_DIR,
|
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_NAME: &str = "Commander";
|
||||||
const DEFAULT_STREAM_INSTALL_LANGUAGE: &str = "english";
|
const DEFAULT_STREAM_INSTALL_LANGUAGE: &str = "english";
|
||||||
const MAX_STREAM_INSTALL_NAME_CHARS: usize = 24;
|
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.
|
/// Sanitized per-user values applied inside a verified Stream Install staging tree.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -209,120 +272,143 @@ fn apply_launch_settings_to_tree_blocking(
|
|||||||
language: Option<&str>,
|
language: Option<&str>,
|
||||||
persona_name: Option<&str>,
|
persona_name: Option<&str>,
|
||||||
) -> eyre::Result<LaunchSettingsOutcome> {
|
) -> eyre::Result<LaunchSettingsOutcome> {
|
||||||
|
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 {
|
Ok(LaunchSettingsOutcome {
|
||||||
already_applied: false,
|
already_applied: false,
|
||||||
account_name_written: overwrite_first_file(local_root, ACCOUNT_NAME_FILE, account_name)?,
|
account_name_written: account_path.is_some(),
|
||||||
language_written: overwrite_first_file(local_root, LANGUAGE_FILE, language)?,
|
language_written: language_path.is_some(),
|
||||||
persona_name_written: rewrite_first_persona_name(local_root, persona_name)?,
|
persona_name_written: persona_update.is_some(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overwrite the first file named `file_name` under `root` with `value`.
|
#[derive(Debug, Default)]
|
||||||
///
|
struct SettingsCandidates {
|
||||||
/// Returns `false` without touching anything when `value` is `None` or no such
|
account_names: Vec<PathBuf>,
|
||||||
/// file exists.
|
languages: Vec<PathBuf>,
|
||||||
fn overwrite_first_file(root: &Path, file_name: &str, value: Option<&str>) -> eyre::Result<bool> {
|
persona_files: Vec<PathBuf>,
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite the first `PersonaName` line found in any `SmartSteamEmu.ini` under `root`.
|
/// Find every launcher-setting candidate in one bounded traversal.
|
||||||
fn rewrite_first_persona_name(root: &Path, persona_name: Option<&str>) -> eyre::Result<bool> {
|
///
|
||||||
let Some(persona_name) = persona_name else {
|
/// A missing `root` yields an empty set. Sorting complete relative paths keeps
|
||||||
return Ok(false);
|
/// 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<SettingsCandidates> {
|
||||||
|
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,
|
||||||
|
Err(err) => {
|
||||||
|
return Err(err).wrap_err_with(|| format!("failed to read {}", dir.display()));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for path in find_files(root, SMART_STEAM_EMU_INI)? {
|
let mut child_dirs = Vec::new();
|
||||||
let content = std::fs::read_to_string(&path)
|
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, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child_dirs.sort();
|
||||||
|
child_dirs.reverse();
|
||||||
|
pending_dirs.extend(child_dirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.account_names.sort();
|
||||||
|
candidates.languages.sort();
|
||||||
|
candidates.persona_files.sort();
|
||||||
|
Ok(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bounded_settings_file(path: &Path) -> eyre::Result<String> {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))?;
|
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
|
||||||
let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) else {
|
if u64::try_from(content.len()).unwrap_or(u64::MAX) > MAX_LAUNCH_SETTINGS_FILE_BYTES {
|
||||||
continue;
|
eyre::bail!(
|
||||||
};
|
"launch-settings input exceeds the {MAX_LAUNCH_SETTINGS_FILE_BYTES}-byte limit: {}",
|
||||||
|
path.display()
|
||||||
std::fs::write(&path, rewritten)
|
);
|
||||||
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
|
|
||||||
return Ok(true);
|
|
||||||
}
|
}
|
||||||
|
Ok(content)
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the first regular file named `file_name` anywhere under `root`.
|
|
||||||
///
|
|
||||||
/// 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<Option<PathBuf>> {
|
|
||||||
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) {
|
|
||||||
return Ok(Some(path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
child_dirs.sort();
|
|
||||||
child_dirs.reverse();
|
|
||||||
pending_dirs.extend(child_dirs);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<Vec<PathBuf>> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(matches)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite the first `PersonaName` line in `content`, preserving its line ending.
|
/// 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());
|
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]) {
|
fn write_file(path: &Path, bytes: &[u8]) {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent).expect("parent dir should be created");
|
std::fs::create_dir_all(parent).expect("parent dir should be created");
|
||||||
|
|||||||
Reference in New Issue
Block a user