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.
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.
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".
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Field report: the Steam library plugin installed, the grid stayed empty, and the only clue was one warn per sync.
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 toC:\Program Files (x86)\Steamand keeps both the things the plugin publishes there:appcache\librarycache\<appid>\<hash>\and each account'suserdata\<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 HKLMSOFTWARE\{WOW6432Node\,}Valve\Steam→InstallPathso 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.vdfand a non-image wearing.pngremain unservable from inside an art root.2. One unservable cover 400'd the entire payload
reconcile_provider_entriesvalidated 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 seesHostRequestError, 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_fieldsis 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 serveline, and the knob isPUNKTFUNK_LIBRARY_ART_ROOTS— which replaces the defaults, so list every root you need. The docs row also had the separator wrong (it isPATH-style:;on Windows,:on POSIX, perstd::env::split_paths); fixed.Verification
-D warningsclean and non-vacuous.#[cfg(windows)]line of this fix, andscripts/xcheck.sh windowsdoes not coverpunktfunk-host.cargo fmt --checkclean.⚠ 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 syntheticSteam\appcache\librarycache\570\abcdef\library_hero.jpgtree 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.