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");