Steam's art lives in Program Files, which was never an allowed art root #215

Merged
enricobuehler merged 3 commits from worktree-steam-art-root-windows into main 2026-08-14 07:51:32 +00:00
Owner

Field report: the Steam library plugin installed, the grid stayed empty, and the only clue was one warn per sync.

plugin:steam sync (fs-change) failed: HostRequestError: PUT /library/provider/steam?store=steam
  failed: art.hero: local art must be an image file (…) inside an allowed art root

Two independent defects, and the second is why it cost the whole library rather than a thumbnail.

1. Steam's art was never inside an allowed root on Windows

art_roots() defaulted to the users base — C:\Users, from %PUBLIC%'s parent. That covers the launchers that install per-user (Playnite, Heroic, both under %APPDATA%), but not Steam, which installs to C:\Program Files (x86)\Steam and keeps both the things the plugin publishes there: appcache\librarycache\<appid>\<hash>\ and each account's userdata\<id>\config\grid\ overrides. Every cover was out of root, on every default Windows install.

This is a v0.28.0 regression, and the shape of it is worth noting: the built-in scanner the plugin replaced served its covers through the legacy steam: art-proxy branch, which never passed through the H-2 confinement at all. Deleting the scanner (M6/WP6.4) silently routed that art through a gate it had never been measured against — removing a bypass is a behaviour change even when no line of the gate moves.

art_roots() now also carries every Steam install it can find: %ProgramFiles(x86)% / %ProgramFiles% / %ProgramW6432%, plus HKLM SOFTWARE\{WOW6432Node\,}Valve\SteamInstallPath so a Steam on another drive counts too. POSIX needs no equivalent — native and Flatpak Steam are both already under $HOME.

This is not a security loosening. The confinement exists to stop the host (SYSTEM) reading files the plugin lane (LocalService) cannot reach itself. The Steam directory is LocalService-readable already, so nothing there is reachable because the host is privileged. The extension, regular-file, magic-byte and config-dir gates all still apply on top — there is a test asserting Steam's own config.vdf and a non-image wearing .png remain unservable from inside an art root.

2. One unservable cover 400'd the entire payload

reconcile_provider_entries validated art per entry and returned on the first bad value, so a path mismatch cost the operator every game from that store — and the plugin, which only ever sees HostRequestError, could not say which. That is the whole gap between the symptom ("nothing fills out") and the message (which only mentions art).

A provider reconcile now strips unservable local art and syncs the rest (sanitize_art_paths), logging one aggregated warn with the count, an example path and the env var. The invariant the 400 held is unchanged: no unservable path is ever persisted. The operator's own single-entry custom writes keep the hard 400 — there the path was typed by hand, and silence would be the wrong answer.

check_entry_fields is split so the reconcile still applies the authority half (check_privileged_fields: 403 privileged field / 400 icon) in full.

Operator-visible

An art-root mismatch no longer fails a sync. If covers are blank where art is expected, the cue is the host log's dropped local art the proxy may not serve line, and the knob is PUNKTFUNK_LIBRARY_ART_ROOTS — which replaces the defaults, so list every root you need. The docs row also had the separator wrong (it is PATH-style: ; on Windows, : on POSIX, per std::env::split_paths); fixed.

Verification

  • Linux (.25): 493/493 host tests, clippy -D warnings clean and non-vacuous.
  • Windows (.133): 12/12 art tests, clippy clean and non-vacuous. Mandatory here — neither the Mac (host does not build there) nor .25 compiles a single #[cfg(windows)] line of this fix, and scripts/xcheck.sh windows does not cover punktfunk-host.
  • cargo fmt --check clean.

The Windows test had to be made hermetic to be worth anything. The first version asserted over whatever steam_art_roots() found — which is empty on a runner with no Steam, so it passed vacuously, which is precisely the kind of test that let this ship in the first place. The final version repoints %ProgramFiles(x86)% at a synthetic Steam\appcache\librarycache\570\abcdef\library_hero.jpg tree and drives the whole chain: probe → roots → confinement → art_path_is_servablelocal_art_bytes. Confirmed non-vacuous by disabling the fix in a staging copy — it fails on "the DEFAULT art roots must include it".

🛑 No on-glass confirmation. The RTX box (.173) was down, and the CI runner has no Steam installed, which is why the test is synthetic. A real Windows box with Steam should be checked before this is relied on.

Field report: the Steam library plugin installed, the grid stayed empty, and the only clue was one warn per sync. ``` plugin:steam sync (fs-change) failed: HostRequestError: PUT /library/provider/steam?store=steam failed: art.hero: local art must be an image file (…) inside an allowed art root ``` Two independent defects, and the second is why it cost the whole library rather than a thumbnail. ## 1. Steam's art was never inside an allowed root on Windows `art_roots()` defaulted to the users base — `C:\Users`, from `%PUBLIC%`'s parent. That covers the launchers that install per-user (Playnite, Heroic, both under `%APPDATA%`), but **not Steam**, which installs to `C:\Program Files (x86)\Steam` and keeps both the things the plugin publishes there: `appcache\librarycache\<appid>\<hash>\` and each account's `userdata\<id>\config\grid\` overrides. Every cover was out of root, on every default Windows install. This is a **v0.28.0 regression**, and the shape of it is worth noting: the built-in scanner the plugin replaced served its covers through the legacy `steam:` art-proxy branch, which never passed through the H-2 confinement at all. Deleting the scanner (M6/WP6.4) silently routed that art through a gate it had never been measured against — removing a bypass is a behaviour change even when no line of the gate moves. `art_roots()` now also carries every Steam install it can find: `%ProgramFiles(x86)%` / `%ProgramFiles%` / `%ProgramW6432%`, plus HKLM `SOFTWARE\{WOW6432Node\,}Valve\Steam` → `InstallPath` so a Steam on another drive counts too. POSIX needs no equivalent — native and Flatpak Steam are both already under `$HOME`. **This is not a security loosening.** The confinement exists to stop the host (SYSTEM) reading files the plugin lane (LocalService) cannot reach itself. The Steam directory is LocalService-readable already, so nothing there is reachable *because* the host is privileged. The extension, regular-file, magic-byte and config-dir gates all still apply on top — there is a test asserting Steam's own `config.vdf` and a non-image wearing `.png` remain unservable from inside an art root. ## 2. One unservable cover 400'd the entire payload `reconcile_provider_entries` validated art per entry and returned on the first bad value, so a path mismatch cost the operator **every game from that store** — and the plugin, which only ever sees `HostRequestError`, could not say which. That is the whole gap between the symptom ("nothing fills out") and the message (which only mentions art). A provider reconcile now **strips** unservable local art and syncs the rest (`sanitize_art_paths`), logging one aggregated warn with the count, an example path and the env var. The invariant the 400 held is unchanged: no unservable path is ever persisted. The operator's own single-entry custom writes keep the hard 400 — there the path was typed by hand, and silence would be the wrong answer. `check_entry_fields` is split so the reconcile still applies the authority half (`check_privileged_fields`: 403 privileged field / 400 icon) in full. ## Operator-visible An art-root mismatch no longer fails a sync. If covers are blank where art is expected, the cue is the host log's `dropped local art the proxy may not serve` line, and the knob is `PUNKTFUNK_LIBRARY_ART_ROOTS` — which **replaces** the defaults, so list every root you need. The docs row also had the separator wrong (it is `PATH`-style: `;` on Windows, `:` on POSIX, per `std::env::split_paths`); fixed. ## Verification - **Linux** (.25): 493/493 host tests, clippy `-D warnings` clean and non-vacuous. - **Windows** (.133): 12/12 art tests, clippy clean and non-vacuous. Mandatory here — neither the Mac (host does not build there) nor .25 compiles a single `#[cfg(windows)]` line of this fix, and `scripts/xcheck.sh windows` does not cover `punktfunk-host`. - `cargo fmt --check` clean. ⚠ **The Windows test had to be made hermetic to be worth anything.** The first version asserted over whatever `steam_art_roots()` found — which is empty on a runner with no Steam, so it passed *vacuously*, which is precisely the kind of test that let this ship in the first place. The final version repoints `%ProgramFiles(x86)%` at a synthetic `Steam\appcache\librarycache\570\abcdef\library_hero.jpg` tree and drives the whole chain: probe → roots → confinement → `art_path_is_servable` → `local_art_bytes`. Confirmed non-vacuous by disabling the fix in a staging copy — it fails on *"the DEFAULT art roots must include it"*. 🛑 **No on-glass confirmation.** The RTX box (.173) was down, and the CI runner has no Steam installed, which is why the test is synthetic. A real Windows box with Steam should be checked before this is relied on.
enricobuehler added 1 commit 2026-08-14 07:11:42 +00:00
fix(library): Steam's art lives in Program Files, which was never an allowed art root
ci / bun-nix (pull_request) Successful in 20s
android / android (pull_request) Canceled after 1m22s
ci / rust-arm64 (pull_request) Successful in 1m17s
ci / rust (pull_request) Canceled after 1m22s
ci / web (pull_request) Canceled after 1m18s
ci / docs-site (pull_request) Canceled after 1m18s
6dd4add11b
A field report: the Steam plugin installed, the grid stayed empty, and the
only clue was one warn per sync — `art.hero: local art must be an image file
… inside an allowed art root`.

Two defects, both here.

The art roots defaulted to the users base (`C:\Users`, from `%PUBLIC%`'s
parent). That covers the launchers that install per-user, but not Steam,
which installs to `C:\Program Files (x86)\Steam` and keeps both the things
the plugin publishes there — `appcache\librarycache\<appid>\<hash>\` and each
account's `userdata\<id>\config\grid\`. So every cover was out of root. It is
a v0.28.0 regression: the built-in scanner the plugin replaced served covers
through the legacy `steam:` art-proxy branch, which never passed through the
H-2 confinement, so deleting the scanner routed that art through a gate it
had never been measured against. `art_roots()` now also carries every Steam
install it can find, from the three Program Files vars and from HKLM
`Valve\Steam\InstallPath` so a Steam on another drive counts too. POSIX needs
no equivalent — native and Flatpak Steam are both already under `$HOME`.

The confinement is not weakened. It exists to stop the host (SYSTEM) reading
what the plugin lane (LocalService) cannot reach itself; the Steam directory
is readable by LocalService already, so nothing there is reachable *because*
the host is privileged, and the extension, regular-file, magic-byte and
config-dir gates still apply on top. Tested: `config.vdf` is not servable
from an art root, nor is a non-image wearing `.png`.

Second, and the reason this cost a whole library rather than a thumbnail: the
provider reconcile validated art per entry and 400'd the WHOLE payload on the
first bad value. A path mismatch therefore deleted every game from that
store, and the plugin — which only ever sees `HostRequestError` — could not
say which. A reconcile now strips unservable local art and syncs the rest,
logging one aggregated warn with the count, an example path and the env var.
The invariant the 400 held is unchanged: no unservable path is persisted. The
operator's own single-entry writes keep the hard 400, because there the path
was typed by hand and silence would be the wrong answer.

Verified on Linux (.25: 493/493, clippy clean) and Windows (.133: 12/12 art
tests, clippy clean). The new Windows test is hermetic — it repoints
`%ProgramFiles(x86)%` at a synthetic Steam tree rather than asserting over
whatever Steam the box happens to have, since the vacuous version of that
test is what would have let this ship. Confirmed non-vacuous by disabling the
fix: it fails on "the DEFAULT art roots must include it".
enricobuehler added 1 commit 2026-08-14 07:13:01 +00:00
Merge remote-tracking branch 'origin/main' into worktree-steam-art-root-windows
ci / web (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m34s
ci / bun-nix (pull_request) Successful in 1m51s
android / android (pull_request) Successful in 3m57s
ci / docs-site (pull_request) Successful in 3m8s
ci / rust (pull_request) Failing after 6m35s
8d60f1cec0
# Conflicts:
#	CHANGELOG.md
enricobuehler added 1 commit 2026-08-14 07:32:51 +00:00
fix(ci): funnel the art tests' env overrides through one RAII guard
ci / bun-nix (pull_request) Successful in 24s
apple / swift (pull_request) Successful in 2m8s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 5m47s
ci / docs-site (pull_request) Successful in 5m51s
ci / rust-arm64 (pull_request) Successful in 5m54s
ci / rust (pull_request) Successful in 18m16s
0f9ccfa8b6
CI gate C (unsafe hygiene) failed on the previous commit: `library/art.rs`
went from 4 process-global-API mentions to 10, because the two new tests each
hand-rolled a set/restore pair the way the two existing ones already did.

The gate says fix the call sites rather than raise the baseline, and it is
right to here — the hand-rolled pattern was also leaking. Each test set
`PUNKTFUNK_LIBRARY_ART_ROOTS` and unset it at the end, so any assertion
firing between the two halves left the override installed for every later
test in the process, turning one real failure into a cascade.

`ArtRootsEnv` now holds the lock and the saved values and restores them on
drop, which runs on an unwind too. `write_env` is the single write point, so
the gate has exactly one pair of call sites to judge: the count drops to 2,
below the old baseline of 4, and stays flat however many tests are added.
Baseline lowered to 2 in the same commit, as the ratchet's policy requires.

⚠ The gate greps for the API names in COMMENTS as well as code, so the SAFETY
comments here deliberately describe the calls instead of naming them.

Re-verified after the refactor: .25 493/493 + clippy clean, .133 12/12 art
tests + clippy clean, `check-unsafe-hygiene.sh` clean locally.
enricobuehler merged commit 0ead084838 into main 2026-08-14 07:51:32 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#215