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:
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user