diff --git a/crates/lanspread-proto/src/lib.rs b/crates/lanspread-proto/src/lib.rs index 2fe55f7..7dd55a7 100644 --- a/crates/lanspread-proto/src/lib.rs +++ b/crates/lanspread-proto/src/lib.rs @@ -6,6 +6,7 @@ use std::{ }; use bytes::Bytes; +use lanspread_db::content_manifest::validate_portable_component; pub use lanspread_db::content_manifest::{CanonicalCatalogPath, ContentId}; use serde::{ Deserialize, @@ -945,8 +946,13 @@ impl CallToPlayAuthorEvent { /// Game IDs name one catalog directory. The wire boundary therefore admits /// only a single plain path component: no separators, no control characters, -/// and neither of the `.`/`..` pseudo-components. Every consumer still resolves -/// the ID against the local catalog before touching the filesystem. +/// and neither of the `.`/`..` pseudo-components. On top of that it applies +/// the catalog's own portable component rules (no trailing dot or space, no +/// Windows-reserved characters, no Windows device name stem) by calling the +/// `lanspread-db` validator, so anything the catalog can publish is accepted +/// and anything it would refuse never reaches a filesystem-backed handler. +/// Every consumer still resolves the ID against the local catalog before +/// touching the filesystem; this is defence in depth, not the only gate. fn validate_game_id(game_id: &str) -> Result<(), ControlValidationError> { if game_id.trim().is_empty() { return Err(ControlValidationError::EmptyField { field: "game ID" }); @@ -961,6 +967,7 @@ fn validate_game_id(game_id: &str) -> Result<(), ControlValidationError> { || game_id .chars() .any(|character| character.is_control() || matches!(character, '/' | '\\')) + || validate_portable_component(game_id).is_err() { return Err(ControlValidationError::InvalidPathComponent { field: "game ID" }); } @@ -1740,6 +1747,14 @@ mod tests { "dir\\game", "game\0", "game\n", + "game.", + "game ", + "CON", + "nul.txt", + "com1", + "C:game", + "game?", + "game*", ] { assert!( matches!( @@ -1755,12 +1770,14 @@ mod tests { "accepted game ID {game_id:?}" ); } - Request::StreamInstall { - game_id: "game..v1 (final)".to_owned(), - content_id: content(1), + for game_id in ["game..v1 (final)", "console.txt", "com10", "Jörg"] { + Request::StreamInstall { + game_id: game_id.to_owned(), + content_id: content(1), + } + .encode() + .expect("catalog-valid game ID should encode"); } - .encode() - .expect("plain component with embedded dots should encode"); let mut snapshot = state_snapshot(Vec::new()); snapshot.call_to_play.display_name = "é".repeat(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS);