From 166e158afeb1616311d20624fd68d180b929c28d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 15:39:44 +0200 Subject: [PATCH] feat(host/library): an `xbox` launch kind a de-privileged plugin can publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The xbox scanner resolves each package's PackageFamilyName by enumerating %ProgramData%\Microsoft\Windows\AppRepository\Packages. Probed on .173 (design doc S1), that directory is: NT AUTHORITY\LocalService UnauthorizedAccessException <- the plugin runner LocalSystem count=348 <- the host service So an extracted xbox plugin cannot build an AUMID, and `aumid` — which takes a complete `!` — is unpublishable by one. The design doc offered two fallbacks: entries without `aumid` (they would list but not launch), or a one-shot elevated PFN resolve at install time. This is neither. The plugin sends `!`, both read straight out of MicrosoftGame.config — which IS readable de-privileged — and the host resolves the publisher hash at LAUNCH time, where it already owns command construction. That keeps D1 intact (the plugin supplies a validated value, the host builds the command), it is the same shape as the `playnite` kind, and resolving late means a package update that changes the publisher hash cannot leave a stale unlaunchable tile behind — which the install-time cache would have. `xbox_pfn` therefore moves from the scan path to the launch path rather than being deleted, and its doc records the privilege asymmetry so the next reader doesn't re-derive it. The charset guard is factored out (`aumid_part` / `valid_aumid`) and shared with the `aumid` kind. On `aumid` it was belt-and-braces — every value was host-derived. On `xbox` it is load-bearing: the Identity arrives over the wire from a plugin, and it is interpolated into a `shell:AppsFolder\…` argument. Validated inbound at reconcile too, so a malformed value is a 400 the plugin author can act on rather than a tile that fails on click. Gates: punktfunk-host 438 passed / 0 failed on .21, and 55/0 for the library tests on .173 (where these arms actually compile). Both counts +1 for the new guard test. --- crates/punktfunk-host/src/library/custom.rs | 8 +++ crates/punktfunk-host/src/library/launch.rs | 79 +++++++++++++++++---- crates/punktfunk-host/src/library/xbox.rs | 8 ++- crates/punktfunk-host/src/mgmt/library.rs | 4 +- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index 1d4fe59c..911710f9 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -468,6 +468,14 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St "entries[{i}]: `launch.value` for kind `playnite` must be a Playnite game GUID" )); } + // `!`, both straight off `MicrosoftGame.config`. The host completes it + // into an AUMID at launch (it can read the publisher hash; the runner cannot), so the + // shape is checked here where the author can still act on the error. + if launch.kind == "xbox" && !valid_aumid(&launch.value) { + return Err(format!( + "entries[{i}]: `launch.value` for kind `xbox` must be `!`" + )); + } } if let Some(marker) = &e.detect.env_marker { if !valid_env_key(&marker.key) { diff --git a/crates/punktfunk-host/src/library/launch.rs b/crates/punktfunk-host/src/library/launch.rs index b5bb5eac..87ee45fa 100644 --- a/crates/punktfunk-host/src/library/launch.rs +++ b/crates/punktfunk-host/src/library/launch.rs @@ -184,21 +184,33 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option { - let valid = spec.value.split_once('!').is_some_and(|(pfn, app)| { - let part = |s: &str| { - !s.is_empty() - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) - }; - part(pfn) && part(app) - }); - valid.then(|| { - ( - format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value), - None, - ) - }) + "aumid" => valid_aumid(&spec.value).then(|| { + ( + format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value), + None, + ) + }), + // Xbox / Game Pass from a library PLUGIN: `!`, both read straight out of + // `MicrosoftGame.config`. The host completes it into the AUMID. + // + // This kind exists because of a measured privilege asymmetry (2026-08-06): resolving the + // PackageFamilyName means enumerating `%ProgramData%\…\AppRepository\Packages`, which is + // denied to `NT AUTHORITY\LocalService` — the principal the plugin runner runs as — and + // allowed to the host, which runs as LocalSystem. So the plugin sends what it can read and + // the host reads the authoritative publisher hash itself, at launch time. + // + // Resolving here rather than caching at install time also means a package update that + // changes the hash cannot leave a stale, unlaunchable tile behind. + "xbox" => { + let (identity, app_id) = spec.value.split_once('!')?; + if !aumid_part(identity) || !aumid_part(app_id) { + return None; + } + let pfn = xbox_pfn(identity)?; + Some(( + format!("explorer.exe \"shell:AppsFolder\\{pfn}!{app_id}\""), + None, + )) } // Playnite: open the game through Playnite's own URI handler, which is what actually knows // how to start it (Playnite maps the id to whichever store owns the title). explorer.exe @@ -285,6 +297,23 @@ pub(crate) fn valid_steam_ui(value: &str) -> bool { matches!(value, "bigpicture" | "desktop") } +/// One half of an AUMID (a package family name or an app id): non-empty, and no character that +/// could break out of the `shell:AppsFolder\…` argument. Both halves are host-derived, so this is +/// belt-and-braces — but the `xbox` kind now takes an Identity straight off a plugin's wire, which +/// makes it load-bearing rather than defensive. +pub(crate) fn aumid_part(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// A full `!` AUMID. +pub(crate) fn valid_aumid(value: &str) -> bool { + value + .split_once('!') + .is_some_and(|(pfn, app)| aumid_part(pfn) && aumid_part(app)) +} + /// A Playnite game id: the GUID Playnite's own database uses, and the only client-influenced part /// of a `playnite` launch. Interpolated into a URI handed to explorer.exe, so the charset is /// validated first — 8-4-4-4-12 lowercase-or-uppercase hex with dashes, nothing else. @@ -684,6 +713,26 @@ mod tests { assert!(!valid_launcher_ui("lutris; rm -rf ~")); } + /// The `xbox` kind is what a library PLUGIN can publish: the runner's principal cannot read + /// AppRepository (measured 2026-08-06), so it sends `!` and the host resolves + /// the publisher hash. The charset guard is load-bearing here — unlike `aumid`, this value + /// arrives over the wire. + #[test] + fn xbox_value_is_identity_bang_appid_and_charset_guarded() { + assert!(valid_aumid("Microsoft.Foo!Game")); + assert!(valid_aumid("A_b-c.d!App")); + // Both halves must be present and non-empty. + assert!(!valid_aumid("Microsoft.Foo")); + assert!(!valid_aumid("!Game")); + assert!(!valid_aumid("Microsoft.Foo!")); + assert!(!valid_aumid("")); + // Nothing that could break out of the `shell:AppsFolder\…` argument. + assert!(!valid_aumid("Foo\"!Game")); + assert!(!valid_aumid("Foo!Game\" & calc")); + assert!(!valid_aumid("Foo\\..\\Bar!Game")); + assert!(!valid_aumid("Foo Bar!Game")); + } + /// Windows' launcher tile opens Playnite's FULLSCREEN app. Both negatives are the point: the /// desktop app is not what a couch tile should open, and the `playnite://` handler cannot be /// used because it is registered to the desktop app (verified on .173, 2026-08-06). diff --git a/crates/punktfunk-host/src/library/xbox.rs b/crates/punktfunk-host/src/library/xbox.rs index 80cbcfe7..82db43f7 100644 --- a/crates/punktfunk-host/src/library/xbox.rs +++ b/crates/punktfunk-host/src/library/xbox.rs @@ -134,8 +134,14 @@ fn xbox_parse_config(text: &str, folder: Option<&str>) -> Option<(String, String /// Resolve a package's PackageFamilyName by finding its /// `AppRepository\Packages\` dir (machine-wide, SYSTEM-readable) and reducing the /// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash. +/// +/// **Readable by the host, NOT by the plugin runner.** Measured on 2026-08-06: that directory is +/// `UnauthorizedAccessException` for `NT AUTHORITY\LocalService` (which the runner is), while the +/// host service runs as LocalSystem and enumerates all 348 entries. That asymmetry is why the +/// `xbox` launch kind exists — a library plugin sends the package Identity it CAN read out of +/// `MicrosoftGame.config`, and this resolves the rest at launch time (see `launch.rs`). #[cfg(windows)] -fn xbox_pfn(identity: &str) -> Option { +pub(crate) fn xbox_pfn(identity: &str) -> Option { let pkgs = PathBuf::from(std::env::var_os("ProgramData")?) .join("Microsoft") .join("Windows") diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index 179e91a1..21c2e525 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -31,8 +31,8 @@ fn check_entry_fields( &format!( "`{field}` is executed as the host user and may only be set with the \ operator's admin token — a plugin may publish entries with any host-resolved \ - launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic, \ - playnite) \ + launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \ + heroic, playnite) \ instead" ), ));