Files
lanspread/crates/lanspread-peer/src/state_paths.rs
T
ddidderr 0a38dfbb19 fix(peer): reject unsafe game IDs in state marker paths
Scanner finding #12 ("state marker path escape"). The per-game state
helpers in `state_paths.rs` joined a raw game ID below
`<state_dir>/games/`. The public `setup_done_path` was therefore usable
with an absolute or parent-containing ID by an embedding caller, and the
legacy migration discovered IDs from directory names in the user's games
folder and joined them unconditionally. Every shipping caller today
validates its ID or takes it from the catalog, so this was a footgun
rather than an exploited hole, but the fix is small and removes the
reliance on every future caller remembering the rule.

Add `validate_game_state_id`, which rejects separators and NUL and then
delegates to `lanspread_db::content_manifest::validate_portable_component`
(the catalog's own rules: no `.`/`..`, no trailing dot or space, no control
or Windows-reserved characters, no Windows device names). Reusing the
catalog validator rather than a private copy guarantees that any ID the
catalog can publish is accepted here and that the two cannot drift apart.

`setup_done_path` now returns `eyre::Result<PathBuf>`; it is the only
state path the embedding application calls with an ID that may originate
from UI input. `launch_settings_applied_path` leaves the public API and
becomes `pub(crate)`; the two public launch-settings entry points
(`apply_launch_settings_once`, `mark_launch_settings_applied`) validate
the ID before any filesystem work. `game_state_dir` carries a
`debug_assert!` documenting the contract for internal callers without
turning a bad ID into a release-build panic; the migration test suite
exercises that assertion in debug builds.

Behaviour changes:
- Legacy migration logs a warning, counts a failure and leaves the legacy
  marker in place for a games-folder directory whose name is not a
  portable game ID, instead of creating state below it. A new test covers
  a trailing-dot directory name.
- The Windows launcher ignores a run request whose ID `setup_done_path`
  rejects, with a warning, mirroring the existing invalid-ID early return.

Tests cover catalog-valid IDs that must remain accepted (embedded dots,
spaces, `console.txt`, `com10`, non-ASCII) and unsafe IDs that must be
rejected (empty, `.`, `..`, separators, NUL, trailing dot or space,
device names, `a:b`).

This ports the fallible API from the parallel security branch (lanspread2
commits 4146a0e and 9f26c63) onto the validator this branch already
exports from `lanspread-db`.

Test plan:
- `cargo test -p lanspread-peer --lib`: 491 passed.
- `just clippy`: clean.
- On Windows, launch a game with a valid ID and confirm the setup marker
  is still written under `<app-data>/games/<id>/setup_done`.

Claude-Session: https://claude.ai/code/session_01QRkCv4a4GqkajyamxmbSuA
2026-09-12 11:14:44 +02:00

269 lines
9.1 KiB
Rust

use std::path::{Path, PathBuf};
use lanspread_db::content_manifest::validate_portable_component;
const PEER_IDENTITY_FILE: &str = "peer-identity-v1.json";
const LOCAL_LIBRARY_DIR: &str = "local_library";
const LOCAL_LIBRARY_INDEX_FILE: &str = "index.json";
const GAMES_DIR: &str = "games";
const SETUP_DONE_FILE: &str = "setup_done";
const LAUNCH_SETTINGS_APPLIED_FILE: &str = "launch_settings_applied";
pub(crate) const DOWNLOAD_OWNERSHIP_DIR: &str = "download_ownership";
pub(crate) const DOWNLOAD_OWNERSHIP_RECORD_FILE: &str = "record.json";
pub(crate) const DOWNLOAD_OWNERSHIP_TMP_FILE: &str = "record.json.tmp";
pub(crate) const DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE: &str = "recovery-required";
pub(crate) const LEGACY_DOWNLOAD_OWNERSHIP_FILE: &str = "download_ownership.json";
pub(crate) const LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE: &str = "download_ownership.json.tmp";
pub(crate) const LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE: &str =
"download_ownership.recovery-required";
/// Resolves the directory that holds the peer identity, ownership journals and
/// other durable state.
///
/// Precedence: an explicit path from the embedding application (the Tauri
/// app passes its app-data directory, the CLI its `--state-dir`), then
/// `LANSPREAD_STATE_DIR`, then `$HOME`/`%USERPROFILE%/.lanspread`.
///
/// # Errors
///
/// Returns an error when none of these sources is available. State holds the
/// private identity key, so it is never placed in a shared, world-writable
/// temporary directory where another local user could pre-create it.
pub(crate) fn resolve_state_dir(explicit: Option<&Path>) -> eyre::Result<PathBuf> {
if let Some(dir) = explicit {
return Ok(dir.to_path_buf());
}
if let Some(dir) = std::env::var_os("LANSPREAD_STATE_DIR") {
return Ok(PathBuf::from(dir));
}
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return Ok(PathBuf::from(home).join(".lanspread"));
}
eyre::bail!(
"no state directory: pass one explicitly or set LANSPREAD_STATE_DIR, HOME, or USERPROFILE"
)
}
pub(crate) fn peer_identity_path(state_dir: &Path) -> PathBuf {
state_dir.join(PEER_IDENTITY_FILE)
}
pub(crate) fn local_library_index_path(state_dir: &Path) -> PathBuf {
state_dir
.join(LOCAL_LIBRARY_DIR)
.join(LOCAL_LIBRARY_INDEX_FILE)
}
/// Joins a per-game state directory below `<state_dir>/games`.
///
/// Callers inside this crate pass IDs that come from the catalog or have
/// already passed [`validate_game_state_id`]; the debug assertion documents
/// that contract without turning a bad ID into a release-build panic.
pub(crate) fn game_state_dir(state_dir: &Path, game_id: &str) -> PathBuf {
debug_assert!(
validate_game_state_id(game_id).is_ok(),
"game_state_dir called with an unvalidated game ID: {game_id:?}"
);
games_state_dir(state_dir).join(game_id)
}
/// Rejects a game ID that could escape or alias its per-game state
/// directory: separators, NUL, `.`/`..`, trailing dots or spaces, control or
/// Windows-reserved characters and Windows device names.
///
/// The rules are the catalog's own portable component rules, so every ID a
/// catalog can publish is accepted and the check cannot drift from the
/// validator that admits game IDs in the first place.
///
/// # Errors
///
/// Returns the first violated rule.
pub(crate) fn validate_game_state_id(game_id: &str) -> eyre::Result<()> {
if game_id.contains(['/', '\\', '\0']) {
eyre::bail!("game ID must be one path component: {game_id:?}");
}
validate_portable_component(game_id)
}
pub(crate) fn games_state_dir(state_dir: &Path) -> PathBuf {
state_dir.join(GAMES_DIR)
}
/// Path of the marker that records a completed one-time `game_setup` run.
///
/// This is the only state path exposed to embedding applications, whose game
/// IDs may originate from UI input rather than the catalog, so it validates
/// the ID before joining it below the state directory.
///
/// # Errors
///
/// Returns an error when `game_id` is not a single portable path component.
pub fn setup_done_path(state_dir: &Path, game_id: &str) -> eyre::Result<PathBuf> {
validate_game_state_id(game_id)?;
Ok(game_state_dir(state_dir, game_id).join(SETUP_DONE_FILE))
}
pub(crate) fn launch_settings_applied_path(state_dir: &Path, game_id: &str) -> PathBuf {
game_state_dir(state_dir, game_id).join(LAUNCH_SETTINGS_APPLIED_FILE)
}
pub(crate) fn download_ownership_namespaces_dir(state_dir: &Path, game_id: &str) -> PathBuf {
game_state_dir(state_dir, game_id).join(DOWNLOAD_OWNERSHIP_DIR)
}
pub(crate) fn download_ownership_namespace_component(games_folder_key: &str) -> String {
let key = games_folder_key.as_bytes();
let mut hasher = blake3::Hasher::new();
hasher.update(b"lanspread-download-ownership-root\0");
hasher.update(&u64::try_from(key.len()).unwrap_or(u64::MAX).to_le_bytes());
hasher.update(key);
format!("v1-{}", hasher.finalize().to_hex())
}
pub(crate) fn download_ownership_namespace_dir(
state_dir: &Path,
game_id: &str,
games_folder_key: &str,
) -> PathBuf {
download_ownership_namespaces_dir(state_dir, game_id)
.join(download_ownership_namespace_component(games_folder_key))
}
pub(crate) fn download_ownership_path(
state_dir: &Path,
game_id: &str,
games_folder_key: &str,
) -> PathBuf {
download_ownership_namespace_dir(state_dir, game_id, games_folder_key)
.join(DOWNLOAD_OWNERSHIP_RECORD_FILE)
}
pub(crate) fn download_ownership_tmp_path(
state_dir: &Path,
game_id: &str,
games_folder_key: &str,
) -> PathBuf {
download_ownership_namespace_dir(state_dir, game_id, games_folder_key)
.join(DOWNLOAD_OWNERSHIP_TMP_FILE)
}
pub(crate) fn download_ownership_recovery_required_path(
state_dir: &Path,
game_id: &str,
games_folder_key: &str,
) -> PathBuf {
download_ownership_namespace_dir(state_dir, game_id, games_folder_key)
.join(DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE)
}
pub(crate) fn legacy_download_ownership_path(state_dir: &Path, game_id: &str) -> PathBuf {
game_state_dir(state_dir, game_id).join(LEGACY_DOWNLOAD_OWNERSHIP_FILE)
}
pub(crate) fn legacy_download_ownership_tmp_path(state_dir: &Path, game_id: &str) -> PathBuf {
game_state_dir(state_dir, game_id).join(LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE)
}
pub(crate) fn legacy_download_ownership_recovery_required_path(
state_dir: &Path,
game_id: &str,
) -> PathBuf {
game_state_dir(state_dir, game_id).join(LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE)
}
/// Stable, lossless platform-native identity for a canonical games directory.
///
/// Callers must canonicalize and validate the directory before deriving its
/// key. Persistent state uses this value to prevent records from one configured
/// games directory from authorizing mutations in another.
#[cfg(unix)]
pub(crate) fn games_folder_key(path: &Path) -> String {
use std::os::unix::ffi::OsStrExt as _;
format!("unix:{}", hex_encode(path.as_os_str().as_bytes()))
}
#[cfg(windows)]
pub(crate) fn games_folder_key(path: &Path) -> String {
use std::{fmt::Write as _, os::windows::ffi::OsStrExt as _};
let mut encoded = String::from("windows:");
for unit in path.as_os_str().encode_wide() {
let _ = write!(encoded, "{unit:04x}");
}
encoded
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn games_folder_key(path: &Path) -> String {
format!("native:{}", hex_encode(path.as_os_str().as_encoded_bytes()))
}
#[cfg(not(windows))]
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(encoded, "{byte:02x}");
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_state_dir_takes_precedence() {
let explicit = Path::new("/explicit/state");
assert_eq!(
resolve_state_dir(Some(explicit)).expect("explicit path resolves"),
explicit
);
}
#[test]
fn setup_done_path_accepts_catalog_style_game_ids() {
let state_dir = Path::new("/state");
assert_eq!(
setup_done_path(state_dir, "game").expect("plain ID should be accepted"),
Path::new("/state/games/game/setup_done")
);
for game_id in ["game..v1 (final)", "console.txt", "com10", "Jörg"] {
assert!(
setup_done_path(state_dir, game_id).is_ok(),
"rejected catalog-valid game ID {game_id:?}"
);
}
}
#[test]
fn setup_done_path_rejects_escaping_game_ids() {
let state_dir = Path::new("/state");
for game_id in [
"",
".",
"..",
"../outside",
"/outside",
r"nested\game",
"game/child",
"game\0",
"game.",
"game ",
"NUL",
"con.txt",
"a:b",
] {
assert!(
setup_done_path(state_dir, game_id).is_err(),
"accepted unsafe game ID {game_id:?}"
);
}
}
}