diff --git a/README.md b/README.md index 19b57ee..43f0517 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ as a game launcher. installation, preserving files needed by paused or unfinished transfers. - Native macOS games and Windows games through pinned NotProton integration with a separately installed CrossOver or CrossOver Preview. +- Native launch plans use Steam option IDs and installed bundle/binary/script + metadata. Auto-Cloud save paths are resolved without per-game AppID rules; + unsupported paths and launch formats produce specific diagnostics. - Per-game launch arguments and compatibility settings, launch/stop controls, installation removal and redacted launch diagnostics. - Steam store and profile pages, friends and text chat, with invisible presence diff --git a/crates/client-core/src/model.rs b/crates/client-core/src/model.rs index 24df317..328ea2f 100644 --- a/crates/client-core/src/model.rs +++ b/crates/client-core/src/model.rs @@ -58,10 +58,17 @@ pub struct Game { pub installed_platform: Option, pub header_url: String, pub launch: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cloud_config: Option, + /// False for pre-UFS caches, where None does not mean verified absence. + #[serde(default)] + pub launch_metadata_known: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct LaunchEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, pub executable: String, pub arguments: Option, pub workingdir: Option, @@ -71,6 +78,21 @@ pub struct LaunchEntry { pub betakey: Option, } +impl From for LaunchEntry { + fn from(entry: steam_cm_protocol::LaunchEntry) -> Self { + Self { + id: entry.id, + executable: entry.executable, + arguments: entry.arguments, + workingdir: entry.workingdir, + launch_type: entry.launch_type, + oslist: entry.oslist, + osarch: entry.osarch, + betakey: entry.betakey, + } + } +} + impl From for Game { fn from(game: steam_cm_protocol::ProtocolGame) -> Self { let public_default = |entry: &&steam_cm_protocol::LaunchEntry| { @@ -115,19 +137,9 @@ impl From for Game { "https://cdn.akamai.steamstatic.com/steam/apps/{}/header.jpg", game.appid ), - launch: game - .launch - .into_iter() - .map(|entry| LaunchEntry { - executable: entry.executable, - arguments: entry.arguments, - workingdir: entry.workingdir, - launch_type: entry.launch_type, - oslist: entry.oslist, - osarch: entry.osarch, - betakey: entry.betakey, - }) - .collect(), + launch: game.launch.into_iter().map(LaunchEntry::from).collect(), + cloud_config: game.cloud_config, + launch_metadata_known: true, } } } @@ -135,6 +147,48 @@ impl From for Game { #[cfg(test)] mod tests { use super::*; + + #[test] + fn legacy_cached_game_omits_launch_ids_and_cloud_config() { + let game: Game = serde_json::from_value(serde_json::json!({ + "appid": 42, "name": "Legacy", "playtime_minutes": 0, "last_played": 0, + "app_type": "game", "installdir": "Legacy", "installed_dir": null, + "macos_supported": true, "header_url": "", + "launch": [{"executable": "Legacy.app"}] + })) + .unwrap(); + assert!(game.cloud_config.is_none()); + assert!(!game.launch_metadata_known); + assert!(game.launch[0].id.is_none()); + } + + #[test] + fn game_conversion_preserves_cloud_and_original_launch_option_id() { + let ufs = serde_json::json!({"savefiles": {"8": { + "root": "MacAppSupport", "path": "Publisher/Game", "pattern": "*.sav" + }}}); + let protocol = steam_cm_protocol::ProtocolGame { + appid: 42, + name: "Game".into(), + playtime_forever: 0, + rtime_last_played: 0, + img_icon_url: None, + app_type: None, + installdir: None, + cloud_config: Some(ufs.clone()), + launch: vec![steam_cm_protocol::LaunchEntry { + id: Some(19), + executable: "Game.app".into(), + ..Default::default() + }], + }; + let serialized = serde_json::to_value(Game::from(protocol)).unwrap(); + assert_eq!(serialized["cloud_config"], ufs); + assert_eq!(serialized["launch_metadata_known"], true); + let restored: Game = serde_json::from_value(serialized.clone()).unwrap(); + assert!(restored.launch_metadata_known); + assert_eq!(serialized["launch"][0]["id"], 19); + } #[test] fn request_password_is_never_in_response_schema() { let req: Request = serde_json::from_str( @@ -154,6 +208,7 @@ mod tests { img_icon_url: None, app_type: None, installdir: None, + cloud_config: None, launch: vec![steam_cm_protocol::LaunchEntry { oslist: Some("windows, macos".into()), ..Default::default() diff --git a/crates/client-core/src/operations.rs b/crates/client-core/src/operations.rs index 0da9876..eb4c2c9 100644 --- a/crates/client-core/src/operations.rs +++ b/crates/client-core/src/operations.rs @@ -405,11 +405,15 @@ fn launch_entry<'a>( dir: &Path, windows: bool, ) -> Result<&'a crate::model::LaunchEntry> { + ensure!( + windows || game.launch_metadata_known, + "Данные запуска и сохранений игры устарели. Обновите библиотеку перед запуском macOS-версии" + ); game.launch .iter() .filter(|e| is_default_public(e)) .filter(|e| if windows { is_windows(e) } else { is_macos(e) }) - .find(|e| { + .filter(|e| { relative_game_path(dir, &e.executable).is_ok_and(|p| { if windows { p.is_file() @@ -418,6 +422,9 @@ fn launch_entry<'a>( } }) }) + // PICS option numbers are stable identifiers, not array positions. + // Old library caches omit them; retain their existing order until refresh. + .min_by_key(|entry| (entry.id.is_none(), entry.id.unwrap_or_default())) .with_context(|| { if windows { "Не найдены установленные Windows-файлы. Установите Windows-версию через NotProton" @@ -427,6 +434,49 @@ fn launch_entry<'a>( }) } +fn game_with_fresh_launch_metadata( + game: &Game, + metadata: steam_cm_protocol::pics::AppCatalogInfo, +) -> Result { + ensure!( + metadata.appid == game.appid, + "Steam returned launch metadata for another game" + ); + ensure!( + !metadata.name.trim().is_empty(), + "Steam returned launch metadata without a game identity" + ); + let mut refreshed = game.clone(); + refreshed.name = metadata.name; + refreshed.installdir = metadata.installdir; + refreshed.launch = metadata + .launch + .into_iter() + .map(crate::model::LaunchEntry::from) + .collect(); + refreshed.cloud_config = metadata.cloud_config; + refreshed.launch_metadata_known = true; + Ok(refreshed) +} + +async fn game_for_launch<'a>( + game: &'a Game, + windows: bool, + metadata: impl std::future::Future>, +) -> Result> { + if windows { + return Ok(std::borrow::Cow::Borrowed(game)); + } + // `known` distinguishes legacy caches from an explicitly empty UFS object; + // it does not prove that launch options or save paths survived a game update. + // Native launch already requires a live connection, so resolve its current + // PICS metadata each time before selecting an executable or save policy. + let metadata = metadata.await.context( + "Не удалось обновить данные запуска и сохранений. Обновите библиотеку и повторите запуск", + )?; + game_with_fresh_launch_metadata(game, metadata).map(std::borrow::Cow::Owned) +} + fn launch_arguments( entry: &crate::model::LaunchEntry, options: &skinos_compat::GameSettings, @@ -794,6 +844,13 @@ async fn launch( record.appid == game.appid, "Installed game identity mismatch" ); + let game = game_for_launch( + game, + windows, + skinos_downloader::application_metadata(&context.connection, game.appid), + ) + .await?; + let game = game.as_ref(); let entry = launch_entry(game, destination, windows)?; let executable = relative_game_path(destination, &entry.executable)?; let working_dir = match entry.workingdir.as_deref().filter(|v| !v.is_empty()) { @@ -875,6 +932,7 @@ async fn launch( let mut launch = json!({"protocol_version":1,"command":"launch","appid":game.appid,"steamid":context.steamid, "account_name":account.account_name,"refresh_token":token.expose_secret(),"install_dir":destination, "executable":executable,"arguments":arguments,"working_dir":working_dir, + "launch_option_id":entry.id,"cloud_config":game.cloud_config, "name":game.name,"build_id":record.build_id,"depots":record.depots,"compatibility":compatibility,"persona_state":initial_persona}); launch["input_diagnostic"] = json!(input_diagnostic); // A requested diagnostic temporarily enables the same input path without @@ -1039,6 +1097,7 @@ mod tests { } fn entry(exe: &str, os: &str) -> crate::model::LaunchEntry { crate::model::LaunchEntry { + id: None, executable: exe.into(), arguments: Some("--base 'a b'".into()), workingdir: None, @@ -1050,7 +1109,137 @@ mod tests { } fn game(launch: Vec) -> Game { serde_json::from_value(json!({"appid":42,"name":"Game","playtime_minutes":0,"last_played":0,"app_type":"game", - "installdir":"Game","installed_dir":null,"macos_supported":true,"windows_supported":true,"header_url":"","launch":launch})).unwrap() + "installdir":"Game","installed_dir":null,"macos_supported":true,"windows_supported":true,"header_url":"","launch_metadata_known":true,"launch":launch})).unwrap() + } + #[test] + fn launch_selection_uses_original_numeric_id_after_platform_and_file_checks() { + let dir = tempfile::tempdir().unwrap(); + for filename in ["high.app", "low.app", "private.app", "windows.exe"] { + std::fs::write(dir.path().join(filename), b"fixture").unwrap(); + } + let mut high = entry("high.app", "macos"); + high.id = Some(19); + let mut low = entry("low.app", "macos"); + low.id = Some(2); + let mut missing = entry("missing.app", "macos"); + missing.id = Some(0); + let mut private = entry("private.app", "macos"); + private.id = Some(1); + private.betakey = Some("private".into()); + let mut windows = entry("windows.exe", "windows"); + windows.id = Some(0); + let g = game(vec![high, private, missing, windows, low]); + assert_eq!(launch_entry(&g, dir.path(), false).unwrap().id, Some(2)); + assert_eq!(launch_entry(&g, dir.path(), true).unwrap().id, Some(0)); + } + + #[test] + fn legacy_launch_selection_keeps_first_matching_entry() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("first"), b"fixture").unwrap(); + std::fs::write(dir.path().join("second"), b"fixture").unwrap(); + let g = game(vec![entry("first", "macos"), entry("second", "macos")]); + assert_eq!( + launch_entry(&g, dir.path(), false).unwrap().executable, + "first" + ); + } + + #[test] + fn native_launch_rejects_legacy_unknown_cloud_metadata() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("game"), b"fixture").unwrap(); + let mut g = game(vec![entry("game", "macos")]); + g.launch_metadata_known = false; + assert!(launch_entry(&g, dir.path(), false).is_err()); + } + + #[test] + fn fresh_single_app_metadata_resolves_legacy_unknown_and_known_no_cloud() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("game"), b"fixture").unwrap(); + let mut legacy = game(vec![entry("old", "macos")]); + legacy.launch_metadata_known = false; + legacy.playtime_minutes = 123; + let metadata = || steam_cm_protocol::pics::AppCatalogInfo { + appid: legacy.appid, + name: "Fresh identity".into(), + img_icon_url: None, + app_type: Some("game".into()), + installdir: Some("Fresh folder".into()), + launch: vec![steam_cm_protocol::LaunchEntry { + id: Some(19), + executable: "game".into(), + oslist: Some("macos".into()), + ..Default::default() + }], + cloud_config: None, + }; + let refreshed = game_with_fresh_launch_metadata(&legacy, metadata()).unwrap(); + assert!(refreshed.launch_metadata_known); + assert!(refreshed.cloud_config.is_none(), "known absence is valid"); + assert_eq!(refreshed.name, "Fresh identity"); + assert_eq!(refreshed.playtime_minutes, 123); + assert_eq!( + launch_entry(&refreshed, dir.path(), false).unwrap().id, + Some(19) + ); + let mut wrong = metadata(); + wrong.appid += 1; + assert!(game_with_fresh_launch_metadata(&legacy, wrong).is_err()); + let mut with_cloud = metadata(); + with_cloud.cloud_config = + Some(json!({"savefiles":{"0":{"root":"MacDocuments","path":"Fresh identity"}}})); + assert!( + game_with_fresh_launch_metadata(&legacy, with_cloud) + .unwrap() + .cloud_config + .is_some() + ); + } + + #[tokio::test] + async fn known_native_cache_refreshes_changed_launch_and_cloud_but_windows_stays_cached() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("new-game"), b"fixture").unwrap(); + let mut stale_entry = entry("old-game", "macos"); + stale_entry.id = Some(2); + let mut cached = game(vec![stale_entry]); + cached.launch_metadata_known = true; + cached.cloud_config = + Some(json!({"savefiles":{"0":{"root":"MacDocuments","path":"OldGame"}}})); + let current_cloud = json!({"savefiles":{"0":{"root":"GameInstall","path":"saves"}}}); + let refreshed = game_for_launch(&cached, false, async { + Ok(steam_cm_protocol::pics::AppCatalogInfo { + appid: cached.appid, + name: cached.name.clone(), + img_icon_url: None, + app_type: Some("game".into()), + installdir: cached.installdir.clone(), + launch: vec![steam_cm_protocol::LaunchEntry { + id: Some(19), + executable: "new-game".into(), + oslist: Some("macos".into()), + ..Default::default() + }], + cloud_config: Some(current_cloud.clone()), + }) + }) + .await + .unwrap(); + let selected = launch_entry(refreshed.as_ref(), dir.path(), false).unwrap(); + assert_eq!(selected.id, Some(19)); + assert_eq!(selected.executable, "new-game"); + assert_eq!(refreshed.cloud_config.as_ref(), Some(¤t_cloud)); + + let windows = game_for_launch(&cached, true, async { + bail!("Windows metadata refresh must not be polled") + }) + .await + .unwrap(); + assert!(matches!(windows, std::borrow::Cow::Borrowed(_))); + assert_eq!(windows.launch[0].id, Some(2)); + assert_eq!(windows.cloud_config, cached.cloud_config); } #[test] fn detects_actual_installed_platform_and_ignores_beta_tools() { diff --git a/crates/client-core/vendor/steam-cm-protocol/CHANGELOG.md b/crates/client-core/vendor/steam-cm-protocol/CHANGELOG.md index 5c2bacd..34ec132 100644 --- a/crates/client-core/vendor/steam-cm-protocol/CHANGELOG.md +++ b/crates/client-core/vendor/steam-cm-protocol/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## SkinOS local changes - 2026-10-15 + +These changes apply to the vendored SkinOS copy, not an upstream release. See +[SKINOS-PATCHES.md](SKINOS-PATCHES.md) for provenance and the other local patches. + +### Added + +- `LaunchEntry.id` preserves the numeric PICS launch-option key. Parsed entries + are sorted by ID, retaining sparse option IDs rather than treating them as indices. +- `AppCatalogInfo.cloud_config` and `ProtocolGame.cloud_config` preserve appinfo + `ufs` save rules and root overrides, with matching text/binary scalar normalization. +- `pics::parse_app_info_kv` decodes already-fetched appinfo. Authentication and + requested-AppID validation remain the caller's responsibility. + ## [v0.4.1] - 2026-06-09 diff --git a/crates/client-core/vendor/steam-cm-protocol/README.md b/crates/client-core/vendor/steam-cm-protocol/README.md index 872abbb..0bdf0c3 100644 --- a/crates/client-core/vendor/steam-cm-protocol/README.md +++ b/crates/client-core/vendor/steam-cm-protocol/README.md @@ -7,6 +7,23 @@ Rust APIs for client-style Steam features — authentication, friends, chat, and without depending on the Steam client or Web API key. It was built for, and is used by, [Steamie](https://github.com/LargeModGames/steamie), but it is a standalone crate you can use on its own. +## SkinOS vendored changes + +This copy includes local SkinOS patches; it is not an unmodified upstream release. +[SKINOS-PATCHES.md](SKINOS-PATCHES.md) records the upstream revision and local changes. + +Launch metadata preserves each numeric `config/launch` key as `LaunchEntry.id` and +sorts entries by that ID. IDs can be sparse and must not be replaced with vector indices. +`AppCatalogInfo` and `ProtocolGame` also expose the appinfo `ufs` object as +`cloud_config`, including numbered save rules and root overrides. Text and binary +PICS parsing normalize scalar values to strings. This supplies metadata only; +callers still validate paths and implement any Cloud synchronization separately. + +`pics::parse_app_info_kv` decodes already-fetched `KVValue` appinfo using the same +launch and UFS rules as library loading. It does not fetch metadata or verify an +account's entitlement; the caller must fetch through its authenticated connection +and validate the requested AppID. + ## Features - Steam authentication: QR, username/password (with Steam Guard), and refresh-token reuse. diff --git a/crates/client-core/vendor/steam-cm-protocol/SKINOS-PATCHES.md b/crates/client-core/vendor/steam-cm-protocol/SKINOS-PATCHES.md index 1f31131..e02fdb1 100644 --- a/crates/client-core/vendor/steam-cm-protocol/SKINOS-PATCHES.md +++ b/crates/client-core/vendor/steam-cm-protocol/SKINOS-PATCHES.md @@ -9,5 +9,8 @@ SkinOs changes: - Public binary KV parser for depot metadata. - Depot key EMsg 5438/5439 (SteamTracking/Protobufs steam/enums_clientserver.proto). - Device display name SkinOs; macOS sends EOSType::MacOSUnknown (-102) instead of Windows 11. Enum source: SteamRE/SteamKit SteamKit2/SteamKit2/Base/Generated/SteamLanguage.cs. +- Preserve numeric PICS launch-option IDs, sorting them numerically without replacing sparse keys with vector indices. +- Preserve raw appinfo UFS save rules and root overrides in catalog/library metadata, normalizing text and binary scalar values to strings. +- Expose `pics::parse_app_info_kv` for already-fetched appinfo; authenticated fetching and requested-AppID validation remain the caller's responsibility. Synthetic fixtures verify identical text/binary metadata and sparse option IDs. No bypass, credential import, or official Steam process launch is added. diff --git a/crates/client-core/vendor/steam-cm-protocol/src/friends.rs b/crates/client-core/vendor/steam-cm-protocol/src/friends.rs index e53b65a..52a5b8e 100644 --- a/crates/client-core/vendor/steam-cm-protocol/src/friends.rs +++ b/crates/client-core/vendor/steam-cm-protocol/src/friends.rs @@ -76,6 +76,8 @@ pub struct Persona { /// Used by the direct (no-Steam) launch path to resolve the executable without the Steam client. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct LaunchEntry { + /// Numeric child key in `config/launch`; this is not the entry's vector index. + pub id: Option, /// Executable path, relative to the game's install directory (e.g. `bin/game.exe`). pub executable: String, /// Launch arguments, if any. @@ -107,6 +109,8 @@ pub struct ProtocolGame { pub installdir: Option, /// Appinfo `config/launch` entries (how Steam would launch it). Empty when unresolved. pub launch: Vec, + /// Raw appinfo `ufs` object. Scalar leaves use strings in both PICS encodings. + pub cloud_config: Option, } #[derive(Debug, Clone)] diff --git a/crates/client-core/vendor/steam-cm-protocol/src/library.rs b/crates/client-core/vendor/steam-cm-protocol/src/library.rs index f4b2c5b..0f8583e 100644 --- a/crates/client-core/vendor/steam-cm-protocol/src/library.rs +++ b/crates/client-core/vendor/steam-cm-protocol/src/library.rs @@ -59,6 +59,7 @@ pub fn recently_played_games(playtimes: &HashMap) -> Vec, /// Appinfo `config/launch` entries — used by the direct (no-Steam) launch path. pub launch: Vec, + /// Raw appinfo `ufs` object, retaining numbered savefiles and root overrides. + pub cloud_config: Option, } /// Library entries we surface: real games plus owned software/tools. DLC, soundtracks (music), @@ -250,6 +252,7 @@ async fn resolve_app_infos( app_type: None, installdir: None, launch: Vec::new(), + cloud_config: None, }, ) }) @@ -455,6 +458,12 @@ fn hex_prefix(buffer: &[u8], limit: usize) -> String { fn parse_binary_app_info(appid: u32, buffer: &[u8]) -> Option { let root = kv::parse_binary_kv(buffer)?; + parse_app_info_kv(appid, &root) +} + +/// Decode already-fetched appinfo with the same launch/UFS interpretation as +/// the library parser. Callers are responsible for the authenticated fetch. +pub fn parse_app_info_kv(appid: u32, root: &KVValue) -> Option { let common = root .get("appinfo") .and_then(|appinfo| appinfo.get("common")) @@ -488,6 +497,12 @@ fn parse_binary_app_info(appid: u32, buffer: &[u8]) -> Option { .and_then(|config| config.get("launch")) .map(launch_entries_from_kv) .unwrap_or_default(); + let cloud_config = root + .get("appinfo") + .and_then(|appinfo| appinfo.get("ufs")) + .or_else(|| root.get("ufs")) + .filter(|ufs| ufs.as_nested().is_some()) + .map(kv_json); Some(AppCatalogInfo { appid, @@ -496,6 +511,7 @@ fn parse_binary_app_info(appid: u32, buffer: &[u8]) -> Option { app_type, installdir, launch, + cloud_config, }) } @@ -504,9 +520,10 @@ fn launch_entries_from_kv(launch: &KVValue) -> Vec { let Some(children) = launch.as_nested() else { return Vec::new(); }; - children + let mut entries: Vec<_> = children .iter() - .filter_map(|(_, entry)| { + .filter_map(|(key, entry)| { + let id = key.parse::().ok()?; let executable = entry .get("executable") .and_then(|value| value.as_str()) @@ -520,6 +537,7 @@ fn launch_entries_from_kv(launch: &KVValue) -> Vec { .map(str::to_owned) }; Some(LaunchEntry { + id: Some(id), executable, arguments: str_field(Some(entry), "arguments"), workingdir: str_field(Some(entry), "workingdir"), @@ -529,6 +547,36 @@ fn launch_entries_from_kv(launch: &KVValue) -> Vec { betakey: str_field(cfg, "betakey"), }) }) + .collect(); + entries.sort_by_key(|entry| entry.id); + entries +} + +// UFS originates in KeyValues; normalize typed binary scalars to the string +// representation of text appinfo so consumers do not depend on the transport. +fn kv_json(value: &KVValue) -> serde_json::Value { + match value { + KVValue::Nested(children) => children + .iter() + .map(|(key, value)| (key.clone(), kv_json(value))) + .collect(), + KVValue::Str(value) => serde_json::Value::String(value.clone()), + KVValue::Int(value) => serde_json::Value::String(value.to_string()), + KVValue::Uint64(value) => serde_json::Value::String(value.to_string()), + KVValue::Other => serde_json::Value::Null, + } +} + +fn vdf_json(node: &VdfNode) -> serde_json::Value { + node.values + .iter() + .map(|(key, value)| { + let value = match value { + VdfValue::Node(node) => vdf_json(node), + VdfValue::Str(value) => serde_json::Value::String(value.clone()), + }; + (key.clone(), value) + }) .collect() } @@ -564,6 +612,11 @@ fn parse_text_app_info(appid: u32, buffer: &[u8]) -> Option { .and_then(|config| config.get_node("launch")) .map(launch_entries_from_vdf) .unwrap_or_default(); + let cloud_config = root + .get_node("appinfo") + .and_then(|appinfo| appinfo.get_node("ufs")) + .or_else(|| root.get_node("ufs")) + .map(vdf_json); Some(AppCatalogInfo { appid, @@ -572,16 +625,18 @@ fn parse_text_app_info(appid: u32, buffer: &[u8]) -> Option { app_type, installdir, launch, + cloud_config, }) } /// Collect `config/launch` entries from a text-VDF `launch` node (numbered child nodes). fn launch_entries_from_vdf(launch: &VdfNode) -> Vec { - launch + let mut entries: Vec<_> = launch .values .iter() - .filter_map(|(_, value)| match value { + .filter_map(|(key, value)| match value { VdfValue::Node(entry) => { + let id = key.parse::().ok()?; let executable = entry .get_str("executable") .filter(|value| !value.is_empty())? @@ -593,6 +648,7 @@ fn launch_entries_from_vdf(launch: &VdfNode) -> Vec { .map(str::to_owned) }; Some(LaunchEntry { + id: Some(id), executable, arguments: str_field(Some(entry), "arguments"), workingdir: str_field(Some(entry), "workingdir"), @@ -604,7 +660,9 @@ fn launch_entries_from_vdf(launch: &VdfNode) -> Vec { } VdfValue::Str(_) => None, }) - .collect() + .collect(); + entries.sort_by_key(|entry| entry.id); + entries } #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -771,6 +829,135 @@ fn parse_vdf_node(lexer: &mut VdfLexer<'_>, stop_on_close: bool) -> Option>(), + ["first.app", "second.app"] + ); + assert_eq!( + app.launch.iter().map(|entry| entry.id).collect::>(), + [Some(2), Some(19)] + ); + } + + fn binary_fixture(node: &KVValue) -> Vec { + fn append(out: &mut Vec, key: &str, value: &KVValue) { + out.push(match value { + KVValue::Nested(_) => 0, + KVValue::Str(_) => 1, + KVValue::Int(_) => 2, + KVValue::Uint64(_) => 7, + KVValue::Other => panic!("unsupported fixture"), + }); + out.extend_from_slice(key.as_bytes()); + out.push(0); + match value { + KVValue::Nested(children) => { + for (key, value) in children { + append(out, key, value); + } + out.push(8); + } + KVValue::Str(value) => { + out.extend_from_slice(value.as_bytes()); + out.push(0); + } + KVValue::Int(value) => out.extend_from_slice(&value.to_le_bytes()), + KVValue::Uint64(value) => out.extend_from_slice(&value.to_le_bytes()), + KVValue::Other => unreachable!(), + } + } + let mut bytes = Vec::new(); + append(&mut bytes, "appinfo", node); + bytes + } + + #[test] + fn text_and_binary_preserve_same_cloud_metadata_and_sparse_launch_ids() { + let text = br#""appinfo" { + "common" { "name" "Game" } + "config" { "launch" { + "19" { "executable" "other.app" } + "2" { "executable" "Game.app" "config" { "oslist" "macos" } } + } } + "ufs" { + "quota" "1000" "maxnumfiles" "20" + "savefiles" { "7" { + "root" "MacAppSupport" "path" "Studio/Game/{64BitSteamID}" + "pattern" "*.sav" "recursive" "1" "platforms" { "1" "MacOS" } + } } + "rootoverrides" { "5" { + "root" "WinAppDataRoaming" "os" "MacOS" "oscompare" "=" + "useinstead" "MacAppSupport" "addpath" "Studio" + } } + } + }"#; + let vdf = parse_vdf(std::str::from_utf8(text).unwrap()).unwrap(); + fn as_kv(node: &VdfNode) -> KVValue { + KVValue::Nested( + node.values + .iter() + .map(|(key, value)| { + let value = match value { + VdfValue::Node(node) => as_kv(node), + VdfValue::Str(value) if key == "quota" => { + KVValue::Uint64(value.parse().unwrap()) + } + VdfValue::Str(value) if key == "maxnumfiles" || key == "recursive" => { + KVValue::Int(value.parse().unwrap()) + } + VdfValue::Str(value) => KVValue::Str(value.clone()), + }; + (key.clone(), value) + }) + .collect(), + ) + } + let binary = binary_fixture(&as_kv(vdf.get_node("appinfo").unwrap())); + let text_app = parse_app_info(42, text).unwrap(); + let binary_app = parse_app_info(42, &binary).unwrap(); + let fetched_app = parse_app_info_kv(42, &kv::parse_binary_kv(&binary).unwrap()).unwrap(); + assert_eq!(text_app, binary_app); + assert_eq!(binary_app, fetched_app); + assert_eq!( + binary_app + .launch + .iter() + .map(|entry| entry.id) + .collect::>(), + [Some(2), Some(19)] + ); + let cloud = binary_app.cloud_config.unwrap(); + assert_eq!(cloud["quota"], "1000"); + assert_eq!(cloud["maxnumfiles"], "20"); + assert_eq!(cloud["savefiles"]["7"]["recursive"], "1"); + assert_eq!( + cloud["savefiles"]["7"]["path"], + "Studio/Game/{64BitSteamID}" + ); + assert_eq!(cloud["savefiles"]["7"]["platforms"]["1"], "MacOS"); + assert_eq!(cloud["rootoverrides"]["5"]["useinstead"], "MacAppSupport"); + } + fn catalog_app(appid: u32, name: &str, app_type: Option<&str>) -> AppCatalogInfo { AppCatalogInfo { appid, @@ -779,6 +966,7 @@ mod tests { app_type: app_type.map(str::to_owned), installdir: None, launch: Vec::new(), + cloud_config: None, } } diff --git a/crates/downloader/src/lib.rs b/crates/downloader/src/lib.rs index 402f8a6..1e295f5 100644 --- a/crates/downloader/src/lib.rs +++ b/crates/downloader/src/lib.rs @@ -24,6 +24,18 @@ use tokio::sync::Mutex; pub type SharedConnection = Arc>; pub type ProgressCallback = Arc; +/// Refresh one app's launch and Cloud settings over the current authenticated +/// CM connection. This does not acquire a license or change installed files. +pub async fn application_metadata( + connection: &SharedConnection, + appid: u32, +) -> Result { + ensure!(appid != 0, "Invalid application metadata identity"); + let appinfo = network::app_info(connection, appid).await?; + steam_cm_protocol::pics::parse_app_info_kv(appid, &appinfo) + .context("Steam returned incomplete application metadata") +} + #[derive(Debug, Clone)] pub struct InstallRequest { pub appid: u32, diff --git a/crates/runtime-host/native/launch.m b/crates/runtime-host/native/launch.m index 31a21e1..ddbaab0 100644 --- a/crates/runtime-host/native/launch.m +++ b/crates/runtime-host/native/launch.m @@ -1,7 +1,5 @@ -// Mac platform launch adapter: real Steam engine still owns authentication, -// ownership, launch metadata and process lifetime. Only NSWorkspace's remote -// LaunchServices handoff is replaced with a direct, confined child spawn so the -// child retains the intended environment and owned IPC broker namespace. +// Steam owns authentication, entitlement and the selected launch option. This +// adapter confines its platform handoff to the parent's validated native plan. #import #import #include @@ -12,16 +10,19 @@ #include #include #include +#include #include "launch_guard.h" extern char **environ; static _Atomic(pid_t) last_game_pid=0; +static _Atomic(pid_t) last_game_group=0; +static _Atomic(uint64_t) last_game_started_microseconds=0; static NSString *last_game_executable; pid_t skinos_last_game_pid(void) { return atomic_load(&last_game_pid); } +pid_t skinos_last_game_group(void) { return atomic_load(&last_game_group); } +uint64_t skinos_last_game_started_microseconds(void) { return atomic_load(&last_game_started_microseconds); } const char *skinos_last_game_executable(void) { return last_game_executable.fileSystemRepresentation; } -// Steam's reviewed Mac platform wrapper reads only processIdentifier from its -// successful launch result. This object represents the actual posix_spawn PID. @interface SkinOsChildProcess : NSObject @property(nonatomic) pid_t processIdentifier; @end @@ -33,30 +34,86 @@ static id rejected(NSError **error,const char *reason) { if (error) *error=[NSError errorWithDomain:@"dev.skinos.launch" code:EPERM userInfo:@{NSLocalizedDescriptionKey:@"Launch context was rejected"}]; return nil; } +static NSString *configured(const char *name) { + const char *value=getenv(name); + return value ? [NSString stringWithUTF8String:value] : nil; +} +static NSString *resolved(NSString *path) { + if (!path) return nil; + char canonical[PATH_MAX]; + return realpath(path.fileSystemRepresentation,canonical) ? [NSString stringWithUTF8String:canonical] : nil; +} +static bool contained(NSString *path,NSString *root) { + return path && root && [path hasPrefix:[root stringByAppendingString:@"/"]]; +} +static bool regular_macho(NSString *path) { + NSFileHandle *file=[NSFileHandle fileHandleForReadingAtPath:path]; + NSData *header=[file readDataOfLength:4]; + [file closeFile]; + if (header.length!=4) return false; + const unsigned char *b=header.bytes; + uint32_t magic=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3]; + return magic==0xfeedface || magic==0xfeedfacf || magic==0xcefaedfe || magic==0xcffaedfe || + magic==0xcafebabe || magic==0xbebafeca || magic==0xcafebabf || magic==0xbfbafeca; +} +static bool string_arguments(id value) { + if (![value isKindOfClass:[NSArray class]] || [value count]>128) return false; + for (id argument in value) { + if (![argument isKindOfClass:[NSString class]] || [argument length]>16384 || + strlen([argument UTF8String])!=[argument lengthOfBytesUsingEncoding:NSUTF8StringEncoding]) return false; + } + return true; +} +// This fixed code restores only our adapter after the protected system shell +// strips DYLD_* on entry. The published script remains the launcher, with its +// original $0, positional arguments and cwd. No path is interpolated as code. +static NSString *const scriptBootstrap=@"export DYLD_INSERT_LIBRARIES=\"$SKINOS_ADAPTER_PATH\"; . \"$0\""; static id native_launch(id object,SEL selector,NSURL *url,NSWorkspaceLaunchOptions options,NSDictionary *configuration,NSError **error) { (void)object;(void)selector;(void)options; const char *expected=getenv("SKINOS_LAUNCH_APP_ID"); - const char *selected=getenv("SKINOS_GAME_BUNDLE"); + NSString *selected=configured("SKINOS_GAME_BUNDLE"); if (!url.isFileURL || !selected || !expected) return rejected(error,"non_file_or_unconfigured_target"); - NSString *target=url.path.stringByResolvingSymlinksInPath; - NSString *allowed=[[NSString stringWithUTF8String:selected] stringByResolvingSymlinksInPath]; - if (![target isEqualToString:allowed]) return rejected(error,"different_application"); - NSDictionary *provided=configuration[NSWorkspaceLaunchConfigurationEnvironment]; + NSString *target=resolved(url.path); + NSString *allowed=resolved(selected); + NSString *root=resolved(configured("SKINOS_GAME_INSTALL_ROOT")); + if (!target || ![target isEqualToString:allowed]) return rejected(error,"different_application"); + if (!contained(target,root)) return rejected(error,"target_outside_installation"); + id provided=configuration[NSWorkspaceLaunchConfigurationEnvironment]; + if (![provided isKindOfClass:[NSDictionary class]]) return rejected(error,"missing_steam_environment"); + for (id key in provided) if (![key isKindOfClass:[NSString class]] || ![provided[key] isKindOfClass:[NSString class]]) return rejected(error,"invalid_steam_environment"); NSString *appid=provided[@"SteamAppId"]; NSString *gameid=provided[@"SteamGameId"]; - NSString *launched=provided[@"SteamClientLaunch"]; bool valid=skinos_launch_context_valid(expected,appid.UTF8String,gameid.UTF8String); - fprintf(stderr,"SKINOS_LAUNCH context appid_matches=%d gameid_matches=%d client_launch_present=%d valid=%d\n",appid && strcmp(expected,appid.UTF8String)==0,gameid && strcmp(expected,gameid.UTF8String)==0,launched!=nil,valid);fflush(stderr); + fprintf(stderr,"SKINOS_LAUNCH context appid_matches=%d gameid_matches=%d client_launch_present=%d valid=%d\n",appid && strcmp(expected,appid.UTF8String)==0,gameid && strcmp(expected,gameid.UTF8String)==0,provided[@"SteamClientLaunch"]!=nil,valid);fflush(stderr); if (!valid) return rejected(error,"missing_or_wrong_steam_context"); - NSBundle *bundle=[NSBundle bundleWithPath:target]; - NSString *executable=bundle.executablePath; - if (!executable || ![executable.stringByResolvingSymlinksInPath hasPrefix:[allowed stringByAppendingString:@"/"]]) return rejected(error,"invalid_bundle_executable"); + NSString *kind=configured("SKINOS_NATIVE_TARGET_KIND"); + NSString *executable=nil; + NSMutableArray *prefix=[NSMutableArray array]; + if ([kind isEqualToString:@"bundle"]) { + executable=resolved([NSBundle bundleWithPath:target].executablePath); + if (!contained(executable,target) || !regular_macho(executable)) return rejected(error,"invalid_bundle_executable"); + } else if ([kind isEqualToString:@"binary"]) { + executable=target; + if (!regular_macho(executable)) return rejected(error,"invalid_native_binary"); + } else if ([kind isEqualToString:@"script"]) { + executable=configured("SKINOS_NATIVE_INTERPRETER"); + if (!([executable isEqualToString:@"/bin/sh"] || [executable isEqualToString:@"/bin/bash"])) return rejected(error,"unsupported_script_interpreter"); + NSString *option=configured("SKINOS_NATIVE_INTERPRETER_ARGUMENT"); + if (option) { + NSCharacterSet *flags=[NSCharacterSet characterSetWithCharactersInString:@"eufvx"]; + if (![option hasPrefix:@"-"] || option.length<2 || [[option substringFromIndex:1] rangeOfCharacterFromSet:flags.invertedSet].location!=NSNotFound) return rejected(error,"unsupported_script_option"); + [prefix addObject:option]; + } + [prefix addObjectsFromArray:@[@"-c",scriptBootstrap,target]]; + } else return rejected(error,"unknown_native_target_kind"); + if (![resolved(configured("SKINOS_NATIVE_EXECUTABLE")) isEqualToString:([kind isEqualToString:@"script"] ? target : executable)]) return rejected(error,"selected_executable_changed"); + NSString *cwd=resolved(configured("SKINOS_GAME_WORKING_DIR")); + BOOL cwdDirectory=NO; + if (!cwd || !([cwd isEqualToString:root] || contained(cwd,root)) || ![[NSFileManager defaultManager] fileExistsAtPath:cwd isDirectory:&cwdDirectory] || !cwdDirectory) return rejected(error,"invalid_working_directory"); const char *adapter=getenv("SKINOS_ADAPTER_PATH"); const char *service=getenv("SKINOS_IPC_SERVICE_NAME"); if (!adapter || !service || strncmp(service,"dev.skinos.ipctool.",19)!=0) return rejected(error,"owned_broker_missing"); - // The parent worker is the sole lock owner. Valve/Wine may clear CLOEXEC, - // so require an explicit close action for this descriptor in the game. const char *lockValue=getenv("SKINOS_PROFILE_LOCK_FD"); char *lockEnd=NULL; errno=0; @@ -65,76 +122,83 @@ static id native_launch(id object,SEL selector,NSURL *url,NSWorkspaceLaunchOptio NSMutableDictionary *environment=[NSMutableDictionary dictionary]; for (char **entry=environ;entry && *entry;entry++) { NSString *line=[NSString stringWithUTF8String:*entry]; + if (!line) continue; NSRange equal=[line rangeOfString:@"="]; if (equal.location!=NSNotFound) environment[[line substringToIndex:equal.location]]=[line substringFromIndex:equal.location+1]; } [environment addEntriesFromDictionary:provided]; [environment removeObjectForKey:@"SKINOS_PROFILE_LOCK_FD"]; - // Deliberately inject only our documented IPC/directory adapter into this - // selected game. The game's Steam SDK and all authorization stay original. environment[@"DYLD_INSERT_LIBRARIES"]=[NSString stringWithUTF8String:adapter]; + environment[@"SKINOS_ADAPTER_PATH"]=[NSString stringWithUTF8String:adapter]; + environment[@"SKINOS_LAUNCH_APP_ID"]=[NSString stringWithUTF8String:expected]; environment[@"SKINOS_COMPONENT_ROLE"]=@"game"; environment[@"SKINOS_IPC_SERVICE_NAME"]=[NSString stringWithUTF8String:service]; - NSArray *arguments=configuration[NSWorkspaceLaunchConfigurationArguments] ?: @[]; + id arguments=configuration[NSWorkspaceLaunchConfigurationArguments] ?: @[]; const char *requestedArguments=getenv("SKINOS_GAME_ARGUMENTS_JSON"); if (requestedArguments) { NSData *data=[[NSString stringWithUTF8String:requestedArguments] dataUsingEncoding:NSUTF8StringEncoding]; - id parsed=[NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (![parsed isKindOfClass:[NSArray class]]) return rejected(error,"invalid_arguments"); - for (id argument in parsed) if (![argument isKindOfClass:[NSString class]]) return rejected(error,"invalid_argument_type"); - arguments=parsed; + arguments=[NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; } - if (arguments.count>128) return rejected(error,"argument_limit"); - char **argv=calloc(arguments.count+2,sizeof(char *)); + if (!string_arguments(arguments)) return rejected(error,"invalid_arguments"); + [prefix addObjectsFromArray:arguments]; + char **argv=calloc(prefix.count+2,sizeof(char *)); char **envp=calloc(environment.count+1,sizeof(char *)); if (!argv || !envp) { free(argv);free(envp);return rejected(error,"allocation_failure"); } argv[0]=strdup(executable.fileSystemRepresentation); - for (NSUInteger i=0;i +#include #include -int main(void) { +#include +#include +#include +static NSArray *observedArguments; +static NSDictionary *observedEnvironment; +static NSString *observedExecutable; +static NSString *observedCwd; +static int observedClose=-1; +static short observedFlags=0; +static pid_t observedGroup=-1; +static int spawnCalls=0; +static int failAttributeStep=0; +static int spawnError=0; +static int initializedAttributes=0; +static int destroyedAttributes=0; +static int fixture_spawn(pid_t *pid,const char *path,const posix_spawn_file_actions_t *actions,const posix_spawnattr_t *attributes,char *const argv[],char *const envp[]) { + (void)actions; + spawnCalls++; + observedFlags=0;observedGroup=-1; + if (attributes) { + assert(posix_spawnattr_getflags(attributes,&observedFlags)==0); + assert(posix_spawnattr_getpgroup(attributes,&observedGroup)==0); + } + if (spawnError) return spawnError; + observedExecutable=[NSString stringWithUTF8String:path]; + NSMutableArray *args=[NSMutableArray array]; + for (int i=0;argv[i];i++) [args addObject:[NSString stringWithUTF8String:argv[i]]]; + observedArguments=args; + NSMutableDictionary *env=[NSMutableDictionary dictionary]; + for (int i=0;envp[i];i++) { + NSString *line=[NSString stringWithUTF8String:envp[i]]; + NSRange equal=[line rangeOfString:@"="]; + env[[line substringToIndex:equal.location]]=[line substringFromIndex:equal.location+1]; + } + observedEnvironment=env; + *pid=4242; + return 0; +} +static int fixture_chdir(posix_spawn_file_actions_t *actions,const char *path) { + observedCwd=[NSString stringWithUTF8String:path]; + return posix_spawn_file_actions_addchdir_np(actions,path); +} +static int fixture_close(posix_spawn_file_actions_t *actions,int fd) { + observedClose=fd; + return posix_spawn_file_actions_addclose(actions,fd); +} +static int fixture_attr_init(posix_spawnattr_t *attributes) { + if (failAttributeStep==1) return EINVAL; + int result=posix_spawnattr_init(attributes); + if (!result) initializedAttributes++; + return result; +} +static int fixture_attr_group(posix_spawnattr_t *attributes,pid_t group) { + if (failAttributeStep==2) return EINVAL; + return posix_spawnattr_setpgroup(attributes,group); +} +static int fixture_attr_flags(posix_spawnattr_t *attributes,short flags) { + if (failAttributeStep==3) return EINVAL; + return posix_spawnattr_setflags(attributes,flags); +} +static int fixture_attr_destroy(posix_spawnattr_t *attributes) { + destroyedAttributes++; + return posix_spawnattr_destroy(attributes); +} +#define posix_spawnattr_init fixture_attr_init +#define posix_spawnattr_setpgroup fixture_attr_group +#define posix_spawnattr_setflags fixture_attr_flags +#define posix_spawnattr_destroy fixture_attr_destroy +#define posix_spawn fixture_spawn +#define posix_spawn_file_actions_addchdir_np fixture_chdir +#define posix_spawn_file_actions_addclose fixture_close +#import "launch.m" +#undef posix_spawn +#undef posix_spawnattr_init +#undef posix_spawnattr_setpgroup +#undef posix_spawnattr_setflags +#undef posix_spawnattr_destroy +#undef posix_spawn_file_actions_addchdir_np +#undef posix_spawn_file_actions_addclose + +static void write_macho(NSString *path) { + [[NSFileManager defaultManager] createDirectoryAtPath:path.stringByDeletingLastPathComponent withIntermediateDirectories:YES attributes:nil error:NULL]; + const unsigned char bytes[]={0xcf,0xfa,0xed,0xfe,7,0,0,1,0,0,0,0}; + assert([[NSData dataWithBytes:bytes length:sizeof(bytes)] writeToFile:path atomically:YES]); +} +static void select_target(NSString *target,NSString *executable,const char *kind) { + setenv("SKINOS_GAME_BUNDLE",target.fileSystemRepresentation,1); + setenv("SKINOS_NATIVE_EXECUTABLE",executable.fileSystemRepresentation,1); + setenv("SKINOS_NATIVE_TARGET_KIND",kind,1); +} +int main(int argc,const char *argv[]) { @autoreleasepool { + if (argc>1 && strcmp(argv[1],"--script-child")==0) { + NSMutableArray *values=[NSMutableArray arrayWithObject:[[NSFileManager defaultManager] currentDirectoryPath]]; + for (int i=2;i2); + char fd[32];snprintf(fd,sizeof(fd),"%d",lock);setenv("SKINOS_PROFILE_LOCK_FD",fd,1); + NSArray *arguments=@[@"two words",@"quote'\"",@"$(must-not-run)"]; + NSData *encoded=[NSJSONSerialization dataWithJSONObject:arguments options:0 error:NULL]; + setenv("SKINOS_GAME_ARGUMENTS_JSON",[[[NSString alloc] initWithData:encoded encoding:NSUTF8StringEncoding] UTF8String],1); + NSDictionary *context=@{NSWorkspaceLaunchConfigurationEnvironment:@{@"SteamAppId":@"123",@"SteamGameId":@"123",@"SteamClientLaunch":@"1"},NSWorkspaceLaunchConfigurationArguments:@[@"ignored-runtime-default"]}; + select_target(binary,binary,"binary"); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:binary],0,@{},&error)==nil); + NSDictionary *wrong=@{NSWorkspaceLaunchConfigurationEnvironment:@{@"SteamAppId":@"124",@"SteamGameId":@"123"}}; + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:binary],0,wrong,&error)==nil); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:binary],0,context,&error)!=nil); + assert([observedExecutable isEqualToString:binary]); + assert([observedArguments isEqualToArray:[@[binary] arrayByAddingObjectsFromArray:arguments]]); + assert([observedCwd isEqualToString:root]); + assert(observedClose==lock); + assert(!observedEnvironment[@"SKINOS_PROFILE_LOCK_FD"]); + assert([observedEnvironment[@"SteamAppId"] isEqualToString:@"123"]); + assert([observedEnvironment[@"DYLD_INSERT_LIBRARIES"] isEqualToString:@"/fixture/our-adapter.dylib"]); + assert(skinos_last_game_pid()==4242); + assert(skinos_last_game_group()==0 && observedGroup==-1 && observedFlags==0); + assert(skinos_last_game_started_microseconds()>0); + NSString *bundle=[root stringByAppendingPathComponent:@"Demo.app"]; + NSString *bundleBinary=[bundle stringByAppendingPathComponent:@"Contents/MacOS/Demo"]; + write_macho(bundleBinary); + NSDictionary *info=@{@"CFBundleExecutable":@"Demo",@"CFBundleIdentifier":@"dev.skinos.fixture",@"CFBundlePackageType":@"APPL"}; + assert([info writeToFile:[bundle stringByAppendingPathComponent:@"Contents/Info.plist"] atomically:YES]); + select_target(bundle,bundleBinary,"bundle"); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:bundle],0,context,&error)!=nil); + assert([observedExecutable isEqualToString:bundleBinary]); + assert(skinos_last_game_group()==0 && observedGroup==-1 && observedFlags==0); + assert(native_open_urls(nil,NULL,@[[NSURL fileURLWithPath:binary]],[NSURL fileURLWithPath:bundle],0,context,&error)==nil); + NSString *script=[root stringByAppendingPathComponent:@"launcher with 'quote.sh"]; + NSString *source=@"#!/bin/sh\n[ \"${DYLD_INSERT_LIBRARIES+x}\" = x ] || exit 80\nexec \"$SKINOS_NATIVE_FIXTURE_CHILD\" --script-child \"$0\" \"$@\"\n"; + assert([source writeToFile:script atomically:YES encoding:NSUTF8StringEncoding error:NULL]); + select_target(script,script,"script"); + setenv("SKINOS_NATIVE_INTERPRETER","/bin/sh",1); + assert(native_open_urls(nil,NULL,@[[NSURL fileURLWithPath:script]],[NSURL fileURLWithPath:@"/Applications/TextEdit.app"],0,context,&error)!=nil); + assert([observedExecutable isEqualToString:@"/bin/sh"]); + assert(([observedArguments isEqualToArray:[@[@"/bin/sh",@"-c",scriptBootstrap,script] arrayByAddingObjectsFromArray:arguments]])); + assert(strcmp(skinos_last_game_executable(),"/bin/sh")==0); + assert(observedGroup==0 && observedFlags==POSIX_SPAWN_SETPGROUP); + assert(skinos_last_game_group()==4242); + assert(initializedAttributes==destroyedAttributes); + uint64_t publishedStart=skinos_last_game_started_microseconds(); + int callsBeforeFailure=spawnCalls; + for (int step=1;step<=3;step++) { + failAttributeStep=step; + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:script],0,context,&error)==nil); + assert(spawnCalls==callsBeforeFailure); + assert(skinos_last_game_started_microseconds()==publishedStart); + assert(skinos_last_game_group()==4242); + assert(initializedAttributes==destroyedAttributes); + } + failAttributeStep=0; + spawnError=EAGAIN; + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:script],0,context,&error)==nil); + assert(skinos_last_game_started_microseconds()==publishedStart); + assert(initializedAttributes==destroyedAttributes); + spawnError=0; + assert(native_open_urls(nil,NULL,@[[NSURL fileURLWithPath:script],[NSURL fileURLWithPath:binary]],nil,0,context,&error)==nil); + assert(native_open_urls(nil,NULL,@[[NSURL fileURLWithPath:binary]],nil,0,context,&error)==nil); + NSString *escape=[root stringByAppendingPathComponent:@"escaped.sh"]; + assert([[NSFileManager defaultManager] createSymbolicLinkAtPath:escape withDestinationPath:@"/bin/sh" error:NULL]); + select_target(escape,escape,"script"); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:escape],0,context,&error)==nil); + select_target(script,script,"script"); + setenv("SKINOS_GAME_WORKING_DIR","/Applications",1); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:script],0,context,&error)==nil); + setenv("SKINOS_GAME_WORKING_DIR",root.fileSystemRepresentation,1); + setenv("SKINOS_NATIVE_INTERPRETER","/usr/bin/env",1); + assert(native_launch(nil,NULL,[NSURL fileURLWithPath:script],0,context,&error)==nil); + unsetenv("SKINOS_NATIVE_INTERPRETER_ARGUMENT"); + // Prove the bootstrap preserves the script's observable invocation. It + // executes only this fixture binary, with no Steam runtime or game. + for (NSString *shell in @[@"/bin/sh",@"/bin/bash"]) { + NSTask *task=[NSTask new]; + task.executableURL=[NSURL fileURLWithPath:shell]; + task.currentDirectoryURL=[NSURL fileURLWithPath:root]; + task.arguments=[@[@"-c",scriptBootstrap,script] arrayByAddingObjectsFromArray:arguments]; + task.environment=@{@"SKINOS_COMPONENT_ROLE":@"fixture",@"SKINOS_ADAPTER_PATH":@"",@"SKINOS_NATIVE_FIXTURE_CHILD":resolved([NSString stringWithUTF8String:argv[0]])}; + NSPipe *pipe=[NSPipe pipe];task.standardOutput=pipe; + assert([task launchAndReturnError:&error]); + NSData *output=[pipe.fileHandleForReading readDataToEndOfFile]; + [task waitUntilExit]; + assert(task.terminationStatus==0); + id result=[NSJSONSerialization JSONObjectWithData:output options:0 error:NULL]; + assert(([result isEqualToArray:[@[root,script] arrayByAddingObjectsFromArray:arguments]])); + } + close(lock); + assert([[NSFileManager defaultManager] removeItemAtPath:root error:NULL]); + puts("Native bundle, Mach-O and script plans preserve context/args/cwd; escapes, arbitrary URLs and invalid interpreters rejected; shell exec fixture passed"); } } diff --git a/crates/runtime-host/src/installation.rs b/crates/runtime-host/src/installation.rs index c68d2c2..b57158c 100644 --- a/crates/runtime-host/src/installation.rs +++ b/crates/runtime-host/src/installation.rs @@ -2,35 +2,20 @@ use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; -/// Verified from Brotato's shipped Godot project config and Steam UFS metadata. -/// Never derive a permission grant from arbitrary title text or allow all HOME. -pub fn native_save_root(request: &Value) -> Result, String> { - if request["command"] != "launch" || !request["compatibility"].is_null() { - return Ok(None); - } - if request["appid"] != 1942280 || request["name"] != "Brotato" { - return Err("This game's native save-directory policy has not been verified yet".into()); - } - let executable = Path::new(request["executable"].as_str().ok_or("Executable missing")?); - if executable.file_name() != Some(std::ffi::OsStr::new("Brotato.app")) { - return Err("Save policy does not match the native bundle".into()); +mod save_roots; +pub use save_roots::native_save_roots; + +/// Create only paths accepted by the read-only resolver, then re-resolve them +/// before the caller constructs its normal sandbox permissions. +pub fn prepare_native_save_roots(request: &Value) -> Result, String> { + let roots = native_save_roots(request)?; + for root in &roots { + save_roots::create_checked_directory(root)?; } - let base = PathBuf::from(std::env::var_os("HOME").ok_or("User home unavailable")?) - .join("Library/Application Support"); - let saves = base.join("Brotato"); - fs::create_dir_all(&saves).map_err(|e| e.to_string())?; - let canonical = saves.canonicalize().map_err(|e| e.to_string())?; - if canonical - != base - .canonicalize() - .map_err(|e| e.to_string())? - .join("Brotato") - { - return Err( - "Native save root is unexpectedly redirected; no broad access was granted".into(), - ); + if native_save_roots(request)? != roots { + return Err("Native save paths changed while preparing the launch".into()); } - Ok(Some(canonical)) + Ok(roots) } /// Register only a completed, caller-verified SkinOs installation in a private @@ -109,3 +94,15 @@ pub fn stage(request: &Value, state: &Path) -> Result, String> { .map_err(|e| e.to_string())?; Ok(Some(install)) } + +#[cfg(test)] +mod save_policy_regression_tests { + use super::*; + use serde_json::json; + + #[test] + fn unknown_native_game_without_cloud_metadata_has_no_extra_save_roots() { + let request = json!({"command": "launch", "appid": 424242, "name": "Fixture"}); + assert!(native_save_roots(&request).unwrap().is_empty()); + } +} diff --git a/crates/runtime-host/src/installation/save_roots.rs b/crates/runtime-host/src/installation/save_roots.rs new file mode 100644 index 0000000..817d30a --- /dev/null +++ b/crates/runtime-host/src/installation/save_roots.rs @@ -0,0 +1,896 @@ +//! Resolve Steam Auto-Cloud locations, not a game's complete write policy. +//! +//! Root meanings and user placeholders are documented by Valve at +//! https://partner.steamgames.com/doc/features/cloud#steam_auto-cloud . +//! Metadata is untrusted: only narrow descendants of supported save containers +//! are accepted. There is no AppID/title exception and no fallback to all HOME. + +use serde_json::Value; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +const MAX_RULES: usize = 128; +const MAX_PATH_BYTES: usize = 2048; + +/// Read-only resolution. Missing Auto-Cloud settings means no additional roots; +/// games using the Cloud API do not necessarily have Auto-Cloud settings. +pub fn native_save_roots(request: &Value) -> Result, String> { + if !is_native_launch(request) || request["cloud_config"].is_null() { + return Ok(Vec::new()); + } + let home = PathBuf::from(std::env::var_os("HOME").ok_or("User home unavailable")?); + resolve(request, &home) +} + +fn is_native_launch(request: &Value) -> bool { + request["command"] == "launch" && request["compatibility"].is_null() +} + +fn resolve(request: &Value, home: &Path) -> Result, String> { + if !is_native_launch(request) || request["cloud_config"].is_null() { + return Ok(Vec::new()); + } + let config = &request["cloud_config"]; + if !config.is_object() { + return Err("Steam Auto-Cloud configuration is not an object".into()); + } + let home = home + .canonicalize() + .map_err(|_| "Cannot resolve the native save home directory")?; + let files = numbered_entries(&config["savefiles"], "savefiles")?; + let overrides = numbered_entries(&config["rootoverrides"], "rootoverrides")?; + let mut roots = BTreeSet::new(); + for file in files { + if !applies_to_mac(file)? { + continue; + } + let mut root = required_string(file, "root")?.to_owned(); + let mut path = file["path"].as_str().unwrap_or("").to_owned(); + let matching: Vec<_> = overrides + .iter() + .copied() + .filter(|entry| { + entry["root"] + .as_str() + .is_some_and(|s| s.eq_ignore_ascii_case(&root)) + }) + .filter(|entry| entry["os"].as_str().is_some_and(is_mac)) + .collect(); + if matching.len() > 1 { + return Err("Steam Auto-Cloud has ambiguous macOS root overrides".into()); + } + if let Some(over) = matching.first() { + if over["oscompare"].as_str().is_some_and(|s| s != "=") { + return Err("Unsupported Steam Auto-Cloud OS comparison".into()); + } + root = required_string(over, "useinstead")?.to_owned(); + path = override_path(&path, over)?; + } + let Some(base) = root_base(&root, request, &home)? else { + continue; + }; + let relative = relative_path(&expand_user_path(&path, request)?)?; + let target = base.join(relative); + let installation_root = matches!( + root.to_ascii_lowercase().as_str(), + "gameinstall" | "appinstalldir" + ); + // Valve permits `.` when saves live directly in the game's install + // directory. That caller-verified installation is already game-scoped; + // the same spelling must never grant a whole user-data container. + if !installation_root || target != base { + require_narrow_save_path(&target, &base, &home)?; + } + if !installation_root { + require_game_identity(&target, &home, request)?; + } + check_existing_components(&target)?; + roots.insert(target); + } + Ok(roots.into_iter().collect()) +} + +fn numbered_entries<'a>(value: &'a Value, field: &str) -> Result, String> { + if value.is_null() { + return Ok(Vec::new()); + } + let entries = match value { + Value::Object(object) => { + if object.keys().any(|key| key.parse::().is_err()) { + return Err(format!("Unsupported Steam Auto-Cloud {field} entries")); + } + let mut entries: Vec<_> = object.iter().collect(); + entries.sort_by_key(|(key, _)| key.parse::().unwrap_or(u32::MAX)); + entries + .into_iter() + .map(|(_, value)| value) + .collect::>() + } + Value::Array(array) => array.iter().collect(), + _ => return Err(format!("Malformed Steam Auto-Cloud {field}")), + }; + if entries.len() > MAX_RULES || entries.iter().any(|entry| !entry.is_object()) { + return Err(format!( + "Unsupported Steam Auto-Cloud {field} size or shape" + )); + } + Ok(entries) +} + +fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> { + value[key] + .as_str() + .ok_or_else(|| format!("Steam Auto-Cloud rule has no {key}")) +} + +fn is_mac(os: &str) -> bool { + matches!( + os.trim().to_ascii_lowercase().as_str(), + "macos" | "mac" | "osx" + ) +} + +fn applies_to_mac(rule: &Value) -> Result { + let field = if !rule["platforms"].is_null() { + &rule["platforms"] + } else if !rule["oslist"].is_null() { + &rule["oslist"] + } else { + &rule["os"] + }; + let values: Vec<&str> = match field { + Value::Null => return Ok(true), + Value::String(value) => value.split([',', ';']).collect(), + Value::Object(object) => object + .values() + .map(|value| { + value + .as_str() + .ok_or("Malformed Auto-Cloud platform restriction") + }) + .collect::>()?, + Value::Array(array) => array + .iter() + .map(|value| { + value + .as_str() + .ok_or("Malformed Auto-Cloud platform restriction") + }) + .collect::>()?, + _ => return Err("Unsupported Auto-Cloud platform restriction".into()), + }; + Ok(values.is_empty() + || values.iter().any(|value| { + is_mac(value) + || matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "all" | "cross-platform" + ) + })) +} + +fn override_path(original: &str, over: &Value) -> Result { + // Validate every supplied path, including replaced portions, so an override + // cannot hide traversal in otherwise discarded input. + relative_path(original)?; + let add = over["addpath"].as_str().unwrap_or(""); + relative_path(add)?; + let replace = match &over["replacepath"] { + Value::Null => false, + Value::Bool(value) => *value, + Value::String(value) if value == "1" => true, + Value::String(value) if value == "0" || value.is_empty() => false, + _ => return Err("Unsupported Auto-Cloud replace-path flag".into()), + }; + let mut path = if replace { + add.to_owned() + } else { + [add, original] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("/") + }; + for transform in numbered_entries(&over["pathtransforms"], "pathtransforms")? { + let find = required_string(transform, "find")?.replace('\\', "/"); + let replace = required_string(transform, "replace")?.replace('\\', "/"); + let find_path = relative_path(&find)?; + relative_path(&replace)?; + if find_path.as_os_str().is_empty() { + return Err("Empty Auto-Cloud path transform is unsupported".into()); + } + path = path.replace('\\', "/"); + // A transform may replace a directory prefix, never an arbitrary piece + // of a component. Reject ambiguous substitutions instead of guessing. + let original_path = relative_path(&path)?; + if let Ok(suffix) = original_path.strip_prefix(&find_path) { + path = relative_path(&replace)? + .join(suffix) + .to_string_lossy() + .into_owned(); + } else if path.starts_with(&find) { + return Err("Auto-Cloud transform does not end at a directory boundary".into()); + } + } + Ok(path) +} + +fn expand_user_path(path: &str, request: &Value) -> Result { + let mut expanded = path.to_owned(); + if path.contains("{64BitSteamID}") || path.contains("{Steam3AccountID}") { + let id = request["steamid"] + .as_str() + .and_then(|s| s.parse::().ok()) + .filter(|id| *id != 0) + .ok_or("Auto-Cloud user placeholder requires a valid SteamID")?; + expanded = expanded + .replace("{64BitSteamID}", &id.to_string()) + .replace("{Steam3AccountID}", &(id as u32).to_string()); + } + if expanded.contains(['{', '}', '%', '$', '~']) { + return Err("Unsupported or unresolved Steam Auto-Cloud path placeholder".into()); + } + Ok(expanded) +} + +fn relative_path(value: &str) -> Result { + if value.len() > MAX_PATH_BYTES + || value.chars().any(|c| c.is_control()) + || value.contains([':', '*', '?', '[', ']']) + { + return Err("Unsafe Steam Auto-Cloud directory path".into()); + } + let normalized = value.replace('\\', "/"); + let path = Path::new(&normalized); + if path.is_absolute() || path.components().any(|c| c == Component::ParentDir) { + return Err("Steam Auto-Cloud directory escapes its root".into()); + } + Ok(path + .components() + .filter(|c| *c != Component::CurDir) + .collect()) +} + +fn root_base(root: &str, request: &Value, home: &Path) -> Result, String> { + Ok(match root.to_ascii_lowercase().as_str() { + "machome" => Some(home.to_path_buf()), + "macappsupport" => Some(home.join("Library/Application Support")), + "macdocuments" => Some(home.join("Documents")), + "gameinstall" | "appinstalldir" => { + let install = request["install_dir"] + .as_str() + .ok_or("Missing verified installation")?; + let install = Path::new(install) + .canonicalize() + .map_err(|_| "Cannot resolve the verified installation for Auto-Cloud")?; + if !install.is_dir() { + return Err("Auto-Cloud installation root is not a directory".into()); + } + if [ + home.to_path_buf(), + home.join("Documents"), + home.join("Library/Application Support"), + ] + .contains(&install) + { + return Err("Auto-Cloud installation cannot be a whole user-data container".into()); + } + Some(install) + } + "winmydocuments" | "winappdatalocal" | "winappdatalocallow" | "winappdataroaming" + | "winsavedgames" | "windowshome" | "linuxhome" | "linuxxdgdatahome" => None, + "steamclouddocuments" => { + return Err( + "SteamCloudDocuments has no verified macOS mapping; no extra access was granted" + .into(), + ); + } + _ => return Err("Unsupported Steam Auto-Cloud root; no extra access was granted".into()), + }) +} + +fn require_narrow_save_path(target: &Path, base: &Path, home: &Path) -> Result<(), String> { + if target == base { + return Err("Auto-Cloud requires a specific save subdirectory, not a whole root".into()); + } + if base == home { + let support = home.join("Library/Application Support"); + let documents = home.join("Documents"); + let container = [&support, &documents] + .into_iter() + .find(|container| target.starts_with(container)) + .ok_or("MacHome Auto-Cloud path is outside supported save containers")?; + return require_narrow_save_path(target, container, home); + } + if base == home.join("Library/Application Support") { + let first = target.strip_prefix(base).ok().and_then(|p| p.iter().next()); + let first = first + .and_then(|p| p.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if matches!( + first.as_str(), + "steam" + | "skinos" + | "codex" + | "google" + | "mozilla" + | "firefox" + | "chromium" + | "bravesoftware" + | "1password" + | "keychains" + ) || first.starts_with("com.apple.") + || first.starts_with("dev.skinos") + { + return Err("Auto-Cloud path names a protected application data directory".into()); + } + } + if target.strip_prefix(base).ok().is_none_or(|p| { + p.components().any(|component| { + component + .as_os_str() + .to_str() + .is_none_or(|s| s.starts_with('.')) + }) + }) { + return Err("Auto-Cloud path is not a supported visible save subdirectory".into()); + } + Ok(()) +} + +fn identity_component(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +/// UFS may propose a directory, but its spelling alone is not a grant. Match a +/// complete path component to identity from the catalog/verified installation, +/// never to a value taken from the UFS rule itself or to a substring. +fn require_game_identity(target: &Path, home: &Path, request: &Value) -> Result<(), String> { + let containers = [ + home.join("Library/Application Support"), + home.join("Documents"), + ]; + let relative = containers + .iter() + .find_map(|base| target.strip_prefix(base).ok()) + .ok_or("Native save path is outside supported external containers")?; + let mut identities = BTreeSet::new(); + if let Some(name) = request["name"].as_str() { + identities.insert(identity_component(name)); + } + if let Some(install) = request["install_dir"].as_str() { + let install = Path::new(install) + .canonicalize() + .map_err(|_| "Cannot verify installation identity for native saves")?; + if !install.is_dir() { + return Err("Native save identity requires an installed directory".into()); + } + if let Some(name) = install.file_name().and_then(|name| name.to_str()) { + identities.insert(identity_component(name)); + } + if let Some(executable) = request["executable"].as_str() { + let executable = Path::new(executable) + .canonicalize() + .map_err(|_| "Cannot verify executable identity for native saves")?; + if executable == install || !executable.starts_with(&install) { + return Err("Native save identity executable escapes its installation".into()); + } + if let Some(name) = executable.file_stem().and_then(|name| name.to_str()) { + identities.insert(identity_component(name)); + } + for bundle in executable + .ancestors() + .take_while(|path| path.starts_with(&install) && *path != install) + { + if bundle + .extension() + .is_some_and(|extension| extension == "app") + && bundle.is_dir() + { + add_bundle_identities(bundle, &mut identities)?; + break; + } + } + } + } + identities.remove(""); + if relative + .iter() + .filter_map(|part| part.to_str()) + .any(|part| identities.contains(&identity_component(part))) + { + return Ok(()); + } + Err(format!( + "Steam Auto-Cloud directory '{}' does not match the selected game's identity; no extra save access was granted", + relative.display() + )) +} + +fn add_bundle_identities(bundle: &Path, identities: &mut BTreeSet) -> Result<(), String> { + let plist = bundle.join("Contents/Info.plist"); + if !plist.exists() { + return Ok(()); + } + let plist = plist + .canonicalize() + .map_err(|_| "Cannot verify native bundle save identity")?; + if !plist.starts_with(bundle) + || !plist.is_file() + || fs::metadata(&plist) + .map_err(|_| "Cannot inspect native bundle save identity")? + .len() + > 1024 * 1024 + { + return Err( + "Native bundle save identity metadata is outside the bundle or too large".into(), + ); + } + for key in ["CFBundleIdentifier", "CFBundleName"] { + let output = std::process::Command::new("/usr/bin/plutil") + .args(["-extract", key, "raw", "-o", "-", "--"]) + .arg(&plist) + .output() + .map_err(|_| "Cannot inspect native bundle save identity")?; + if output.status.success() && output.stdout.len() <= 4096 { + if let Ok(value) = std::str::from_utf8(&output.stdout) { + identities.insert(identity_component(value.trim())); + } + } + } + Ok(()) +} + +fn check_existing_components(path: &Path) -> Result<(), String> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err("Native save directory is redirected by a symlink".into()); + } + Ok(meta) if !meta.is_dir() => { + return Err("Native save path contains a non-directory".into()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(_) => return Err("Cannot inspect native save directory safely".into()), + } + } + Ok(()) +} + +/// Check every existing component for redirection before and after creation. +/// Errors must never be converted to a grant on a broader parent directory. +pub(super) fn create_checked_directory(path: &Path) -> Result<(), String> { + check_existing_components(path)?; + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component); + if let Err(error) = fs::create_dir(¤t) { + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err("Cannot create native save directory".into()); + } + } + check_existing_components(¤t)?; + } + if fs::canonicalize(path).ok().as_deref() != Some(path) { + return Err("Native save directory changed while being created".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "skinos-save-policy-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(path.join("Library/Application Support")).unwrap(); + fs::create_dir(path.join("Documents")).unwrap(); + let bundle = path.join("Installed Fixture/Game.app/Contents"); + fs::create_dir_all(&bundle).unwrap(); + fs::write(bundle.join("Info.plist"), br#"CFBundleIdentifiercom.studio.gameCFBundleNameMacGame"#).unwrap(); + Self(path.canonicalize().unwrap()) + } + fn resolve(&self, cloud: Value) -> Result, String> { + self.resolve_as(cloud, "Game") + } + fn resolve_as(&self, cloud: Value, name: &str) -> Result, String> { + resolve( + &json!({"command":"launch", "appid":424242, "name":name,"install_dir":self.0.join("Installed Fixture"),"executable":self.0.join("Installed Fixture/Game.app"), "steamid":"76561197960265770", "cloud_config":cloud}), + &self.0, + ) + } + fn rule(&self, root: &str, path: &str) -> Result, String> { + self.resolve(json!({"savefiles":{"0":{"root":root,"path":path,"pattern":"*"}}})) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn mac_saves_follow_metadata_without_an_appid_allowlist() { + let f = Fixture::new(); + let roots = f + .resolve_as( + json!({"savefiles":{"0":{"root":"MacAppSupport","path":"Brotato"}}}), + "Brotato", + ) + .unwrap(); + assert_eq!(roots, vec![f.0.join("Library/Application Support/Brotato")]); + assert!( + !roots[0].exists(), + "read-only resolution must not create directories" + ); + } + + #[test] + fn multiple_save_locations_are_deduplicated() { + let f = Fixture::new(); + let roots = f + .resolve(json!({"savefiles":{ + "0":{"root":"MacAppSupport","path":"Studio/Game","pattern":"*.sav"}, + "1":{"root":"MacAppSupport","path":"Studio/Game","pattern":"*.cfg"}, + "2":{"root":"MacDocuments","path":"My Games/Game"} + }})) + .unwrap(); + assert_eq!(roots.len(), 2); + assert!(roots.contains(&f.0.join("Library/Application Support/Studio/Game"))); + assert!(roots.contains(&f.0.join("Documents/My Games/Game"))); + } + + #[test] + fn unrelated_documents_are_not_authorized_by_cloud_metadata() { + let f = Fixture::new(); + assert!(f.rule("MacDocuments", "Taxes2026").is_err()); + assert!(f.rule("MacAppSupport", "AnotherGame/Backups").is_err()); + assert!( + f.rule("MacDocuments", "GameSaves").is_err(), + "substring identity is not proof" + ); + assert!(f.rule("MacDocuments", "SomeGame").is_err()); + assert!(f.rule("MacDocuments", "GAME/Saves").is_ok()); + } + + #[test] + fn external_paths_match_catalog_install_executable_or_bundle_identity() { + let f = Fixture::new(); + for path in [ + "Game/Saves", + "Installed Fixture/Saves", + "com.studio.game/Saves", + "MacGame/Saves", + ] { + assert!( + f.rule("MacDocuments", path).is_ok(), + "identity not recognized: {path}" + ); + } + assert!( + f.resolve_as( + json!({"savefiles":{"0":{"root":"MacAppSupport","path":"Space Adventure/Saves"}}}), + "Space: Adventure" + ) + .is_ok() + ); + } + + #[test] + fn external_paths_do_not_take_identity_from_an_escaping_executable() { + let f = Fixture::new(); + let outside = Fixture::new(); + let request = json!({"command":"launch","name":"Other","install_dir":f.0.join("Installed Fixture"),"executable":outside.0.join("Installed Fixture/Game.app"), + "cloud_config":{"savefiles":{"0":{"root":"MacDocuments","path":"Game"}}}}); + assert!(resolve(&request, &f.0).is_err()); + } + + #[test] + fn user_placeholders_use_the_selected_account() { + let f = Fixture::new(); + assert_eq!( + f.rule("MacAppSupport", "Game/{64BitSteamID}/{Steam3AccountID}") + .unwrap(), + vec![f.0.join("Library/Application Support/Game/76561197960265770/42")] + ); + } + + #[test] + fn windows_paths_require_a_mac_override() { + let f = Fixture::new(); + assert!( + f.rule("WinAppDataLocalLow", "Studio/Game") + .unwrap() + .is_empty() + ); + let roots = f.resolve(json!({ + "savefiles":{"0":{"root":"WinAppDataLocalLow","path":"Studio\\Game"}}, + "rootoverrides":{"0":{"root":"WinAppDataLocalLow","os":"MacOS","oscompare":"=","useinstead":"MacAppSupport","pathtransforms":{"0":{"find":"Studio/Game","replace":"com.studio.game"}}}} + })).unwrap(); + assert_eq!( + roots, + vec![f.0.join("Library/Application Support/com.studio.game")] + ); + } + + #[test] + fn override_add_and_replace_paths_are_applied() { + let f = Fixture::new(); + for (replace, expected) in [("0", "Studio/Game/Saves"), ("1", "Studio/Game")] { + let roots = f.resolve(json!({ + "savefiles":{"0":{"root":"WinMyDocuments","path":"Saves"}}, + "rootoverrides":{"0":{"root":"WinMyDocuments","os":"MacOS","useinstead":"MacDocuments","addpath":"Studio/Game","replacepath":replace}} + })).unwrap(); + assert_eq!(roots, vec![f.0.join("Documents").join(expected)]); + } + } + + #[test] + fn non_mac_platform_restrictions_are_respected() { + let f = Fixture::new(); + let roots = f + .resolve(json!({"savefiles":{ + "0":{"root":"MacAppSupport","path":"WindowsOnly","platforms":{"1":"Windows"}}, + "1":{"root":"MacAppSupport","path":"MacGame","platforms":{"1":"MacOS","2":"Linux"}}, + "2":{"root":"MacAppSupport","path":"Other","oslist":"windows,linux"} + }})) + .unwrap(); + assert_eq!(roots, vec![f.0.join("Library/Application Support/MacGame")]); + } + + #[test] + fn mac_home_only_maps_into_narrow_supported_save_containers() { + let f = Fixture::new(); + assert_eq!( + f.rule("MacHome", "Library/Application Support/Studio/Game") + .unwrap(), + vec![f.0.join("Library/Application Support/Studio/Game")] + ); + assert_eq!( + f.rule("MacHome", "Documents/Game").unwrap(), + vec![f.0.join("Documents/Game")] + ); + for path in [ + "", + ".", + "Library", + "Documents", + "Library/Application Support", + ".ssh", + "Desktop", + "Library/Keychains", + ] { + assert!(f.rule("MacHome", path).is_err(), "accepted {path}"); + } + } + + #[test] + fn broad_and_injected_paths_are_rejected() { + let f = Fixture::new(); + for root in ["MacAppSupport", "MacDocuments"] { + for path in [ + "", + ".", + "../escape", + "Game/../../escape", + "/tmp/escape", + "C:\\escape", + "\\escape", + "Game/*", + "Game\nOther", + "~", + "Game/{Unknown}", + "$HOME/Game", + "%USERPROFILE%/Game", + ] { + assert!(f.rule(root, path).is_err(), "accepted {root} {path}"); + } + } + } + + #[test] + fn install_root_saves_accept_dot_without_granting_user_containers() { + let f = Fixture::new(); + for root in ["GameInstall", "AppInstallDir"] { + assert_eq!( + f.rule(root, ".").unwrap(), + vec![f.0.join("Installed Fixture")] + ); + assert!(f.rule(root, "../escape").is_err()); + for container in [ + f.0.clone(), + f.0.join("Documents"), + f.0.join("Library/Application Support"), + ] { + let request = json!({ + "command":"launch", "install_dir":container, + "cloud_config":{"savefiles":{"0":{"root":root,"path":".","pattern":"*.sav"}}} + }); + assert!(resolve(&request, &f.0).is_err()); + } + } + for root in ["MacHome", "MacDocuments", "MacAppSupport"] { + assert!(f.rule(root, ".").is_err()); + } + } + + #[test] + fn sensitive_application_roots_are_not_granted_by_cloud_metadata() { + let f = Fixture::new(); + for path in [ + "Steam", + "Steam/config", + "SkinOS/accounts", + "Google/Chrome", + "com.apple.TCC", + ".secrets", + ] { + assert!(f.rule("MacAppSupport", path).is_err(), "accepted {path}"); + } + } + + #[test] + fn directory_symlinks_do_not_expand_the_save_grant() { + let f = Fixture::new(); + let outside = Fixture::new(); + std::os::unix::fs::symlink(&outside.0, f.0.join("Documents/Game")).unwrap(); + assert!( + f.rule("MacDocuments", "Game/Saves") + .unwrap_err() + .contains("symlink") + ); + assert!(!outside.0.join("Saves").exists()); + } + + #[test] + fn a_file_cannot_be_used_as_a_save_directory() { + let f = Fixture::new(); + fs::write(f.0.join("Documents/Game"), b"fixture").unwrap(); + assert!( + f.rule("MacDocuments", "Game") + .unwrap_err() + .contains("non-directory") + ); + } + + #[test] + fn unsupported_roots_and_ambiguous_overrides_have_diagnostics() { + let f = Fixture::new(); + assert!( + f.rule("SteamCloudDocuments", "Game") + .unwrap_err() + .contains("verified macOS mapping") + ); + assert!( + f.rule("UnexpectedRoot", "Game") + .unwrap_err() + .contains("Unsupported") + ); + assert!( + f.resolve(json!({ + "savefiles":{"0":{"root":"WinMyDocuments","path":"Game"}}, + "rootoverrides":{ + "0":{"root":"WinMyDocuments","os":"MacOS","useinstead":"MacDocuments"}, + "1":{"root":"WinMyDocuments","os":"MacOS","useinstead":"MacAppSupport"} + } + })) + .unwrap_err() + .contains("ambiguous") + ); + } + + #[test] + fn malformed_metadata_does_not_silently_expand_access() { + let f = Fixture::new(); + for cloud in [ + json!("bad"), + json!({"savefiles":true}), + json!({"savefiles":{"bad":{}}}), + json!({"savefiles":{"0":{"path":"Game"}}}), + ] { + assert!(f.resolve(cloud).is_err()); + } + let files: serde_json::Map = (0..=MAX_RULES) + .map(|i| (i.to_string(), json!({"root":"MacDocuments","path":"Game"}))) + .collect(); + assert!(f.resolve(json!({"savefiles":files})).is_err()); + } + + #[test] + fn invalid_path_transforms_are_rejected() { + let f = Fixture::new(); + for (find, replacement) in [ + ("", "Game"), + (".", "Game"), + ("Studio", "../escape"), + ("Stu", "Game"), + ] { + assert!(f.resolve(json!({ + "savefiles":{"0":{"root":"WinMyDocuments","path":"Studio/Game"}}, + "rootoverrides":{"0":{"root":"WinMyDocuments","os":"MacOS","useinstead":"MacDocuments","pathtransforms":{"0":{"find":find,"replace":replacement}}}} + })).is_err()); + } + } + + #[test] + fn path_transforms_preserve_directory_boundaries_and_subdirectories() { + let f = Fixture::new(); + let roots = f.resolve(json!({ + "savefiles":{"0":{"root":"WinMyDocuments","path":"Studio/Game/Saves"}}, + "rootoverrides":{"0":{"root":"WinMyDocuments","os":"MacOS","useinstead":"MacDocuments","pathtransforms":{"0":{"find":"Studio/Game/","replace":"com.studio.game"}}}} + })).unwrap(); + assert_eq!(roots, vec![f.0.join("Documents/com.studio.game/Saves")]); + } + + #[test] + fn game_install_rules_are_confined_to_the_installation() { + let f = Fixture::new(); + let install = f.0.join("Installed Game"); + fs::create_dir(&install).unwrap(); + let request = |path| { + json!({ + "command":"launch","install_dir":install, + "cloud_config":{"savefiles":{"0":{"root":"gameinstall","path":path}}} + }) + }; + assert_eq!( + resolve(&request("game/cfg"), &f.0).unwrap(), + vec![install.join("game/cfg")] + ); + for path in ["../Documents", "/tmp/Game"] { + assert!(resolve(&request(path), &f.0).is_err()); + } + std::os::unix::fs::symlink(f.0.join("Documents"), install.join("game")).unwrap(); + assert!(resolve(&request("game/cfg"), &f.0).is_err()); + } + + #[test] + fn preparation_creates_only_fixture_subdirectories_and_rechecks_redirects() { + let f = Fixture::new(); + let roots = f.rule("MacDocuments", "Studio/Game").unwrap(); + create_checked_directory(&roots[0]).unwrap(); + assert!(roots[0].is_dir()); + assert_eq!(f.rule("MacDocuments", "Studio/Game").unwrap(), roots); + fs::remove_dir(&roots[0]).unwrap(); + let outside = Fixture::new(); + std::os::unix::fs::symlink(&outside.0, &roots[0]).unwrap(); + assert!(create_checked_directory(&roots[0]).is_err()); + } + + #[test] + fn cloud_absence_and_non_native_commands_need_no_home_or_appid_rules() { + for request in [ + json!({"command":"launch"}), + json!({"command":"probe","cloud_config":true}), + json!({"command":"launch","compatibility":{},"cloud_config":true}), + ] { + assert!( + resolve(&request, Path::new("/fixture-does-not-exist")) + .unwrap() + .is_empty() + ); + } + } +} diff --git a/crates/runtime-host/src/main.rs b/crates/runtime-host/src/main.rs index 2a8cc81..3f218f2 100644 --- a/crates/runtime-host/src/main.rs +++ b/crates/runtime-host/src/main.rs @@ -14,6 +14,8 @@ mod input_focus; mod input_readiness; mod input_settings; mod installation; +mod native_launch; +mod native_lock_files; mod profile; mod runtime_bundle; mod wine_input_output; @@ -120,6 +122,36 @@ fn main() -> ExitCode { } fn run(args: &[std::ffi::OsString]) -> Result<()> { + if args + .first() + .is_some_and(|arg| arg == "inspect-native-launch") + { + use std::io::Read; + let mut bytes = Vec::new(); + std::io::stdin() + .lock() + .take(65537) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if bytes.len() > 65536 { + return Err("Launch description exceeds protocol limit".into()); + } + let request: Value = + serde_json::from_slice(&bytes).map_err(|_| "Invalid launch description")?; + let plan = native_launch::NativeLaunchPlan::from_request(&request)?; + let save_roots = installation::native_save_roots(&request)?; + println!( + "{}", + serde_json::json!({ + "kind":plan.kind,"target":plan.target,"executable":plan.executable, + "working_directory":plan.cwd,"interpreter":plan.interpreter, + "architectures":plan.target_architectures,"sdk":plan.sdk, + "sdk_probe_reason":plan.sdk_probe_reason,"save_roots":save_roots, + "game_started":false,"directories_created":false + }) + ); + return Ok(()); + } if args .first() .is_some_and(|arg| arg == "--prepare-bundled-runtime") @@ -205,11 +237,16 @@ fn run(args: &[std::ffi::OsString]) -> Result<()> { .filter(|v| !v.is_null()) .map(|value| serde_json::from_value(value.clone()).map_err(|e| e.to_string())) .transpose()?; + let native_plan = initial_command + .as_ref() + .filter(|(_, request)| request["command"] == "launch" && compatibility.is_none()) + .map(|(_, request)| native_launch::NativeLaunchPlan::from_request(request)) + .transpose()?; let game_data = initial_command .as_ref() - .map(|(_, request)| installation::native_save_root(request)) + .map(|(_, request)| installation::prepare_native_save_roots(request)) .transpose()? - .flatten(); + .unwrap_or_default(); let runtime = runtime.map(Ok).unwrap_or_else(owned_runtime)?; if !cfg!(target_arch = "aarch64") { return Err("The current dynamic adapter is audited for arm64 only.".into()); @@ -386,7 +423,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<()> { &mut write_paths, ); } - if let Some(path) = &game_data { + for path in &game_data { grant(path.clone(), "SAVE_READ", &mut read_paths); grant(path.clone(), "SAVE_WRITE", &mut write_paths); } @@ -435,6 +472,38 @@ fn run(args: &[std::ffi::OsString]) -> Result<()> { // The compatibility runner is launched directly; it never hands off to Steam.app. profile.push_str("\n(deny process-exec (literal \"/usr/bin/open\"))\n"); } + if let Some(plan) = &native_plan { + read_paths.push(native_lock_files::SOURCE2_LOCK_FILES.into()); + write_paths.push(native_lock_files::SOURCE2_LOCK_FILES.into()); + // A script uses its declared interpreter and a small set of system + // path/platform utilities. Every grant names one executable, never a + // general bin directory or a path supplied by launch arguments. + let mut tools = Vec::new(); + if let Some(interpreter) = &plan.interpreter { + tools.push(interpreter.clone()); + tools.extend( + ["/usr/bin/basename", "/usr/bin/dirname", "/usr/bin/uname"].map(PathBuf::from), + ); + } + for path in tools { + let key = format!("NATIVE_TOOL_{}", parameters.len()); + read_paths.push(format!("(literal (param \"{key}\"))")); + exec_paths.push(format!("(literal (param \"{key}\"))")); + parameters.push((key, path)); + } + if plan.kind == "script" + || plan + .target_architectures + .iter() + .any(|arch| arch == "x86_64") + { + // Rosetta's system runtime is outside /System/Library. Intel game + // executables require this read even though no user-data access is needed. + read_paths + .push("(literal \"/Library/Apple/usr/libexec/oah/libRosettaRuntime\")".into()); + } + profile.push_str("\n(deny process-exec (literal \"/usr/bin/open\"))\n"); + } profile = profile .replace("; SKINOS_EXTRA_READ", &read_paths.join(" ")) .replace("; SKINOS_EXTRA_WRITE", &write_paths.join(" ")) @@ -444,9 +513,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<()> { for (name, path) in parameters { command.arg("-D").arg(format!("{name}={}", path.display())); } - if let Some(path) = &game_data { - command.env("SKINOS_GAME_DATA_ROOT", path); - } + command.env_remove("SKINOS_GAME_DATA_ROOT"); command .arg("-D") .arg(format!("STATE_ROOT={}", state.display())) @@ -524,6 +591,12 @@ fn run(args: &[std::ffi::OsString]) -> Result<()> { ); } } + if let Some(plan) = &native_plan { + plan.configure(&mut command); + if plan.kind == "script" { + command.env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin"); + } + } if let Some(profile) = &account_profile { let lock_fd = profile.lock_fd(); command.env("SKINOS_PROFILE_LOCK_FD", lock_fd.to_string()); diff --git a/crates/runtime-host/src/native_launch.rs b/crates/runtime-host/src/native_launch.rs new file mode 100644 index 0000000..13d0967 --- /dev/null +++ b/crates/runtime-host/src/native_launch.rs @@ -0,0 +1,559 @@ +//! A native launch is selected by Steam metadata; filesystem discovery only +//! validates that selection and locates a uniquely associated Steamworks SDK. +use serde_json::Value; +use std::collections::BTreeSet; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone)] +pub struct NativeLaunchPlan { + pub install_root: PathBuf, + pub target: PathBuf, + pub executable: PathBuf, + pub kind: &'static str, + pub cwd: PathBuf, + pub interpreter: Option, + pub interpreter_argument: Option, + pub target_architectures: Vec, + pub sdk: Option, + pub sdk_architectures: Vec, + pub sdk_probe_reason: &'static str, +} + +impl NativeLaunchPlan { + pub fn from_request(request: &Value) -> Result { + let install_root = canonical_directory(request, "install_dir")?; + let target = canonical_member(&install_root, field(request, "executable")?)?; + let cwd = fs::canonicalize(field(request, "working_dir")?) + .map_err(|e| format!("Native working directory is unavailable: {e}"))?; + if !cwd.starts_with(&install_root) { + return Err("Native working directory escapes the selected installation".into()); + } + if !cwd.is_dir() { + return Err("Native working directory is not a directory".into()); + } + let arguments = request["arguments"] + .as_array() + .ok_or("Native launch arguments must be an array")?; + if arguments.len() > 128 + || arguments.iter().any(|arg| { + arg.as_str() + .is_none_or(|text| text.contains('\0') || text.len() > 16 * 1024) + }) + { + return Err("Native launch arguments are invalid or too large".into()); + } + let (kind, executable, interpreter, interpreter_argument) = if target.is_dir() { + if target.extension().is_none_or(|ext| ext != "app") { + return Err("Selected native directory is not an application bundle".into()); + } + let plist = canonical_member(&target, &target.join("Contents/Info.plist"))?; + if fs::metadata(&plist).map_err(|e| e.to_string())?.len() > 1024 * 1024 { + return Err("Native bundle Info.plist exceeds the metadata limit".into()); + } + let output = Command::new("/usr/bin/plutil") + .args(["-extract", "CFBundleExecutable", "raw", "-o", "-", "--"]) + .arg(plist) + .output() + .map_err(|e| format!("Cannot inspect native bundle executable: {e}"))?; + if !output.status.success() { + return Err("Native bundle has no valid CFBundleExecutable".into()); + } + let name = String::from_utf8(output.stdout) + .map_err(|_| "Native bundle executable name is not UTF-8")?; + let name = name.trim_end_matches(['\n', '\r']); + if name.is_empty() || name.contains(['/', '\\', '\0']) || matches!(name, "." | "..") { + return Err("Native bundle executable name is invalid".into()); + } + let executable = canonical_member(&target, &target.join("Contents/MacOS").join(name))?; + if !executable.is_file() || macho_architectures(&executable)?.is_empty() { + return Err("Native bundle executable is not a Mach-O file".into()); + } + ("bundle", executable, None, None) + } else if target.is_file() { + if !macho_architectures(&target)?.is_empty() { + ("binary", target.clone(), None, None) + } else { + let (interpreter, argument) = script_interpreter(&target)?; + ("script", target.clone(), Some(interpreter), argument) + } + } else { + return Err( + "Selected native target is not an ordinary file or application bundle".into(), + ); + }; + let target_architectures = if kind == "script" { + vec![] + } else { + macho_architectures(&executable)? + }; + let (candidate, mut sdk_probe_reason) = + resolve_sdk(&install_root, &target, &executable, kind); + let sdk_architectures = candidate + .as_ref() + .and_then(|path| macho_architectures(path).ok()) + .unwrap_or_default(); + let sdk = if candidate.is_some() && !sdk_architectures.iter().any(|arch| arch == "arm64") { + sdk_probe_reason = "incompatible"; + None + } else { + candidate + }; + Ok(Self { + install_root, + target, + executable, + kind, + cwd, + interpreter, + interpreter_argument, + target_architectures, + sdk, + sdk_architectures, + sdk_probe_reason, + }) + } + + pub fn configure(&self, command: &mut Command) { + command + .env("SKINOS_GAME_INSTALL_ROOT", &self.install_root) + .env("SKINOS_GAME_BUNDLE", &self.target) + .env("SKINOS_GAME_WORKING_DIR", &self.cwd) + .env("SKINOS_NATIVE_TARGET_KIND", self.kind) + .env("SKINOS_NATIVE_EXECUTABLE", &self.executable) + .env_remove("SKINOS_NATIVE_INTERPRETER") + .env_remove("SKINOS_NATIVE_INTERPRETER_ARGUMENT") + .env_remove("SKINOS_NATIVE_SDK_PATH") + .env("SKINOS_NATIVE_SDK_PROBE_SUPPORTED", "0") + .env("SKINOS_NATIVE_SDK_PROBE_REASON", self.sdk_probe_reason); + if let Some(sdk) = &self.sdk { + command.env("SKINOS_NATIVE_SDK_PATH", sdk); + if self.sdk_architectures.iter().any(|arch| arch == "arm64") { + command.env("SKINOS_NATIVE_SDK_PROBE_SUPPORTED", "1"); + } + } + if let Some(interpreter) = &self.interpreter { + command.env("SKINOS_NATIVE_INTERPRETER", interpreter); + } + if let Some(argument) = &self.interpreter_argument { + command.env("SKINOS_NATIVE_INTERPRETER_ARGUMENT", argument); + } + } +} + +fn field<'a>(request: &'a Value, name: &str) -> Result<&'a Path, String> { + request[name] + .as_str() + .map(Path::new) + .ok_or_else(|| format!("Native launch is missing {name}")) +} + +fn canonical_directory(request: &Value, name: &str) -> Result { + let path = fs::canonicalize(field(request, name)?) + .map_err(|e| format!("Native {name} is unavailable: {e}"))?; + if !path.is_dir() { + return Err(format!("Native {name} must be a directory")); + } + Ok(path) +} + +fn canonical_member(root: &Path, path: &Path) -> Result { + let path = + fs::canonicalize(path).map_err(|e| format!("Native launch file is unavailable: {e}"))?; + if !path.starts_with(root) || path == root { + return Err("Native launch path escapes the selected installation or bundle".into()); + } + Ok(path) +} + +fn script_interpreter(path: &Path) -> Result<(PathBuf, Option), String> { + let mut head = [0_u8; 512]; + let len = File::open(path) + .and_then(|mut file| file.read(&mut head)) + .map_err(|e| format!("Cannot inspect native script: {e}"))?; + let head = &head[..len]; + let end = head.iter().position(|byte| *byte == b'\n').unwrap_or(len); + if !head.starts_with(b"#!") || end == 512 { + return Err( + "Selected native file is neither Mach-O nor a script with a bounded shebang".into(), + ); + } + let line = std::str::from_utf8(&head[2..end]) + .map_err(|_| "Native script shebang is not UTF-8")? + .trim(); + let mut fields = line.split_whitespace(); + let interpreter = fields + .next() + .ok_or("Native script interpreter is missing")?; + let rest: Vec<&str> = fields.collect(); + let (interpreter, argument) = if interpreter == "/usr/bin/env" { + // Resolve only a single explicit, known interpreter. No PATH lookup, + // assignments, env -S splitting, or command-language evaluation. + let [name] = rest.as_slice() else { + return Err( + "Native env shebang must name one supported interpreter without options".into(), + ); + }; + let path = match *name { + "sh" => "/bin/sh", + "bash" => "/bin/bash", + _ => return Err("Native env shebang names an unsupported interpreter".into()), + }; + (path, None) + } else { + if rest.len() > 1 { + return Err("Native script shebang has ambiguous interpreter options".into()); + } + (interpreter, rest.first().map(|value| (*value).to_string())) + }; + if !matches!(interpreter, "/bin/sh" | "/bin/bash") { + return Err("Native script must declare /bin/sh or /bin/bash; other interpreters are not supported yet".into()); + } + if argument.as_ref().is_some_and(|option| { + !option.starts_with('-') + || option.len() < 2 + || !option[1..].bytes().all(|flag| b"eufvx".contains(&flag)) + }) { + return Err("Native shell shebang option is not supported".into()); + } + let interpreter = fs::canonicalize(interpreter) + .map_err(|e| format!("Native script interpreter is unavailable: {e}"))?; + Ok((interpreter, argument)) +} + +/// Read only the executable header, never load a game's code to classify it. +pub fn macho_architectures(path: &Path) -> Result, String> { + let mut bytes = [0_u8; 4096]; + let count = File::open(path) + .and_then(|mut file| file.read(&mut bytes)) + .map_err(|e| format!("Cannot inspect native binary header: {e}"))?; + let bytes = &bytes[..count]; + if bytes.len() < 8 { + return Ok(vec![]); + } + let be = |offset: usize| u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let le = |offset: usize| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let mut cpus = vec![]; + match be(0) { + 0xfeedface | 0xfeedfacf => cpus.push(be(4)), + 0xcefaedfe | 0xcffaedfe => cpus.push(le(4)), + magic @ (0xcafebabe | 0xbebafeca | 0xcafebabf | 0xbfbafeca) => { + let little = matches!(magic, 0xbebafeca | 0xbfbafeca); + let read = |offset| if little { le(offset) } else { be(offset) }; + let entries = read(4) as usize; + let width = if matches!(magic, 0xcafebabf | 0xbfbafeca) { + 32 + } else { + 20 + }; + if entries == 0 || entries > 64 || 8 + entries * width > bytes.len() { + return Err("Native universal binary has an invalid architecture table".into()); + } + for index in 0..entries { + cpus.push(read(8 + index * width)); + } + } + _ => return Ok(vec![]), + } + let mut result = BTreeSet::new(); + for cpu in cpus { + result.insert(match cpu { + 0x01000007 => "x86_64".into(), + 0x0100000c => "arm64".into(), + 7 => "i386".into(), + 12 => "arm".into(), + other => format!("cpu-{other}"), + }); + } + Ok(result.into_iter().collect()) +} + +fn resolve_sdk( + root: &Path, + target: &Path, + executable: &Path, + kind: &str, +) -> (Option, &'static str) { + resolve_sdk_with_limits(root, target, executable, kind, 100_000, 12) +} + +fn resolve_sdk_with_limits( + root: &Path, + target: &Path, + executable: &Path, + kind: &str, + max_entries: usize, + max_depth: u8, +) -> (Option, &'static str) { + let Some(parent) = executable.parent() else { + return (None, "invalid_sdk"); + }; + let nearby = parent.join("libsteam_api.dylib"); + if fs::symlink_metadata(&nearby).is_ok() { + return match validate_sdk(root, &nearby) { + Ok(path) => (Some(path), "ready"), + Err(_) => (None, "invalid_sdk"), + }; + } + // Optional SDK verification never blocks a valid Steam launch. Incomplete + // discovery cannot authorize an arbitrary library, so report why it skips. + let scan_root = if kind == "bundle" { target } else { root }; + let mut stack = vec![(scan_root.to_path_buf(), 0_u8)]; + let mut candidates = BTreeSet::new(); + let mut visited = 0_usize; + while let Some((directory, depth)) = stack.pop() { + let Ok(entries) = fs::read_dir(&directory) else { + return (None, "unavailable"); + }; + for entry in entries { + visited += 1; + if visited > max_entries { + return (None, "bounded_scan"); + } + let Ok(entry) = entry else { + return (None, "unavailable"); + }; + let path = entry.path(); + let Ok(metadata) = fs::symlink_metadata(&path) else { + return (None, "unavailable"); + }; + if entry.file_name() == "libsteam_api.dylib" { + let Ok(path) = validate_sdk(root, &path) else { + return (None, "invalid_sdk"); + }; + candidates.insert(path); + if candidates.len() > 1 { + return (None, "ambiguous"); + } + } else if metadata.is_dir() { + if depth >= max_depth { + return (None, "bounded_scan"); + } + stack.push((path, depth + 1)); + } + } + } + match candidates.len() { + 0 => (None, "missing"), + 1 => (candidates.into_iter().next(), "ready"), + _ => (None, "ambiguous"), + } +} + +fn validate_sdk(root: &Path, candidate: &Path) -> Result { + let path = canonical_member(root, candidate)?; + if !path.is_file() || macho_architectures(&path)?.is_empty() { + return Err("Native Steamworks SDK is not a Mach-O library".into()); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "skinos-native-plan-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + fn binary(&self, relative: &str) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, [0xcf, 0xfa, 0xed, 0xfe, 12, 0, 0, 1, 0, 0, 0, 0]).unwrap(); + path + } + fn request(&self, target: &Path) -> Value { + json!({"install_dir":self.0,"executable":target,"working_dir":self.0,"arguments":["-test","two words"]}) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + #[test] + fn binary_with_nearby_sdk_preserves_architecture() { + let f = Fixture::new(); + let binary = f.binary("bin/game"); + let sdk = f.binary("bin/libsteam_api.dylib"); + let plan = NativeLaunchPlan::from_request(&f.request(&binary)).unwrap(); + assert_eq!(plan.kind, "binary"); + assert_eq!(plan.sdk, Some(sdk)); + assert_eq!(plan.sdk_architectures, ["arm64"]); + } + #[test] + fn script_keeps_declared_launcher_and_discovers_unique_nested_sdk() { + let f = Fixture::new(); + let script = f.0.join("launch.sh"); + fs::write(&script, b"#!/bin/sh\nexec ./bin/game \"$@\"\n").unwrap(); + let sdk = f.binary("bin/osx64/libsteam_api.dylib"); + let plan = NativeLaunchPlan::from_request(&f.request(&script)).unwrap(); + assert_eq!(plan.kind, "script"); + assert_eq!(plan.target, script); + assert_eq!(plan.interpreter, Some(fs::canonicalize("/bin/sh").unwrap())); + assert_eq!(plan.sdk, Some(sdk)); + } + #[test] + fn bundle_uses_info_plist_and_framework_sdk() { + let f = Fixture::new(); + let app = f.0.join("Game.app"); + let executable = f.binary("Game.app/Contents/MacOS/Game"); + fs::write(app.join("Contents/Info.plist"),br#"CFBundleExecutableGame"#).unwrap(); + let sdk = f.binary("Game.app/Contents/Frameworks/libsteam_api.dylib"); + let plan = NativeLaunchPlan::from_request(&f.request(&app)).unwrap(); + assert_eq!(plan.kind, "bundle"); + assert_eq!(plan.executable, executable); + assert_eq!(plan.sdk, Some(sdk)); + } + #[test] + fn no_sdk_is_a_valid_standalone_game() { + let f = Fixture::new(); + let target = f.binary("game"); + assert!( + NativeLaunchPlan::from_request(&f.request(&target)) + .unwrap() + .sdk + .is_none() + ); + } + #[test] + fn ambiguous_sdk_does_not_pick_arbitrary_file() { + let f = Fixture::new(); + let target = f.binary("game"); + f.binary("first/libsteam_api.dylib"); + f.binary("second/libsteam_api.dylib"); + let plan = NativeLaunchPlan::from_request(&f.request(&target)).unwrap(); + assert!(plan.sdk.is_none()); + assert_eq!(plan.sdk_probe_reason, "ambiguous"); + } + #[test] + fn executable_and_sdk_symlink_escapes_are_rejected() { + let f = Fixture::new(); + let outside = Fixture::new(); + let external = outside.binary("game"); + let link = f.0.join("game"); + std::os::unix::fs::symlink(&external, &link).unwrap(); + assert!( + NativeLaunchPlan::from_request(&f.request(&link)) + .unwrap_err() + .contains("escapes") + ); + let target = f.binary("actual-game"); + std::os::unix::fs::symlink(external, f.0.join("libsteam_api.dylib")).unwrap(); + let plan = NativeLaunchPlan::from_request(&f.request(&target)).unwrap(); + assert!(plan.sdk.is_none()); + assert_eq!(plan.sdk_probe_reason, "invalid_sdk"); + } + #[test] + fn cwd_outside_install_and_nonstring_arguments_are_rejected() { + let f = Fixture::new(); + let target = f.binary("game"); + let mut request = f.request(&target); + request["working_dir"] = json!("/Applications"); + assert!(NativeLaunchPlan::from_request(&request).is_err()); + request = f.request(&target); + request["arguments"] = json!([1]); + assert!(NativeLaunchPlan::from_request(&request).is_err()); + } + #[test] + fn sdk_scan_bounds_are_diagnostic_not_launch_failures() { + let f = Fixture::new(); + let binary = f.binary("bin/game"); + f.binary("deeper/nested/libsteam_api.dylib"); + for (entries, depth) in [(1, 12), (100_000, 0)] { + let (sdk, reason) = + resolve_sdk_with_limits(&f.0, &binary, &binary, "binary", entries, depth); + assert!(sdk.is_none()); + assert_eq!(reason, "bounded_scan"); + } + let deep = f.0.join(["nested"; 14].join("/")); + fs::create_dir_all(deep).unwrap(); + let plan = NativeLaunchPlan::from_request(&f.request(&binary)).unwrap(); + assert!(plan.sdk.is_none()); + assert_eq!(plan.sdk_probe_reason, "bounded_scan"); + } + #[test] + fn incompatible_sdk_does_not_block_native_target() { + let f = Fixture::new(); + let binary = f.binary("game"); + fs::write( + f.0.join("libsteam_api.dylib"), + [0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1], + ) + .unwrap(); + let plan = NativeLaunchPlan::from_request(&f.request(&binary)).unwrap(); + assert!(plan.sdk.is_none()); + assert_eq!(plan.sdk_probe_reason, "incompatible"); + assert_eq!(plan.sdk_architectures, ["x86_64"]); + } + #[test] + fn bundle_executable_cannot_escape_bundle() { + let f = Fixture::new(); + let binary = f.binary("outside/Game"); + let app = f.0.join("Game.app"); + fs::create_dir_all(app.join("Contents/MacOS")).unwrap(); + fs::write(app.join("Contents/Info.plist"), br#"CFBundleExecutableGame"#).unwrap(); + std::os::unix::fs::symlink(binary, app.join("Contents/MacOS/Game")).unwrap(); + assert!( + NativeLaunchPlan::from_request(&f.request(&app)) + .unwrap_err() + .contains("escapes") + ); + } + #[test] + fn universal_header_reports_both_probe_architectures() { + let f = Fixture::new(); + let path = f.0.join("universal"); + let mut bytes = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 2]; + for cpu in [0x01000007_u32, 0x0100000c] { + bytes.extend_from_slice(&cpu.to_be_bytes()); + bytes.extend_from_slice(&[0; 16]); + } + fs::write(&path, bytes).unwrap(); + assert_eq!(macho_architectures(&path).unwrap(), ["arm64", "x86_64"]); + } + #[test] + fn script_control_options_are_rejected_before_spawn() { + let f = Fixture::new(); + let path = f.0.join("launcher"); + fs::write(&path, b"#!/bin/bash -c\nexit 0\n").unwrap(); + assert!( + NativeLaunchPlan::from_request(&f.request(&path)) + .unwrap_err() + .contains("option") + ); + fs::write(&path, b"#!/bin/bash -eu\nexit 0\n").unwrap(); + assert_eq!( + NativeLaunchPlan::from_request(&f.request(&path)) + .unwrap() + .interpreter_argument + .as_deref(), + Some("-eu") + ); + } + #[test] + fn env_shebang_only_resolves_a_single_fixed_interpreter() { + let f = Fixture::new(); + let target = f.0.join("launch"); + fs::write(&target, b"#!/usr/bin/env bash\nexit 0\n").unwrap(); + assert_eq!( + NativeLaunchPlan::from_request(&f.request(&target)) + .unwrap() + .interpreter, + Some(fs::canonicalize("/bin/bash").unwrap()) + ); + fs::write(&target, b"#!/usr/bin/env PATH=/tmp bash\nexit 0\n").unwrap(); + assert!(NativeLaunchPlan::from_request(&f.request(&target)).is_err()); + } +} diff --git a/crates/runtime-host/src/native_lock_files.rs b/crates/runtime-host/src/native_lock_files.rs new file mode 100644 index 0000000..83f1c67 --- /dev/null +++ b/crates/runtime-host/src/native_lock_files.rs @@ -0,0 +1,107 @@ +//! Source 2 uses a numeric single-instance lock in the POSIX temporary root. +//! Permit that file family without allowing arbitrary temporary files or +//! changing the engine's kernel locking semantics. No existing lock is removed. +pub const SOURCE2_LOCK_FILES: &str = r#"(regex #"^(/private)?/tmp/source_engine_[0-9]+[.]lock$")"#; + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; + + #[test] + fn real_sandbox_allows_only_engine_locks_and_preserves_exclusion() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("skinos-lock-policy-test-{nonce}")); + let state = root.join("state"); + fs::create_dir_all(&state).unwrap(); + let state = state.canonicalize().unwrap(); + let canary = root.join("private-canary"); + fs::write(&canary, b"owned test data").unwrap(); + let lock = std::path::PathBuf::from(format!("/private/tmp/source_engine_{nonce}.lock")); + let unrelated = + std::path::PathBuf::from(format!("/private/tmp/source_engine_{nonce}Xlock")); + assert!(!lock.exists() && !unrelated.exists()); + let source = state.join("fixture.c"); + let executable = state.join("fixture"); + fs::write( + &source, + r#" +#include +#include +#include +#include +#include +int main(int argc, char **argv) { + if (argc != 5) return 10; + int expected = atoi(argv[4]); + int fd = open(argv[1], O_RDWR | O_CREAT | O_EXCL, 0600); + if (!expected) { + if (fd >= 0) { close(fd); return 11; } + return (errno == EPERM || errno == EACCES) ? 0 : 12; + } + if (fd < 0 || flock(fd, LOCK_EX | LOCK_NB)) return 13; + int second = open(argv[1], O_RDWR); + if (second < 0) return 14; + if (flock(second, LOCK_EX | LOCK_NB) == 0 || errno != EWOULDBLOCK) return 15; + close(second); + int other = open(argv[2], O_RDWR | O_CREAT | O_EXCL, 0600); + if (other >= 0) { close(other); return 16; } + if (errno != EPERM && errno != EACCES) return 17; + other = open(argv[3], O_RDONLY); + if (other >= 0) { close(other); return 18; } + if (errno != EPERM && errno != EACCES) return 19; + if (flock(fd, LOCK_UN)) return 20; + close(fd); + return 0; +} +"#, + ) + .unwrap(); + assert!( + Command::new("/usr/bin/clang") + .args(["-Wall", "-Wextra", "-Werror"]) + .arg(&source) + .arg("-o") + .arg(&executable) + .status() + .unwrap() + .success() + ); + for allowed in [false, true] { + let rule = if allowed { SOURCE2_LOCK_FILES } else { "" }; + let profile = crate::PROFILE + .replace("; SKINOS_EXTRA_READ", rule) + .replace("; SKINOS_EXTRA_WRITE", rule) + .replace("; SKINOS_EXTRA_EXEC", ""); + let file = state.join("profile.sb"); + fs::write(&file, profile).unwrap(); + let status = Command::new("/usr/bin/sandbox-exec") + .arg("-D") + .arg(format!("STATE_ROOT={}", state.display())) + .arg("-D") + .arg(format!("WORKER={}", executable.display())) + .arg("-f") + .arg(file) + .arg(&executable) + .arg(&lock) + .arg(&unrelated) + .arg(&canary) + .arg(if allowed { "1" } else { "0" }) + .status() + .unwrap(); + assert!(status.success(), "lock policy fixture failed: {status}"); + } + assert!(!unrelated.exists()); + // Only this test's freshly created, unique fixture is removed. The + // application never removes a game's real lock file. + fs::remove_file(lock).unwrap(); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/runtime-host/src/worker.rs b/crates/runtime-host/src/worker.rs index 10dd0e1..d2ac24e 100644 --- a/crates/runtime-host/src/worker.rs +++ b/crates/runtime-host/src/worker.rs @@ -14,6 +14,10 @@ use std::time::{Duration, Instant}; #[path = "worker/game_input_session.rs"] mod game_input_session; +#[path = "worker/launch_selection.rs"] +mod launch_selection; +#[path = "worker/native_process.rs"] +mod native_process; #[path = "worker/rumble_feedback_session.rs"] mod rumble_feedback_session; #[path = "worker/rumble_fixture_session.rs"] @@ -919,7 +923,26 @@ fn launch_game( let count = launch_options(apps, appid, options.as_mut_ptr(), options.len() as i32); let owned = subscribed(client_user, appid); if owned && (count > 0 || !request["compatibility"].is_null()) { - break if count > 0 { options[0] as u32 } else { 0 }; + let count = + usize::try_from(count.max(0)).map_err(|_| "Invalid launch option count")?; + let available = options + .get(..count) + .ok_or("Runtime launch option list exceeds its buffer")?; + let requested = request + .get("launch_option_id") + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_u64() + .and_then(|id| u32::try_from(id).ok()) + .ok_or("Invalid selected launch option ID") + }) + .transpose()?; + break launch_selection::select( + requested, + available, + !request["compatibility"].is_null(), + )?; } if rerequests < 3 && Instant::now() > next_request { event( @@ -1001,10 +1024,29 @@ fn launch_game( let mut launch_result = false; let mut stop_attempt = None; let mut tracked: Option = None; + let script = std::env::var("SKINOS_NATIVE_TARGET_KIND").as_deref() == Ok("script"); + let mut script_tracker: Option = None; let mut persona = runtime.controls.persona.load(Ordering::SeqCst); apply_presence(runtime, persona, true)?; loop { refresh_presence(runtime, &mut persona)?; + if script + && script_tracker.is_none() + && let Some((pid, group, started)) = native_launcher_identity()? + { + script_tracker = Some(native_process::ScriptTracker::capture( + pid, + group, + u128::from(started), + Path::new( + request["install_dir"] + .as_str() + .ok_or("Installation missing")?, + ), + appid, + runtime.state, + )?); + } if seen_running && runtime.controls.stop.load(Ordering::SeqCst) && let Some(process) = tracked.as_ref() @@ -1058,19 +1100,29 @@ fn launch_game( } let active = running(client_user, &gameid); if active && launch_result && !seen_running { - let process = verify_game_process()?; - let pid = process.pid; - tracked = Some(process); - if !running(client_user, &gameid) { - return Err("Game exited during SDK verification".into()); + let candidate = if script { + script_tracker + .as_ref() + .map(|tracker| tracker.find_game()) + .transpose()? + .flatten() + } else { + Some(verify_game_process()?) + }; + if let Some(process) = candidate { + let pid = process.pid; + tracked = Some(process); + if !running(client_user, &gameid) { + return Err("Game exited during SDK verification".into()); + } + seen_running = true; + event( + "game_running", + true, + json!({"appid":appid,"steamid":steamid.to_string(),"pid":pid}), + ); + live_status(appid, "running", Some(steamid), None); } - seen_running = true; - event( - "game_running", - true, - json!({"appid":appid,"steamid":steamid.to_string(),"pid":pid}), - ); - live_status(appid, "running", Some(steamid), None); } if seen_running && !active { live_status(appid, "exited", Some(steamid), None); @@ -2806,6 +2858,34 @@ fn find_compat_game( Ok(None) } +fn native_launcher_identity() -> Result, String> { + unsafe { + let pointer = dlsym((-2_isize) as *mut c_void, c"skinos_last_game_pid".as_ptr()); + if pointer.is_null() { + return Err("Confined native launch adapter is unavailable".into()); + } + let function: unsafe extern "C" fn() -> i32 = std::mem::transmute(pointer); + let pid = function(); + if pid <= 0 { + return Ok(None); + } + let group = dlsym( + (-2_isize) as *mut c_void, + c"skinos_last_game_group".as_ptr(), + ); + let started = dlsym( + (-2_isize) as *mut c_void, + c"skinos_last_game_started_microseconds".as_ptr(), + ); + if group.is_null() || started.is_null() { + return Err("Native launch identity exports are missing".into()); + } + let group: unsafe extern "C" fn() -> i32 = std::mem::transmute(group); + let started: unsafe extern "C" fn() -> u64 = std::mem::transmute(started); + Ok(Some((pid, group(), started()))) + } +} + fn verify_game_process() -> Result { unsafe { let root = (-2_isize) as *mut c_void; @@ -3178,15 +3258,39 @@ fn verify_sdk( steamid: u64, ) -> Result<(), String> { use std::process::{Command, Stdio}; - let executable = Path::new(request["executable"].as_str().ok_or("Executable missing")?); - let sdk = if executable.is_dir() { - executable.join("Contents/MacOS/libsteam_api.dylib") - } else { - executable - .parent() - .ok_or("Executable parent missing")? - .join("libsteam_api.dylib") + let sdk = match std::env::var_os("SKINOS_NATIVE_SDK_PATH") { + Some(path) => std::path::PathBuf::from(path), + None => { + event( + "sdk_probe_skipped", + true, + json!({"reason":std::env::var("SKINOS_NATIVE_SDK_PROBE_REASON").unwrap_or_else(|_| "missing".into())}), + ); + return Ok(()); + } }; + let install = std::fs::canonicalize( + request["install_dir"] + .as_str() + .ok_or("Installation missing")?, + ) + .map_err(|error| error.to_string())?; + let sdk = sdk.canonicalize().map_err(|error| error.to_string())?; + if !sdk.starts_with(install) + || sdk + .file_name() + .is_none_or(|name| name != "libsteam_api.dylib") + { + return Err("Steamworks library escapes selected installation".into()); + } + if std::env::var("SKINOS_NATIVE_SDK_PROBE_SUPPORTED").as_deref() != Ok("1") { + event( + "sdk_probe_skipped", + true, + json!({"reason":"sdk_architecture_not_supported_by_arm64_probe"}), + ); + return Ok(()); + } let stderr = std::fs::File::create(runtime.state.join("sdk-probe.stderr.log")) .map_err(|e| e.to_string())?; let mut command = Command::new(runtime.state.join("skinos-sdk-probe")); diff --git a/crates/runtime-host/src/worker/launch_selection.rs b/crates/runtime-host/src/worker/launch_selection.rs new file mode 100644 index 0000000..8ef0148 --- /dev/null +++ b/crates/runtime-host/src/worker/launch_selection.rs @@ -0,0 +1,56 @@ +//! Keep the appinfo choice made by the client through the native runtime handoff. +pub(super) fn select( + requested: Option, + available: &[i32], + compatibility: bool, +) -> Result { + // Windows launches use the separately validated compatibility plan; the + // macOS runtime need not advertise those Windows-only option IDs. + if compatibility { + return Ok(requested.unwrap_or(0)); + } + if available.iter().any(|value| *value < 0) { + return Err("Runtime returned an invalid launch option ID".into()); + } + if let Some(requested) = requested { + return available + .iter() + .any(|value| *value as u32 == requested) + .then_some(requested) + .ok_or_else(|| { + "Выбранный вариант запуска изменился. Обновите библиотеку и повторите запуск".into() + }); + } + match available { + [only] => Ok(*only as u32), + [] => Err("No launch options available".into()), + _ => Err( + "В сохранённых данных нет ID варианта запуска. Обновите библиотеку перед запуском" + .into(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_selected_sparse_option_instead_of_first_runtime_option() { + assert_eq!(select(Some(19), &[2, 19], false).unwrap(), 19); + } + + #[test] + fn mismatched_or_ambiguous_metadata_never_launches_another_option() { + assert!(select(Some(19), &[2, 7], false).is_err()); + assert!(select(None, &[2, 7], false).is_err()); + assert!(select(None, &[-1], false).is_err()); + } + + #[test] + fn legacy_single_option_and_windows_compatibility_remain_supported() { + assert_eq!(select(None, &[7], false).unwrap(), 7); + assert_eq!(select(Some(19), &[], true).unwrap(), 19); + assert!(select(None, &[], false).is_err()); + } +} diff --git a/crates/runtime-host/src/worker/native_process.rs b/crates/runtime-host/src/worker/native_process.rs new file mode 100644 index 0000000..02770ad --- /dev/null +++ b/crates/runtime-host/src/worker/native_process.rs @@ -0,0 +1,734 @@ +//! Tracks the native executable reached by a confined launcher script. +//! Process arguments stay in the parent's private, zeroed buffer. Only the +//! selected launch's descendants are inspected, and no environment is logged. + +use super::{ProcBsdInfo, TrackedGameProcess, proc_listallpids, proc_pidinfo, process_arguments}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +const MAX_PROCESSES: usize = 65_536; +const MAX_ANCESTRY: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ProcessSnapshot { + pid: i32, + parent: i32, + group: i32, + start: (u64, u64), + zombie: bool, +} + +impl ProcessSnapshot { + fn capture(pid: i32) -> Option { + let mut info = ProcBsdInfo::default(); + let size = std::mem::size_of::() as i32; + // PROC_PIDTBSDINFO: the PID/parent/status fields are supplied by the + // kernel, not argv. The shared struct is checked against the SDK. + let actual = + unsafe { proc_pidinfo(pid, 3, 0, (&mut info as *mut ProcBsdInfo).cast(), size) }; + if actual != size || info.fields[3] != pid as u32 { + return None; + } + Some(Self { + pid, + parent: i32::try_from(info.fields[4]).ok()?, + // pbi_pgid follows pbi_nfiles in sys/proc_info.h. + group: i32::try_from(info.tail[1]).ok()?, + start: (info.start_seconds, info.start_microseconds), + zombie: info.fields[1] == 5, + }) + } + + fn start_micros(self) -> u128 { + u128::from(self.start.0) * 1_000_000 + u128::from(self.start.1) + } +} + +pub(super) struct ScriptTracker { + launcher: Option, + process_group: i32, + worker: ProcessSnapshot, + install_root: PathBuf, + appid: u32, + state_root: PathBuf, + spawned_after: u128, +} + +impl ScriptTracker { + pub(super) fn capture( + launcher_pid: i32, + process_group: i32, + spawned_after: u128, + install_root: &Path, + appid: u32, + state_root: &Path, + ) -> Result { + let worker = ProcessSnapshot::capture(std::process::id() as i32) + .ok_or("Cannot verify native launcher owner")?; + // These two values come from this worker's native adapter, which used + // POSIX_SPAWN_SETPGROUP with pgroup=0 for this successful script spawn. + // They must never come from the selected game's argv or public appid. + if process_group <= 0 || process_group != launcher_pid || spawned_after == 0 { + return Err("Native script has no proven isolated process group".into()); + } + let launcher = ProcessSnapshot::capture(launcher_pid); + if launcher.is_some_and(|launcher| !owned_launcher(launcher, worker, spawned_after)) { + return Err("Native launcher does not belong to this launch".into()); + } + let install_root = install_root + .canonicalize() + .map_err(|_| "Cannot resolve native installation directory")?; + if !install_root.is_dir() || !state_root.is_absolute() || appid == 0 { + return Err("Invalid native process tracking context".into()); + } + Ok(Self { + launcher, + process_group, + worker, + install_root, + appid, + state_root: state_root.to_owned(), + spawned_after, + }) + } + + pub(super) fn find_game(&self) -> Result, String> { + let snapshots = snapshots()?; + let mut candidates = Vec::new(); + for snapshot in snapshots.values() { + let Some(chain) = launch_origin( + snapshot.pid, + &snapshots, + self.launcher, + self.process_group, + self.worker, + self.spawned_after, + ) else { + continue; + }; + let Some(process) = TrackedGameProcess::capture(snapshot.pid) else { + continue; + }; + if process.start != snapshot.start { + continue; + } + let Ok(executable) = process.executable.canonicalize() else { + continue; + }; + if !native_game_path(&executable, &self.install_root) || !is_macho(&executable) { + continue; + } + // Only now read argv/environment: ownership, age, actual executable + // and containment have already excluded unrelated processes. + let Some(arguments) = process_arguments(process.pid) else { + continue; + }; + if !procargs_match_launch( + &arguments.0, + self.appid, + self.state_root.as_os_str().as_bytes(), + ) { + continue; + } + drop(arguments); + if !chain + .iter() + .all(|expected| ProcessSnapshot::capture(expected.pid) == Some(*expected)) + || ProcessSnapshot::capture(self.process_group) + != snapshots.get(&self.process_group).copied() + || !process.is_current() + { + continue; + } + candidates.push(process); + } + candidates.retain(TrackedGameProcess::is_current); + choose_candidate(candidates) + } +} + +fn owned_launcher(launcher: ProcessSnapshot, worker: ProcessSnapshot, spawned_after: u128) -> bool { + launcher.pid > 0 + && launcher.pid != worker.pid + && launcher.parent == worker.pid + && launcher.group == launcher.pid + && launcher.start >= worker.start + && launcher.start_micros() >= spawned_after +} + +fn launch_origin( + pid: i32, + snapshots: &BTreeMap, + launcher: Option, + process_group: i32, + worker: ProcessSnapshot, + spawned_after: u128, +) -> Option> { + if *snapshots.get(&worker.pid)? != worker { + return None; + } + let current_leader = snapshots.get(&process_group).copied(); + match (launcher, current_leader) { + (Some(expected), Some(actual)) + if expected.pid == actual.pid + && expected.start == actual.start + && owned_launcher(actual, worker, spawned_after) => {} + (_, None) => {} + // A leader that appeared after we captured an already-dead launcher, + // or whose start changed, is a reused PID/group rather than our spawn. + _ => return None, + } + let candidate = *snapshots.get(&pid)?; + if candidate.zombie || candidate.start_micros() < spawned_after { + return None; + } + if let Some(launcher) = launcher + && let Some(chain) = ancestry(pid, snapshots, launcher, worker) + { + return Some(chain); + } + // A fast script may background its child and exit before the first poll. + // Kernel group membership preserves the adapter's launch ownership even + // after reparenting. The caller additionally requires the private launch + // environment, exact current PID/start, and a contained native executable. + if candidate.group != process_group { + return None; + } + let mut proof = vec![candidate, worker]; + if let Some(leader) = current_leader { + proof.push(leader); + } + Some(proof) +} + +fn choose_candidate( + mut candidates: Vec, +) -> Result, String> { + match candidates.len() { + 0 => Ok(None), + 1 => Ok(candidates.pop()), + _ => Err("Launcher produced multiple possible game processes; refusing to guess".into()), + } +} + +fn snapshots() -> Result, String> { + let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) }; + if count <= 0 || count as usize > MAX_PROCESSES - 128 { + return Err("Cannot enumerate native game processes within the process limit".into()); + } + let mut pids = vec![0i32; count as usize + 128]; + let capacity = std::mem::size_of_val(pids.as_slice()) as i32; + let filled = unsafe { proc_listallpids(pids.as_mut_ptr().cast(), capacity) }; + if filled < 0 || filled as usize >= pids.len() { + return Err("Native process list changed during enumeration; retry verification".into()); + } + Ok(pids + .into_iter() + .take(filled as usize) + .filter(|pid| *pid > 0) + .filter_map(|pid| ProcessSnapshot::capture(pid).map(|snapshot| (pid, snapshot))) + .collect()) +} + +/// Every link must still be the captured instance and older than its child. +/// A reparented orphan cannot prove its former ancestry and is not adopted. +fn ancestry( + pid: i32, + snapshots: &BTreeMap, + launcher: ProcessSnapshot, + worker: ProcessSnapshot, +) -> Option> { + let actual_launcher = *snapshots.get(&launcher.pid)?; + let actual_worker = *snapshots.get(&worker.pid)?; + if actual_launcher != launcher || actual_worker != worker || launcher.parent != worker.pid { + return None; + } + let mut current = *snapshots.get(&pid)?; + let mut seen = BTreeSet::new(); + let mut chain = Vec::new(); + for _ in 0..MAX_ANCESTRY { + if current.zombie || !seen.insert(current.pid) || current.start < launcher.start { + return None; + } + chain.push(current); + if current.pid == launcher.pid { + chain.push(worker); + return Some(chain); + } + let parent = *snapshots.get(¤t.parent)?; + if parent.start > current.start { + return None; + } + current = parent; + } + None +} + +fn native_game_path(executable: &Path, install_root: &Path) -> bool { + let Ok(relative) = executable.strip_prefix(install_root) else { + return false; + }; + if relative.as_os_str().is_empty() { + return false; + } + if relative.components().any(|component| { + let name = component.as_os_str().to_string_lossy().to_ascii_lowercase(); + name.ends_with(".framework") || name.ends_with(".xpc") + }) { + return false; + } + let Some(name) = executable.file_name() else { + return false; + }; + let name = name.to_string_lossy().to_ascii_lowercase(); + !matches!( + name.as_str(), + "sh" | "bash" | "zsh" | "dash" | "fish" | "env" | "updater" | "launcher" | "bootstrap" + ) && !name.ends_with(" helper") + && !name.ends_with("_helper") + && !name.ends_with("-helper") + && ![ + "crashpad", + "crashhandler", + "crashreport", + "steamwebhelper", + "gameoverlayui", + ] + .iter() + .any(|helper| name.contains(helper)) +} + +fn is_macho(path: &Path) -> bool { + let mut magic = [0u8; 4]; + if !std::fs::File::open(path).is_ok_and(|mut file| file.read_exact(&mut magic).is_ok()) { + return false; + } + matches!( + u32::from_be_bytes(magic), + 0xfeedfacf | 0xcffaedfe | 0xcafebabe | 0xbebafeca | 0xcafebabf | 0xbfbafeca + ) +} + +fn take_string<'a>(bytes: &'a [u8], offset: &mut usize) -> Option<&'a [u8]> { + let tail = bytes.get(*offset..)?; + let end = tail.iter().position(|byte| *byte == 0)?; + *offset += end + 1; + Some(&tail[..end]) +} + +fn procargs_match_launch(bytes: &[u8], appid: u32, state_root: &[u8]) -> bool { + let Some(raw) = bytes.get(..4).and_then(|bytes| bytes.try_into().ok()) else { + return false; + }; + let argc = i32::from_ne_bytes(raw); + if !(1..=65_536).contains(&argc) { + return false; + } + let mut offset = 4; + if take_string(bytes, &mut offset).is_none_or(<[u8]>::is_empty) { + return false; + } + while bytes.get(offset) == Some(&0) { + offset += 1; + } + for _ in 0..argc { + let Some(argument) = take_string(bytes, &mut offset) else { + return false; + }; + // Chromium/Electron renderer and utility children are not the game. + if argument == b"--type" || argument.starts_with(b"--type=") { + return false; + } + } + // Preserve exact argc including empty arguments before skipping padding. + while bytes.get(offset) == Some(&0) { + offset += 1; + } + let expected_appid = appid.to_string(); + let mut appid_seen = false; + let mut state_seen = false; + while let Some(entry) = take_string(bytes, &mut offset) { + if entry.is_empty() { + return appid_seen && state_seen; + } + if let Some(value) = entry.strip_prefix(b"SteamAppId=") { + if appid_seen || value != expected_appid.as_bytes() { + return false; + } + appid_seen = true; + } else if let Some(value) = entry.strip_prefix(b"SKINOS_RUNTIME_STATE_ROOT=") { + if state_seen || value != state_root { + return false; + } + state_seen = true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kernel_tracks_own_background_fixture_after_script_exits() { + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + + struct Fixture { + root: PathBuf, + spawned: bool, + } + impl Drop for Fixture { + fn drop(&mut self) { + if self.spawned { + // Release only this fixture through its private marker; + // never signal a PID after a failed assertion. + let _ = std::fs::write(self.root.join("release"), b"done"); + let deadline = Instant::now() + Duration::from_secs(5); + while !self.root.join("finished").exists() { + if Instant::now() > deadline { + // The child has its own watchdog. Keep its files + // intact rather than unlinking a running fixture. + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + } + let _ = std::fs::remove_dir_all(&self.root); + } + } + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let mut fixture = Fixture { + root: std::env::temp_dir().join(format!( + "skinos-script-group-{}-{nonce}", + std::process::id() + )), + spawned: false, + }; + let install = fixture.root.join("install"); + let state = fixture.root.join("private-state"); + std::fs::create_dir_all(&install).unwrap(); + std::fs::create_dir(&state).unwrap(); + let game = install.join("game"); + let source = fixture.root.join("game.c"); + // Use our own Mach-O, not an Apple platform binary whose environment + // inspection can differ under SIP between macOS versions. + std::fs::write( + &source, + r#" +#include +#include +static int mark(const char *path) { + int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600); + if (fd < 0) return 1; + return close(fd) != 0; +} +int main(int argc, char **argv) { + if (argc != 4 || mark(argv[1])) return 10; + for (int i = 0; i < 3000; ++i) { + if (access(argv[2], F_OK) == 0) return mark(argv[3]); + usleep(10000); + } + mark(argv[3]); + return 11; +} +"#, + ) + .unwrap(); + assert!( + Command::new("/usr/bin/clang") + .args(["-Wall", "-Wextra", "-Werror"]) + .arg(&source) + .arg("-o") + .arg(&game) + .status() + .unwrap() + .success() + ); + let ready = fixture.root.join("ready"); + let release = fixture.root.join("release"); + let finished = fixture.root.join("finished"); + let script = install.join("launch.sh"); + let pid_file = fixture.root.join("fixture.pid"); + std::fs::write( + &script, + b"#!/bin/sh\n\"$1\" \"$2\" \"$3\" \"$4\" &\nprintf '%s\\n' \"$!\" > \"$5\"\n", + ) + .unwrap(); + let spawned_after = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_micros(); + let mut launcher = Command::new("/bin/sh") + .arg(&script) + .arg(&game) + .arg(&ready) + .arg(&release) + .arg(&finished) + .arg(&pid_file) + .env("SteamAppId", "424242") + .env("SKINOS_RUNTIME_STATE_ROOT", &state) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0) + .spawn() + .unwrap(); + fixture.spawned = true; + let launcher_pid = launcher.id() as i32; + assert!(launcher.wait().unwrap().success()); + let game_pid: i32 = std::fs::read_to_string(&pid_file) + .unwrap() + .trim() + .parse() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !ready.exists() { + assert!( + Instant::now() < deadline, + "native fixture did not enter main" + ); + std::thread::sleep(Duration::from_millis(10)); + } + let tracker = ScriptTracker::capture( + launcher_pid, + launcher_pid, + spawned_after, + &install, + 424242, + &state, + ) + .unwrap(); + assert!( + tracker.launcher.is_none(), + "launcher must be reaped before this check" + ); + // Reproduce a scheduling delay longer than the old fixture's lifetime. + // Readiness and lifetime now have explicit handshakes, not a 1s race. + std::thread::sleep(Duration::from_millis(1300)); + let deadline = Instant::now() + Duration::from_secs(5); + let selected = loop { + if let Some(process) = tracker.find_game().unwrap() { + break process; + } + assert!( + Instant::now() < deadline, + "background fixture must remain identifiable after shell exit" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!(selected.pid, game_pid); + assert_eq!( + selected.executable.canonicalize().unwrap(), + game.canonicalize().unwrap() + ); + } + + fn process(pid: i32, parent: i32, seconds: u64) -> ProcessSnapshot { + ProcessSnapshot { + pid, + parent, + group: pid, + start: (seconds, 0), + zombie: false, + } + } + + fn tree() -> BTreeMap { + [ + process(1, 0, 1), + process(10, 1, 10), + process(20, 10, 20), + process(30, 20, 21), + process(40, 30, 22), + process(50, 10, 23), + ] + .into_iter() + .map(|snapshot| (snapshot.pid, snapshot)) + .collect() + } + + #[test] + fn same_pid_exec_and_proven_descendants_keep_identity() { + let tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + assert!(ancestry(20, &tree, launcher, worker).is_some()); + assert!(ancestry(40, &tree, launcher, worker).is_some()); + assert!(ancestry(50, &tree, launcher, worker).is_none()); + } + + #[test] + fn launcher_must_be_created_after_request_by_current_worker() { + let worker = process(10, 1, 10); + let launcher = process(20, 10, 20); + assert!(owned_launcher(launcher, worker, 20_000_000)); + assert!(!owned_launcher(launcher, worker, 20_000_001)); + assert!(!owned_launcher(process(20, 99, 20), worker, 0)); + assert!(!owned_launcher(worker, worker, 0)); + } + + #[test] + fn multiple_real_game_candidates_are_ambiguous() { + fn candidate(pid: i32) -> TrackedGameProcess { + TrackedGameProcess { + pid, + start: (20, 0), + executable: PathBuf::from("/Games/Game/game"), + executable_identity: (1, 2), + } + } + assert!(choose_candidate(Vec::new()).unwrap().is_none()); + assert_eq!( + choose_candidate(vec![candidate(20)]).unwrap().unwrap().pid, + 20 + ); + assert!(choose_candidate(vec![candidate(20), candidate(30)]).is_err()); + } + + #[test] + fn reused_or_reparented_processes_cannot_be_adopted() { + let mut tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + tree.get_mut(&20).unwrap().start = (30, 0); + assert!(ancestry(20, &tree, launcher, worker).is_none()); + assert!(ancestry(40, &tree, launcher, worker).is_none()); + tree.insert(20, launcher); + tree.get_mut(&30).unwrap().start = (30, 0); + assert!(ancestry(40, &tree, launcher, worker).is_none()); + tree.insert(30, process(30, 1, 21)); + assert!(ancestry(40, &tree, launcher, worker).is_none()); + } + + #[test] + fn orphaned_child_is_found_only_in_the_proven_adapter_group() { + let mut tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + tree.remove(&20); + let orphan = tree.get_mut(&40).unwrap(); + orphan.parent = 1; + orphan.group = 20; + // Both cases are valid: we observed the launcher, or it had already + // exited before capture and only the adapter's group proof remains. + assert!(launch_origin(40, &tree, Some(launcher), 20, worker, 20_000_000).is_some()); + assert!(launch_origin(40, &tree, None, 20, worker, 20_000_000).is_some()); + tree.get_mut(&40).unwrap().group = 99; + assert!(launch_origin(40, &tree, None, 20, worker, 20_000_000).is_none()); + tree.get_mut(&40).unwrap().group = 20; + tree.get_mut(&40).unwrap().start = (19, 0); + assert!(launch_origin(40, &tree, None, 20, worker, 20_000_000).is_none()); + } + + #[test] + fn reused_group_leader_and_stale_worker_invalidate_group_proof() { + let mut tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + tree.get_mut(&40).unwrap().group = 20; + assert!(launch_origin(40, &tree, Some(launcher), 20, worker, 20_000_000).is_some()); + tree.get_mut(&20).unwrap().start = (30, 0); + assert!(launch_origin(40, &tree, Some(launcher), 20, worker, 20_000_000).is_none()); + assert!(launch_origin(40, &tree, None, 20, worker, 20_000_000).is_none()); + tree.remove(&20); + tree.get_mut(&10).unwrap().start = (30, 0); + assert!(launch_origin(40, &tree, None, 20, worker, 20_000_000).is_none()); + } + + #[test] + fn zombie_group_leader_does_not_hide_a_live_child() { + let mut tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + tree.get_mut(&20).unwrap().zombie = true; + tree.get_mut(&40).unwrap().group = 20; + assert!(launch_origin(40, &tree, Some(launcher), 20, worker, 20_000_000).is_some()); + assert!(launch_origin(20, &tree, Some(launcher), 20, worker, 20_000_000).is_none()); + } + + #[test] + fn zombies_and_ancestry_cycles_fail_closed() { + let mut tree = tree(); + let launcher = tree[&20]; + let worker = tree[&10]; + tree.get_mut(&40).unwrap().zombie = true; + assert!(ancestry(40, &tree, launcher, worker).is_none()); + tree.get_mut(&40).unwrap().zombie = false; + tree.get_mut(&30).unwrap().parent = 40; + tree.get_mut(&30).unwrap().start = (22, 0); + assert!(ancestry(40, &tree, launcher, worker).is_none()); + } + + fn procargs(arguments: &[&[u8]], environment: &[&[u8]]) -> Vec { + let mut bytes = (arguments.len() as i32).to_ne_bytes().to_vec(); + bytes.extend_from_slice(b"/Games/Game/game\0\0\0"); + for argument in arguments { + bytes.extend_from_slice(argument); + bytes.push(0); + } + bytes.extend_from_slice(&[0, 0]); + for entry in environment { + bytes.extend_from_slice(entry); + bytes.push(0); + } + bytes.push(0); + bytes + } + + #[test] + fn exact_environment_is_required_and_argv_cannot_spoof_it() { + let env: &[&[u8]] = &[ + b"SteamAppId=42", + b"SKINOS_RUNTIME_STATE_ROOT=/launch/unique", + ]; + let bytes = procargs(&[b"game", b""], env); + assert!(procargs_match_launch(&bytes, 42, b"/launch/unique")); + assert!(!procargs_match_launch(&bytes, 43, b"/launch/unique")); + assert!(!procargs_match_launch(&bytes, 42, b"/other/launch")); + assert!(!procargs_match_launch( + &procargs(env, &[]), + 42, + b"/launch/unique" + )); + assert!(!procargs_match_launch( + &procargs(&[b"game"], &[env[0], env[1], env[0]]), + 42, + b"/launch/unique" + )); + assert!(!procargs_match_launch( + &procargs(&[b"game", b"--type=renderer"], env), + 42, + b"/launch/unique" + )); + } + + #[test] + fn outside_install_shells_and_helpers_are_not_game_candidates() { + let root = Path::new("/Games/Game"); + assert!(native_game_path( + Path::new("/Games/Game/Game.app/Contents/MacOS/Game"), + root + )); + for path in [ + "/Games/Other/game", + "/Games/Game2/game", + "/Games/Game/sh", + "/Games/Game/UnityCrashHandler64", + "/Games/Game/Game Helper.app/Contents/MacOS/crashpad_handler", + "/Games/Game/Engine.framework/engine", + "/Games/Game/Renderer.xpc/renderer", + "/Games/Game/updater", + "/Games/Game/Game Helper", + ] { + assert!(!native_game_path(Path::new(path), root), "{path}"); + } + } +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 483e580..58cef4b 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -229,8 +229,8 @@ cat > "$SKINOS_OUTPUT/Contents/Info.plist" <<'PLIST' CFBundleExecutableSkinOS CFBundleIconFileSkinOS CFBundlePackageTypeAPPL - CFBundleShortVersionString0.1.4 - CFBundleVersion5 + CFBundleShortVersionString0.1.5 + CFBundleVersion6 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication