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
This commit is contained in:
2026-08-09 22:01:00 +02:00
parent 5bb4a8b611
commit bcdede7fad
4 changed files with 96 additions and 0 deletions
Generated
+10
View File
@@ -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"
+1
View File
@@ -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 = {
+1
View File
@@ -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 }
@@ -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");