From bcdede7fadc0e101406de47c09d2f1a23d614b53 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 9 Aug 2026 22:01:00 +0200 Subject: [PATCH] fix(peer): reject non-normalized download paths Require catalog game IDs and every remotely described path component to use Unicode NFC before any download transaction begins. This prevents canonically equivalent spellings from bypassing portable alias checks on filesystems that normalize names, while preserving the protocol's exact path spelling. Cover accepted NFC names and both game-ID and nested-component rejection with zero-mutation tree snapshots. Test Plan: - `just clippy` -- passed - `just test` -- passed (242 lanspread-peer tests) - `git diff --cached --check` -- passed --- Cargo.lock | 10 +++ Cargo.toml | 1 + crates/lanspread-peer/Cargo.toml | 1 + .../lanspread-peer/src/download/manifest.rs | 84 +++++++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index f18402a..1fbf9b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2052,6 +2052,7 @@ dependencies = [ "strum", "tokio", "tokio-util", + "unicode-normalization", "uuid", "walkdir", ] @@ -4892,6 +4893,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/Cargo.toml b/Cargo.toml index 71956cc..70d131d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ tokio-util = { version = "0.7", features = ["codec", "rt"] } tracing = "0.1" tracing-log = "0.2" tracing-subscriber = "0.3" +unicode-normalization = "0.1" uuid = { version = "1", features = ["v7"] } walkdir = "2" windows = { diff --git a/crates/lanspread-peer/Cargo.toml b/crates/lanspread-peer/Cargo.toml index 4f4f6a7..f124e0f 100644 --- a/crates/lanspread-peer/Cargo.toml +++ b/crates/lanspread-peer/Cargo.toml @@ -29,6 +29,7 @@ serde_json = { workspace = true } strum = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } +unicode-normalization = { workspace = true } uuid = { workspace = true } walkdir = { workspace = true } diff --git a/crates/lanspread-peer/src/download/manifest.rs b/crates/lanspread-peer/src/download/manifest.rs index c31347f..f8db50d 100644 --- a/crates/lanspread-peer/src/download/manifest.rs +++ b/crates/lanspread-peer/src/download/manifest.rs @@ -6,6 +6,7 @@ use std::{ use eyre::WrapErr; use lanspread_db::db::{GameCatalog, GameFileDescription}; +use unicode_normalization::is_nfc; use crate::game_paths::{VERSION_INI, is_download_protected_root_name, portable_name_key}; @@ -386,6 +387,9 @@ pub(super) fn validate_game_id(game_id: &str) -> eyre::Result<()> { if game_id.contains('/') || game_id.contains('\\') { eyre::bail!("catalog game ID must be one path component: {game_id}"); } + if !is_nfc(game_id) { + eyre::bail!("catalog game ID must use Unicode NFC normalization: {game_id}"); + } validate_component(game_id)?; if is_download_protected_root_name(game_id) { eyre::bail!("catalog game ID is reserved for application state: {game_id}"); @@ -472,6 +476,9 @@ fn validate_component(component: &str) -> eyre::Result<()> { if component.is_empty() || matches!(component, "." | "..") { eyre::bail!("download path contains a non-canonical component: {component:?}"); } + if !is_nfc(component) { + eyre::bail!("download path component must use Unicode NFC normalization: {component}"); + } if component.ends_with([' ', '.']) { eyre::bail!("download path component has a trailing dot or space: {component}"); } @@ -762,6 +769,83 @@ mod tests { assert_eq!(version_ini.protocol_path(), "game/version.ini"); } + #[test] + fn accepts_nfc_catalog_game_id_and_path_components() { + let temp = TempDir::new("lanspread-manifest-nfc-valid"); + let game_id = "g\u{e1}me"; + let catalog = GameCatalog::from_ids([game_id.to_owned()]); + let descriptions = vec![ + GameFileDescription { + game_id: game_id.to_owned(), + relative_path: game_id.to_owned(), + is_dir: true, + size: 0, + }, + GameFileDescription { + game_id: game_id.to_owned(), + relative_path: format!("{game_id}/caf\u{e9}/archive.eti"), + is_dir: false, + size: 10, + }, + GameFileDescription { + game_id: game_id.to_owned(), + relative_path: format!("{game_id}/version.ini"), + is_dir: false, + size: 8, + }, + ]; + + let manifest = ValidatedDownloadManifest::from_protocol_v7( + temp.path(), + game_id, + descriptions, + &catalog, + ) + .expect("NFC-normalized names should validate"); + + assert_eq!(manifest.game_id(), game_id); + assert!( + manifest + .entries() + .iter() + .any(|entry| entry.destination().canonical() == "caf\u{e9}/archive.eti") + ); + } + + #[test] + fn rejects_non_nfc_catalog_game_id_without_mutation() { + let temp = TempDir::new("lanspread-manifest-nfc-game-id"); + write_file(&temp.path().join("existing/file.bin"), b"unchanged"); + let before = snapshot_tree(temp.path()); + let game_id = "ga\u{301}me"; + let catalog = GameCatalog::from_ids([game_id.to_owned()]); + let descriptions = vec![GameFileDescription { + game_id: game_id.to_owned(), + relative_path: format!("{game_id}/version.ini"), + is_dir: false, + size: 8, + }]; + + let error = ValidatedDownloadManifest::from_protocol_v7( + temp.path(), + game_id, + descriptions, + &catalog, + ) + .expect_err("non-NFC catalog game ID should fail"); + + assert!(error.to_string().contains("Unicode NFC normalization")); + assert_eq!(snapshot_tree(temp.path()), before); + } + + #[test] + fn rejects_non_nfc_download_component_without_mutation() { + assert_rejected_without_mutation(vec![ + file("game/cafe\u{301}/archive.eti", 10), + file("game/version.ini", 8), + ]); + } + #[test] fn rejects_unknown_catalog_game() { let temp = TempDir::new("lanspread-manifest-unknown");