fix(peer): sanitize legacy library index migration
The selected game root could supply an unbounded legacy library index whose revision was copied verbatim into app-owned state. A revision of `u64::MAX` made every later checked revision advance fail even after the original root was removed. Read only a bounded regular, non-link file whose identity stays stable across the read. Deserialize it before publication, preserve its cached game data, and reset its stale revision authority to zero. Existing app-owned state still wins without reading the legacy source, and invalid input remains in place for recovery. Test Plan: - `just test` -- passed outside the sandbox; 494 peer tests and all workspace targets passed. - The initial sandboxed `just test` reached an unrelated Unix-socket permission denial, then passed unchanged with the required socket permission. - `git diff --cached --check` -- passed.
This commit is contained in:
@@ -191,6 +191,14 @@ struct LibraryIndex {
|
|||||||
games: HashMap<String, GameIndexEntry>,
|
games: HashMap<String, GameIndexEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts a legacy selected-root cache into app-owned state without carrying
|
||||||
|
/// its stale revision authority across the trust boundary.
|
||||||
|
pub(crate) fn normalize_migrated_library_index(bytes: &[u8]) -> serde_json::Result<Vec<u8>> {
|
||||||
|
let mut index: LibraryIndex = serde_json::from_slice(bytes)?;
|
||||||
|
index.revision = 0;
|
||||||
|
serde_json::to_vec(&index)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct GameIndexEntry {
|
struct GameIndexEntry {
|
||||||
summary: LocalGameSummary,
|
summary: LocalGameSummary,
|
||||||
@@ -1043,6 +1051,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrated_library_index_preserves_games_but_resets_revision_authority() {
|
||||||
|
let original = test_library_index(u64::MAX, "game", 77);
|
||||||
|
let bytes = serde_json::to_vec(&original).expect("legacy index should serialize");
|
||||||
|
|
||||||
|
let normalized =
|
||||||
|
normalize_migrated_library_index(&bytes).expect("legacy index should normalize");
|
||||||
|
let migrated: LibraryIndex =
|
||||||
|
serde_json::from_slice(&normalized).expect("normalized index should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(migrated.revision, 0);
|
||||||
|
assert_eq!(migrated.games.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
migrated
|
||||||
|
.games
|
||||||
|
.get("game")
|
||||||
|
.expect("game should be preserved")
|
||||||
|
.summary
|
||||||
|
.size,
|
||||||
|
77,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_fingerprint_defaults_download_recovery_to_false() {
|
fn legacy_fingerprint_defaults_download_recovery_to_false() {
|
||||||
let fingerprint: GameFingerprint = serde_json::from_value(serde_json::json!({
|
let fingerprint: GameFingerprint = serde_json::from_value(serde_json::json!({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs,
|
||||||
io::{ErrorKind, Write as _},
|
io::{ErrorKind, Read as _, Write as _},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::atomic::{AtomicUsize, Ordering},
|
sync::atomic::{AtomicUsize, Ordering},
|
||||||
thread,
|
thread,
|
||||||
@@ -16,12 +16,13 @@ use crate::{
|
|||||||
LEGACY_SOFTLAN_INSTALL_MARKER,
|
LEGACY_SOFTLAN_INSTALL_MARKER,
|
||||||
is_ignored_games_root_name,
|
is_ignored_games_root_name,
|
||||||
},
|
},
|
||||||
local_games::legacy_library_index_path,
|
local_games::{legacy_library_index_path, normalize_migrated_library_index},
|
||||||
scoped_blocking::scoped_blocking,
|
scoped_blocking::scoped_blocking,
|
||||||
state_paths::{local_library_index_path, setup_done_path},
|
state_paths::{local_library_index_path, setup_done_path},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIGRATION_CONCURRENCY: usize = 16;
|
const MIGRATION_CONCURRENCY: usize = 16;
|
||||||
|
const MAX_LEGACY_LIBRARY_INDEX_BYTES: u64 = 128 * 1024 * 1024;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize)]
|
||||||
pub struct MigrationReport {
|
pub struct MigrationReport {
|
||||||
@@ -140,7 +141,7 @@ fn migrate_library_index(game_dir: &Path, state_dir: &Path) -> MigrationReport {
|
|||||||
let legacy_path = legacy_library_index_path(game_dir);
|
let legacy_path = legacy_library_index_path(game_dir);
|
||||||
let target_path = local_library_index_path(state_dir);
|
let target_path = local_library_index_path(state_dir);
|
||||||
|
|
||||||
match scoped_blocking(|| migrate_raw_file(&legacy_path, &target_path)) {
|
match scoped_blocking(|| migrate_library_index_file(&legacy_path, &target_path)) {
|
||||||
Ok(MigrationOutcome::Migrated) => {
|
Ok(MigrationOutcome::Migrated) => {
|
||||||
report.library_index_migrated = true;
|
report.library_index_migrated = true;
|
||||||
report.legacy_files_deleted += 1;
|
report.legacy_files_deleted += 1;
|
||||||
@@ -291,7 +292,10 @@ enum MigrationOutcome {
|
|||||||
Migrated,
|
Migrated,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn migrate_raw_file(legacy_path: &Path, target_path: &Path) -> std::io::Result<MigrationOutcome> {
|
fn migrate_library_index_file(
|
||||||
|
legacy_path: &Path,
|
||||||
|
target_path: &Path,
|
||||||
|
) -> std::io::Result<MigrationOutcome> {
|
||||||
if !path_exists(legacy_path)? {
|
if !path_exists(legacy_path)? {
|
||||||
return Ok(MigrationOutcome::SourceMissing);
|
return Ok(MigrationOutcome::SourceMissing);
|
||||||
}
|
}
|
||||||
@@ -301,12 +305,91 @@ fn migrate_raw_file(legacy_path: &Path, target_path: &Path) -> std::io::Result<M
|
|||||||
return Ok(MigrationOutcome::TargetAlreadyExists);
|
return Ok(MigrationOutcome::TargetAlreadyExists);
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = fs::read(legacy_path)?;
|
let data = read_bounded_regular_file(legacy_path, MAX_LEGACY_LIBRARY_INDEX_BYTES)?;
|
||||||
|
let data = normalize_migrated_library_index(&data)
|
||||||
|
.map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?;
|
||||||
write_bytes_atomically(target_path, &data)?;
|
write_bytes_atomically(target_path, &data)?;
|
||||||
remove_file_if_exists(legacy_path)?;
|
remove_file_if_exists(legacy_path)?;
|
||||||
Ok(MigrationOutcome::Migrated)
|
Ok(MigrationOutcome::Migrated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_bounded_regular_file(path: &Path, limit: u64) -> std::io::Result<Vec<u8>> {
|
||||||
|
let before = fs::symlink_metadata(path)?;
|
||||||
|
if !before.is_file() || is_link_or_reparse(&before) || before.len() > limit {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"legacy library index is not a bounded regular file",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = fs::File::open(path)?;
|
||||||
|
let opened = file.metadata()?;
|
||||||
|
if !opened.is_file() || !same_file(&before, &opened) || opened.len() > limit {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"legacy library index changed while opening",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let capacity = usize::try_from(opened.len().min(limit))
|
||||||
|
.map_err(|error| std::io::Error::new(ErrorKind::InvalidData, error))?;
|
||||||
|
let mut bytes = Vec::with_capacity(capacity);
|
||||||
|
std::io::Read::by_ref(&mut file)
|
||||||
|
.take(limit + 1)
|
||||||
|
.read_to_end(&mut bytes)?;
|
||||||
|
if u64::try_from(bytes.len()).map_or(true, |length| length > limit) {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"legacy library index exceeds its byte limit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let after = file.metadata()?;
|
||||||
|
if !after.is_file() || !same_file(&before, &after) || after.len() != opened.len() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"legacy library index changed while reading",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn same_file(before: &fs::Metadata, after: &fs::Metadata) -> bool {
|
||||||
|
use std::os::unix::fs::MetadataExt as _;
|
||||||
|
|
||||||
|
before.dev() == after.dev() && before.ino() == after.ino()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn same_file(before: &fs::Metadata, after: &fs::Metadata) -> bool {
|
||||||
|
use std::os::windows::fs::MetadataExt as _;
|
||||||
|
|
||||||
|
before.volume_serial_number().is_some()
|
||||||
|
&& before.volume_serial_number() == after.volume_serial_number()
|
||||||
|
&& before.file_index().is_some()
|
||||||
|
&& before.file_index() == after.file_index()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
const fn same_file(_before: &fs::Metadata, _after: &fs::Metadata) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||||
|
use std::os::windows::fs::MetadataExt as _;
|
||||||
|
|
||||||
|
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||||
|
metadata.file_type().is_symlink()
|
||||||
|
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||||
|
metadata.file_type().is_symlink()
|
||||||
|
}
|
||||||
|
|
||||||
fn migrate_empty_marker(
|
fn migrate_empty_marker(
|
||||||
legacy_path: &Path,
|
legacy_path: &Path,
|
||||||
target_path: &Path,
|
target_path: &Path,
|
||||||
@@ -493,21 +576,71 @@ mod tests {
|
|||||||
let target_path = local_library_index_path(state.path());
|
let target_path = local_library_index_path(state.path());
|
||||||
let legacy_tmp_path = library_index_tmp_path(&legacy_path);
|
let legacy_tmp_path = library_index_tmp_path(&legacy_path);
|
||||||
|
|
||||||
write_file(&legacy_path, br#"{"revision":7,"games":{}}"#);
|
write_file(
|
||||||
|
&legacy_path,
|
||||||
|
br#"{"revision":18446744073709551615,"games":{}}"#,
|
||||||
|
);
|
||||||
write_file(&legacy_tmp_path, b"tmp");
|
write_file(&legacy_tmp_path, b"tmp");
|
||||||
|
|
||||||
let report = migrate_legacy_state(games.path(), state.path()).await;
|
let report = migrate_legacy_state(games.path(), state.path()).await;
|
||||||
|
|
||||||
assert!(report.library_index_migrated);
|
assert!(report.library_index_migrated);
|
||||||
assert_eq!(
|
let migrated: serde_json::Value =
|
||||||
std::fs::read_to_string(&target_path).expect("index should migrate"),
|
serde_json::from_slice(&std::fs::read(&target_path).expect("index should migrate"))
|
||||||
r#"{"revision":7,"games":{}}"#
|
.expect("migrated index should remain valid JSON");
|
||||||
);
|
assert_eq!(migrated["revision"], 0);
|
||||||
|
assert_eq!(migrated["games"], serde_json::json!({}));
|
||||||
assert!(!legacy_path.exists());
|
assert!(!legacy_path.exists());
|
||||||
assert!(!legacy_tmp_path.exists());
|
assert!(!legacy_tmp_path.exists());
|
||||||
assert!(!games.path().join(LEGACY_LIBRARY_INDEX_DIR).exists());
|
assert!(!games.path().join(LEGACY_LIBRARY_INDEX_DIR).exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oversized_legacy_library_index_is_left_unmigrated() {
|
||||||
|
let games = TempDir::new("lanspread-migration-games");
|
||||||
|
let state = TempDir::new("lanspread-migration-state");
|
||||||
|
let legacy_path = legacy_library_index_path(games.path());
|
||||||
|
let target_path = local_library_index_path(state.path());
|
||||||
|
std::fs::create_dir_all(legacy_path.parent().expect("legacy index has a parent"))
|
||||||
|
.expect("legacy parent should be created");
|
||||||
|
std::fs::File::create(&legacy_path)
|
||||||
|
.expect("legacy index should be created")
|
||||||
|
.set_len(MAX_LEGACY_LIBRARY_INDEX_BYTES + 1)
|
||||||
|
.expect("legacy index should become oversized");
|
||||||
|
|
||||||
|
let report = migrate_legacy_state(games.path(), state.path()).await;
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 1);
|
||||||
|
assert!(!report.library_index_migrated);
|
||||||
|
assert!(legacy_path.exists());
|
||||||
|
assert!(!target_path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn existing_app_index_wins_without_reading_oversized_legacy_bytes() {
|
||||||
|
let games = TempDir::new("lanspread-migration-games");
|
||||||
|
let state = TempDir::new("lanspread-migration-state");
|
||||||
|
let legacy_path = legacy_library_index_path(games.path());
|
||||||
|
let target_path = local_library_index_path(state.path());
|
||||||
|
std::fs::create_dir_all(legacy_path.parent().expect("legacy index has a parent"))
|
||||||
|
.expect("legacy parent should be created");
|
||||||
|
std::fs::File::create(&legacy_path)
|
||||||
|
.expect("legacy index should be created")
|
||||||
|
.set_len(MAX_LEGACY_LIBRARY_INDEX_BYTES + 1)
|
||||||
|
.expect("legacy index should become oversized");
|
||||||
|
write_file(&target_path, br#"{"revision":9,"games":{}}"#);
|
||||||
|
|
||||||
|
let report = migrate_legacy_state(games.path(), state.path()).await;
|
||||||
|
|
||||||
|
assert_eq!(report.failures, 0);
|
||||||
|
assert!(!report.library_index_migrated);
|
||||||
|
assert!(!legacy_path.exists());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(target_path).expect("existing index should remain"),
|
||||||
|
r#"{"revision":9,"games":{}}"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn legacy_install_intent_is_rejected_without_deletion() {
|
async fn legacy_install_intent_is_rejected_without_deletion() {
|
||||||
let games = TempDir::new("lanspread-migration-games");
|
let games = TempDir::new("lanspread-migration-games");
|
||||||
|
|||||||
Reference in New Issue
Block a user