fix(peer): bound aggregate launch-settings reads
Charge every SmartSteamEmu.ini chunk to one 64 MiB aggregate budget and the existing ten-minute scan clock. All candidate reads still finish before the first write, so exhaustion cannot leave partial settings or a completion marker. Test Plan: - just test - just clippy - aggregate-read zero-partial-write regression - git diff --check
This commit is contained in:
@@ -37,6 +37,7 @@ 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_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_ENTRIES: usize = MAX_CATALOG_ENTRIES;
|
||||||
const MAX_LAUNCH_SETTINGS_DEPTH: usize = MAX_CATALOG_PATH_BYTES.div_ceil(2);
|
const MAX_LAUNCH_SETTINGS_DEPTH: usize = MAX_CATALOG_PATH_BYTES.div_ceil(2);
|
||||||
const MAX_LAUNCH_SETTINGS_SCAN_DURATION: Duration = Duration::from_mins(10);
|
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 {
|
struct SearchLimits {
|
||||||
entry_cap: usize,
|
entry_cap: usize,
|
||||||
depth_ceiling: usize,
|
depth_ceiling: usize,
|
||||||
|
read_byte_ceiling: u64,
|
||||||
time_budget: Duration,
|
time_budget: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ impl SearchLimits {
|
|||||||
const PRODUCTION: Self = Self {
|
const PRODUCTION: Self = Self {
|
||||||
entry_cap: MAX_LAUNCH_SETTINGS_ENTRIES,
|
entry_cap: MAX_LAUNCH_SETTINGS_ENTRIES,
|
||||||
depth_ceiling: MAX_LAUNCH_SETTINGS_DEPTH,
|
depth_ceiling: MAX_LAUNCH_SETTINGS_DEPTH,
|
||||||
|
read_byte_ceiling: MAX_LAUNCH_SETTINGS_TOTAL_READ_BYTES,
|
||||||
time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION,
|
time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,7 @@ impl SearchLimits {
|
|||||||
struct SearchBudget {
|
struct SearchBudget {
|
||||||
limits: SearchLimits,
|
limits: SearchLimits,
|
||||||
entries: usize,
|
entries: usize,
|
||||||
|
bytes_read: u64,
|
||||||
started: Instant,
|
started: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +71,7 @@ impl SearchBudget {
|
|||||||
Self {
|
Self {
|
||||||
limits,
|
limits,
|
||||||
entries: 0,
|
entries: 0,
|
||||||
|
bytes_read: 0,
|
||||||
started: Instant::now(),
|
started: Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,6 +93,26 @@ impl SearchBudget {
|
|||||||
self.limits.depth_ceiling
|
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 {
|
if self.started.elapsed() >= self.limits.time_budget {
|
||||||
eyre::bail!(
|
eyre::bail!(
|
||||||
"launch-settings search exceeds the {:?} scan-time budget",
|
"launch-settings search exceeds the {:?} scan-time budget",
|
||||||
@@ -271,11 +296,27 @@ fn apply_launch_settings_to_tree_blocking(
|
|||||||
account_name: Option<&str>,
|
account_name: Option<&str>,
|
||||||
language: Option<&str>,
|
language: Option<&str>,
|
||||||
persona_name: Option<&str>,
|
persona_name: Option<&str>,
|
||||||
|
) -> eyre::Result<LaunchSettingsOutcome> {
|
||||||
|
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<LaunchSettingsOutcome> {
|
) -> eyre::Result<LaunchSettingsOutcome> {
|
||||||
if account_name.is_none() && language.is_none() && persona_name.is_none() {
|
if account_name.is_none() && language.is_none() && persona_name.is_none() {
|
||||||
return Ok(LaunchSettingsOutcome::default());
|
return Ok(LaunchSettingsOutcome::default());
|
||||||
}
|
}
|
||||||
let mut budget = SearchBudget::new(SearchLimits::PRODUCTION);
|
let mut budget = SearchBudget::new(limits);
|
||||||
let SettingsCandidates {
|
let SettingsCandidates {
|
||||||
account_names,
|
account_names,
|
||||||
languages,
|
languages,
|
||||||
@@ -286,11 +327,12 @@ fn apply_launch_settings_to_tree_blocking(
|
|||||||
let persona_update = if let Some(persona_name) = persona_name {
|
let persona_update = if let Some(persona_name) = persona_name {
|
||||||
let mut update = None;
|
let mut update = None;
|
||||||
for path in persona_files {
|
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) {
|
if let Some(rewritten) = rewrite_persona_name_content(&content, persona_name) {
|
||||||
update = Some((path, rewritten));
|
update = Some((path, rewritten));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
budget.ensure_time_budget()?;
|
||||||
}
|
}
|
||||||
update
|
update
|
||||||
} else {
|
} else {
|
||||||
@@ -380,7 +422,7 @@ fn find_settings_candidates(
|
|||||||
Ok(candidates)
|
Ok(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_bounded_settings_file(path: &Path) -> eyre::Result<String> {
|
fn read_bounded_settings_file(path: &Path, budget: &mut SearchBudget) -> eyre::Result<String> {
|
||||||
let metadata = std::fs::symlink_metadata(path)
|
let metadata = std::fs::symlink_metadata(path)
|
||||||
.wrap_err_with(|| format!("failed to inspect {}", path.display()))?;
|
.wrap_err_with(|| format!("failed to inspect {}", path.display()))?;
|
||||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||||
@@ -398,17 +440,34 @@ fn read_bounded_settings_file(path: &Path) -> eyre::Result<String> {
|
|||||||
|
|
||||||
let file =
|
let file =
|
||||||
std::fs::File::open(path).wrap_err_with(|| format!("failed to read {}", path.display()))?;
|
std::fs::File::open(path).wrap_err_with(|| format!("failed to read {}", path.display()))?;
|
||||||
let mut content = String::new();
|
let mut reader = file.take(MAX_LAUNCH_SETTINGS_FILE_BYTES + 1);
|
||||||
file.take(MAX_LAUNCH_SETTINGS_FILE_BYTES + 1)
|
let mut bytes = Vec::new();
|
||||||
.read_to_string(&mut content)
|
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()))?;
|
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
|
||||||
if u64::try_from(content.len()).unwrap_or(u64::MAX) > MAX_LAUNCH_SETTINGS_FILE_BYTES {
|
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!(
|
eyre::bail!(
|
||||||
"launch-settings input exceeds the {MAX_LAUNCH_SETTINGS_FILE_BYTES}-byte limit: {}",
|
"launch-settings input exceeds the {MAX_LAUNCH_SETTINGS_FILE_BYTES}-byte limit: {}",
|
||||||
path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(content)
|
bytes.extend_from_slice(&chunk[..read]);
|
||||||
|
}
|
||||||
|
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.
|
/// Rewrite the first `PersonaName` line in `content`, preserving its line ending.
|
||||||
@@ -827,6 +886,7 @@ mod tests {
|
|||||||
let mut entry_budget = SearchBudget::new(SearchLimits {
|
let mut entry_budget = SearchBudget::new(SearchLimits {
|
||||||
entry_cap: 1,
|
entry_cap: 1,
|
||||||
depth_ceiling: MAX_LAUNCH_SETTINGS_DEPTH,
|
depth_ceiling: MAX_LAUNCH_SETTINGS_DEPTH,
|
||||||
|
read_byte_ceiling: MAX_LAUNCH_SETTINGS_TOTAL_READ_BYTES,
|
||||||
time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION,
|
time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION,
|
||||||
});
|
});
|
||||||
let entry_error = find_settings_candidates(tree.path(), &mut entry_budget)
|
let entry_error = find_settings_candidates(tree.path(), &mut entry_budget)
|
||||||
@@ -836,6 +896,7 @@ mod tests {
|
|||||||
let mut depth_budget = SearchBudget::new(SearchLimits {
|
let mut depth_budget = SearchBudget::new(SearchLimits {
|
||||||
entry_cap: 16,
|
entry_cap: 16,
|
||||||
depth_ceiling: 1,
|
depth_ceiling: 1,
|
||||||
|
read_byte_ceiling: MAX_LAUNCH_SETTINGS_TOTAL_READ_BYTES,
|
||||||
time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION,
|
time_budget: MAX_LAUNCH_SETTINGS_SCAN_DURATION,
|
||||||
});
|
});
|
||||||
let depth_error = find_settings_candidates(tree.path(), &mut depth_budget)
|
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"));
|
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]) {
|
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