fix(peer): bound public readiness sentinel reads

Reuse the stable 64 KiB non-link version.ini reader in anonymous transfer admission and install recovery. A stale Ready snapshot can no longer turn a locally enlarged sentinel into repeated unbounded request-path reads or diagnostic content.

Test Plan:
- just test
- just clippy
- oversized public-admission sentinel regression
- git diff --check
This commit is contained in:
2026-09-12 13:35:54 +02:00
parent 3450d27742
commit 9b6f611db0
3 changed files with 22 additions and 10 deletions
@@ -26,6 +26,7 @@ use super::{
};
use crate::{
game_paths::{BACKUP_DIR, INSTALL_OWNED_MARKER, INSTALLING_DIR, LOCAL_DIR},
local_games::read_version_from_ini_bounded,
scoped_blocking::scoped_blocking,
state_paths::launch_settings_applied_path,
};
@@ -549,12 +550,13 @@ async fn unpack_archives(
/// Windows, any reparse point (junctions, mount points).
///
/// Extraction is delegated to an external `unrar` process whose output is
/// otherwise trusted verbatim. The archives themselves are BLAKE3-verified
/// against the bundled catalog, so a link would have to originate from the
/// catalog publisher; this audit is defense in depth so that a link can never
/// redirect later launch-time rewrites (`apply_launch_settings_once`) or an
/// uninstall outside the game directory. `unrar` is additionally invoked
/// with `-ol-` so links are skipped at extraction time.
/// otherwise trusted verbatim. Downloaded archives are BLAKE3-verified, but
/// ordinary install intentionally consumes the current root `.eti` set so the
/// S33 local-mutation workflow remains supported. This audit prevents either a
/// catalog archive or a locally supplied archive from redirecting later
/// launch-time rewrites (`apply_launch_settings_once`) or an uninstall outside
/// the game directory. `unrar` is additionally invoked with `-ol-` so links are
/// skipped at extraction time.
fn reject_links_in_staging(staging: &Path) -> eyre::Result<()> {
let staging = staging.to_path_buf();
scoped_blocking(move || {
@@ -914,7 +916,7 @@ fn read_downloaded_version(game_root: &Path) -> Option<String> {
if !version_ini_is_regular_file_blocking(game_root) {
return None;
}
match lanspread_db::db::read_version_from_ini(game_root) {
match read_version_from_ini_bounded(game_root) {
Ok(version) => version,
Err(err) => {
log::warn!(
+1 -1
View File
@@ -760,7 +760,7 @@ fn build_game_summary(
})
}
fn read_version_from_ini_bounded(game_path: &Path) -> eyre::Result<Option<String>> {
pub(crate) 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);
+12 -2
View File
@@ -29,7 +29,7 @@ use tokio_util::{
use crate::{
context::PeerCtx,
download::open_catalog_file_for_read,
local_games::version_ini_is_regular_file,
local_games::{read_version_from_ini_bounded, version_ini_is_regular_file},
peer::send_game_file_chunk,
scoped_blocking::scoped_blocking,
stream_install::{send_game_install_stream, send_stream_install_error},
@@ -140,7 +140,7 @@ async fn can_serve_game(ctx: &PeerCtx, game_dir: &std::path::Path, game_id: &str
let expected_version_for_read = expected_version.clone();
scoped_blocking(move || {
expected_version_for_read.as_deref().is_none_or(|expected| {
lanspread_db::db::read_version_from_ini(&game_root)
read_version_from_ini_bounded(&game_root)
.is_ok_and(|version| version.as_deref() == Some(expected))
})
})
@@ -847,6 +847,16 @@ mod tests {
std::fs::write(game_root.join("version.ini"), b"20250101")
.expect("sentinel should be restored");
std::fs::OpenOptions::new()
.write(true)
.open(game_root.join("version.ini"))
.expect("sentinel should open")
.set_len(64 * 1024 + 1)
.expect("sentinel should become oversized");
assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await;
std::fs::write(game_root.join("version.ini"), b"20250101")
.expect("bounded sentinel should be restored");
std::fs::write(game_root.join("payload.bin"), b"short")
.expect("wrong-sized payload should be written");
assert_chunk_rejected(&ctx, content_id, &payload, 0, 7).await;