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 { 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 `/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 { 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:?}" ); } } }