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)
This commit is contained in:
2026-08-09 19:48:21 +02:00
parent 08b1cb5c1d
commit 7a77d3ffd1
@@ -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");