From 7a77d3ffd1a2f6183f16a1b1cbfcc65a2a253225 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 9 Aug 2026 19:48:21 +0200 Subject: [PATCH] fix(peer): reject aliased ownership generations Validate portable aliases across committed and pending ownership sets when loading persisted records. Malformed state can no longer make recovery delete the pending file through an older case-only spelling. Test Plan: - just clippy - just test - just fmt (Rust, TOML, and Prettier completed; rumdl still reports 39 pre-existing issues) --- .../lanspread-peer/src/download/ownership.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/lanspread-peer/src/download/ownership.rs b/crates/lanspread-peer/src/download/ownership.rs index 6db95ae..f04d2e1 100644 --- a/crates/lanspread-peer/src/download/ownership.rs +++ b/crates/lanspread-peer/src/download/ownership.rs @@ -79,6 +79,10 @@ impl DownloadOwnershipRecord { validate_file_set(&self.committed_files)?; if let Some(pending) = &self.pending_files { validate_file_set(pending)?; + validate_generation_aliases( + &self.committed_files.iter().cloned().collect(), + &pending.iter().cloned().collect(), + )?; } Ok(self) } @@ -852,6 +856,12 @@ mod tests { let mut wrong_schema = valid.clone(); wrong_schema.schema_version += 1; assert!(wrong_schema.validate("game", "root").is_err()); + + let mut cross_generation_alias = valid.clone(); + cross_generation_alias.committed_files = vec!["Archive.eti".to_owned()]; + cross_generation_alias.pending_files = Some(vec!["archive.eti".to_owned()]); + assert!(cross_generation_alias.validate("game", "root").is_err()); + assert!(valid.clone().validate("other", "root").is_err()); assert!(valid.validate("game", "other-root").is_err()); } @@ -1263,6 +1273,32 @@ mod tests { assert!(!root.join("archive.eti").exists()); } + #[tokio::test] + async fn recovery_never_trusts_cross_generation_aliases() { + let games = TempDir::new("lanspread-ownership-alias-recovery-games"); + let state = TempDir::new("lanspread-ownership-alias-recovery-state"); + let root = games.game_root(); + write_file(&root.join(VERSION_INI), b"20240101"); + write_file(&root.join("archive.eti"), b"must-survive"); + seed_record( + state.path(), + games.path(), + &["Archive.eti"], + Some(&["archive.eti"]), + ) + .await; + + recover_incomplete_download(&root, state.path(), "game") + .await + .expect("invalid ownership must fail closed"); + + assert_eq!( + std::fs::read(root.join("archive.eti")).expect("payload must be preserved"), + b"must-survive" + ); + assert!(root.join(VERSION_INI).is_file()); + } + #[tokio::test] async fn committed_path_ownership_survives_external_root_recreation() { let games = TempDir::new("lanspread-ownership-recreated-root-games");