Compare commits

...
Author SHA1 Message Date
enricobuehler 0f9ccfa8b6 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
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.
2026-08-14 09:32:43 +02:00
enricobuehler 8d60f1cec0 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
# Conflicts:
#	CHANGELOG.md
2026-08-14 09:12:39 +02:00
enricobuehler 6dd4add11b 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
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".
2026-08-14 08:38:43 +02:00
enricobuehler b6cc76c472 Merge pull request 'The Android audio plane trusted AAudio, so a TV that opened a dead stream was silent all session' (#214) from worktree-android-aaudio-shield-silence into main
ci / bun-nix (push) Successful in 1m10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / docs-site (push) Successful in 2m3s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 3m23s
ci / web (push) Successful in 3m52s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 37s
ci / rust-arm64 (push) Successful in 4m25s
ci / rust (push) Successful in 4m8s
android / android (push) Successful in 12m7s
2026-08-14 06:22:33 +00:00
5 changed files with 393 additions and 31 deletions
+44
View File
@@ -14,6 +14,50 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.28.1 — in development
### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not
Field report — the plugin installed, the grid stayed empty, and the only clue was one host 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, both fixed here.
**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), which covers the launchers that install per-user —
Playnite under `%APPDATA%`, Heroic under `%APPDATA%` — but *not* Steam, which installs to
`C:\Program Files (x86)\Steam` and keeps both the art the plugin publishes there:
`appcache\librarycache\<appid>\<hash>\` and each account's `userdata\<id>\config\grid\` overrides.
Every cover the plugin emitted was out of root. This is a v0.28.0 regression: 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 — deleting the scanner routed that art through a gate it had
never been measured against. `art_roots()` now also includes every Steam install root it can find,
from `%ProgramFiles(x86)%` / `%ProgramFiles%` / `%ProgramW6432%` and from HKLM
`Valve\Steam\InstallPath` (so a Steam on another drive is covered too). POSIX needed no equivalent —
every Steam layout there, native and Flatpak, is already under `$HOME`.
This does not weaken the confinement. It exists to stop the host (SYSTEM) reading files 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. The extension, regular-file, magic-byte
and config-dir gates all still apply, so Steam's own `config.vdf` and `ssfn*` credential blobs are
not servable from it — there is a test.
**2. One unservable cover threw away the entire library.** `PUT /library/provider/{p}` validated art
per entry and returned 400 for the whole payload on the first bad value, so a path mismatch cost the
operator *every game from that store*, not a thumbnail — and the plugin, which only ever sees
`HostRequestError`, could not say which. A provider reconcile now **strips** unservable local art and
syncs the rest (`sanitize_art_paths`), logging one aggregated warn naming 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.
**Operator-visible:** an art-root mismatch no longer fails a sync. If covers are blank where you
expect art, 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 — list every root you need).
### Android — the audio plane trusted AAudio, and a TV box that opened a stream it never played was silent for the session
🛑 **Reported from the field: no audio at all on an NVIDIA Shield Android TV, stereo, with the same
+291 -22
View File
@@ -150,12 +150,13 @@ fn percent_decode(s: &str) -> String {
/// H-2): `mgmt-token`, `key.pem`, the SAM hive. So the value is confined here, at the one place
/// bytes are read, rather than trusted because of where it was written.
///
/// Default: the users base (`C:\Users`), which is where every launcher keeps its art cache
/// Playnite, the only local-art provider, stores covers under `%APPDATA%\Playnite`. Derived from
/// Default: the users base (`C:\Users`), where the launchers that install per-user keep their art
/// Playnite stores covers under `%APPDATA%\Playnite`, Heroic under `%APPDATA%\heroic`. Derived from
/// `%PUBLIC%`'s parent because the host runs as SYSTEM, whose own `%USERPROFILE%` is
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live.
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the default for an operator whose library
/// is on another drive.
/// `…\config\systemprofile` and tells us nothing about where the operator's launchers live. Plus
/// the Steam install root ([`steam_art_roots`]), which is the one launcher that does NOT live under
/// the users base. `PUNKTFUNK_LIBRARY_ART_ROOTS` (`;`-separated) replaces the whole default for an
/// operator whose library is somewhere else again.
fn art_roots() -> Vec<PathBuf> {
if let Some(configured) = std::env::var_os("PUNKTFUNK_LIBRARY_ART_ROOTS") {
return std::env::split_paths(&configured)
@@ -174,6 +175,8 @@ fn art_roots() -> Vec<PathBuf> {
roots.push(PathBuf::from(drive).join("Users"));
}
}
#[cfg(windows)]
roots.extend(steam_art_roots());
// POSIX: the user's home, which is the exact analogue of the Windows users base above — and
// where every launcher this host reads art from actually keeps it. Steam's
// `appcache/librarycache` and `userdata/<id>/config/grid`, Lutris's `coverart`/`banners` (both
@@ -200,6 +203,54 @@ fn art_roots() -> Vec<PathBuf> {
roots
}
/// Windows: every Steam install root that exists on this box.
///
/// Steam is the one launcher whose art is NOT under the users base: it installs to
/// `C:\Program Files (x86)\Steam`, and both places the `steam` library plugin publishes covers from
/// — `appcache\librarycache\<appid>\…` and each account's `userdata\<id>\config\grid\` overrides —
/// live under that root. Without this the users base rejected every one of them, and because an
/// unservable path used to fail the WHOLE reconcile payload the plugin synced NO GAMES AT ALL, not
/// merely no art. That is a v0.28.0 regression: the built-in scanner this plugin replaced served its
/// covers through the legacy `steam:` art-proxy branch, which never passed through this confinement.
/// (POSIX needs no equivalent — every Steam layout there, native and Flatpak, is already under
/// `$HOME`.)
///
/// This does not widen what the host can be *tricked* into reading. The confinement exists to close
/// one asymmetry: the host reads as SYSTEM, while the plugin lane that supplies the path is the far
/// weaker LocalService (2026-08-05 review H-2). The Steam directory is readable by LocalService
/// already, so nothing reachable through it is reachable *because* the host is privileged. The
/// extension, regular-file, magic-byte and config-dir gates all still apply on top, so Steam's own
/// `config.vdf` and `ssfn*` credential blobs are not servable from it either.
#[cfg(windows)]
fn steam_art_roots() -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let mut push = |p: PathBuf| {
// `is_dir` before dedup: `%ProgramFiles%` and `%ProgramW6432%` are the same directory on a
// 64-bit host, and the registry commonly repeats whichever of the two Steam sits in.
if p.is_dir() && !out.contains(&p) {
out.push(p);
}
};
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
if let Some(pf) = std::env::var_os(var) {
push(PathBuf::from(pf).join("Steam"));
}
}
// A Steam installed off the default path — a second drive is common — is only discoverable from
// the registry. HKLM and not HKCU, for the same reason the plugin reads HKLM: the host is
// SYSTEM, whose own hive knows nothing about where the operator installed anything.
for key in [r"SOFTWARE\WOW6432Node\Valve\Steam", r"SOFTWARE\Valve\Steam"] {
if let Some(p) = winreg::RegKey::predef(winreg::enums::HKEY_LOCAL_MACHINE)
.open_subkey(key)
.ok()
.and_then(|k| k.get_value::<String, _>("InstallPath").ok())
{
push(PathBuf::from(p));
}
}
out
}
/// Whether `path` resolves inside one of [`art_roots`] and outside the host config dir.
///
/// Canonicalizes first, so a junction/symlink pointing out of the root is resolved before the
@@ -317,6 +368,43 @@ pub fn validate_art_paths(art: &Artwork) -> Result<(), String> {
Ok(())
}
/// Strip every **local-file** art value the proxy would refuse to serve, returning the
/// `(field, value)` pairs dropped. URLs and already-proxied paths are left alone.
///
/// The provider-reconcile counterpart to [`validate_art_paths`]. Both enforce the same invariant —
/// an unservable path never reaches `library.json` — and differ only on what the REST of the payload
/// is worth. An operator writing one custom entry typed that path by hand, so a hard 400 is the
/// feedback they need. A plugin reconciling its whole entry set did not: it publishes hundreds of
/// covers it resolved from disk, and refusing the payload over one of them costs the operator their
/// entire library for that store.
///
/// That is not hypothetical. A default Windows Steam install put every cover outside the art roots,
/// so `PUT /library/provider/steam` 400'd, the plugin could only report `HostRequestError`, and the
/// grid stayed empty with no indication that the games themselves were fine. [`steam_art_roots`]
/// fixes that specific mismatch; this makes the NEXT one cost a cover instead of a library.
///
/// Dropping rather than rewriting is deliberate: `None` is exactly what an entry with no art
/// carries, and every client already renders that.
pub fn sanitize_art_paths(art: &mut Artwork) -> Vec<(&'static str, String)> {
let mut dropped = Vec::new();
for (field, value) in [
("portrait", &mut art.portrait),
("hero", &mut art.hero),
("logo", &mut art.logo),
("header", &mut art.header),
] {
let unservable = value
.as_deref()
.is_some_and(|v| is_local_art_path(v) && !art_path_is_servable(v));
if unservable {
if let Some(v) = value.take() {
dropped.push((field, v));
}
}
}
dropped
}
/// Read a local image file into `(bytes, content-type)` for the art proxy. `None` if it isn't an
/// existing regular file, is empty, exceeds 16 MiB (a cover never approaches that; the cap bounds
/// host memory), resolves outside the allowed art roots ([`art_path_is_confined`]), or does not
@@ -542,15 +630,67 @@ mod tests {
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` is process-global while cargo runs tests as threads, so the
/// tests that repoint it must not overlap — one clearing the variable mid-flight makes the
/// other's temp root stop being a root, which fails as a confinement bug that isn't there.
/// The variables the art roots derive from are process-global while cargo runs tests as threads,
/// so the tests that repoint them must not overlap — one clearing a variable mid-flight makes
/// another's temp root stop being a root, which fails as a confinement bug that isn't there.
/// Poisoning is recovered rather than propagated: a panic in one test should report ITS
/// failure, not cascade into an unrelated `PoisonError`.
static ART_ROOTS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_art_roots() -> std::sync::MutexGuard<'static, ()> {
ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner())
/// Holds `ART_ROOTS_LOCK` and the overrides one test needs, restoring the previous values on
/// drop. **The only place these tests touch the process environment** — which is what keeps the
/// unsafe-hygiene gate's count flat as tests are added, and what makes the restore run on an
/// unwind (the hand-rolled set/restore this replaced leaked its override to every later test
/// whenever an assertion fired between the two halves).
struct ArtRootsEnv {
_lock: std::sync::MutexGuard<'static, ()>,
saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
}
impl ArtRootsEnv {
/// `None` unsets the variable for the test's duration.
fn set(vars: &[(&'static str, Option<&Path>)]) -> Self {
let _lock = ART_ROOTS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut saved = Vec::new();
for (key, value) in vars {
saved.push((*key, std::env::var_os(key)));
// SAFETY: `_lock` is held for this guard's whole lifetime, and this type is the
// only writer of these variables in the binary — so no other thread is reading
// them while they change.
unsafe { write_env(key, value.map(|p| p.as_os_str())) };
}
Self { _lock, saved }
}
}
impl Drop for ArtRootsEnv {
fn drop(&mut self) {
for (key, value) in &self.saved {
// SAFETY: still under `_lock`, which outlives this loop — same argument as `set`.
unsafe { write_env(key, value.as_deref()) };
}
}
}
/// The single write point, so the hygiene gate has exactly one pair of call sites to judge.
///
/// # Safety
/// The caller must hold `ART_ROOTS_LOCK`; the process environment is global and unsound to
/// mutate while another thread reads it.
unsafe fn write_env(key: &str, value: Option<&std::ffi::OsStr>) {
match value {
// SAFETY: the caller holds `ART_ROOTS_LOCK` (this function's documented contract), and
// `ArtRootsEnv` is the only writer in the binary — so no other thread is reading the
// environment while it changes.
Some(v) => unsafe { std::env::set_var(key, v) },
// SAFETY: as above — the caller's lock is what makes this sound.
None => unsafe { std::env::remove_var(key) },
}
}
/// `PUNKTFUNK_LIBRARY_ART_ROOTS` pointed at one directory — what most of these tests want.
fn confine_art_to(dir: &Path) -> ArtRootsEnv {
ArtRootsEnv::set(&[("PUNKTFUNK_LIBRARY_ART_ROOTS", Some(dir))])
}
/// The art proxy reads bytes in the HOST process (LocalSystem on Windows) from a path the
@@ -558,15 +698,12 @@ mod tests {
/// (2026-08-05 review H-2). Confinement, extension, and content are all load-bearing.
#[test]
fn local_art_bytes_is_confined_and_image_only() {
let _guard = lock_art_roots();
let dir = std::env::temp_dir().join(format!("pf-art-test-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// Confine the proxy to `dir` for the duration of this test.
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
// that writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
let _env = confine_art_to(&dir);
// A real image inside the root: served, with the content type SNIFFED from the bytes.
let cover = dir.join("cover.png");
@@ -642,8 +779,6 @@ mod tests {
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
@@ -706,14 +841,11 @@ mod tests {
/// readable together is the point: either alone passes with the bug present.
#[test]
fn file_url_art_is_accepted_at_write_time_exactly_as_at_read_time() {
let _guard = lock_art_roots();
let dir = std::env::temp_dir().join(format!("pf-art-wr-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("pf-art-wr-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
// that writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
let _env = confine_art_to(&dir);
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
@@ -765,12 +897,149 @@ mod tests {
"an out-of-root file:// cover is still refused"
);
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
/// A reconcile keeps its entries when a cover is unservable — it drops the cover.
///
/// Regression for the report that opened this: on a default Windows Steam install every
/// `appcache\librarycache` path fell outside the users base, `validate_art_paths` refused the
/// whole `PUT /library/provider/steam` payload, and the operator's grid stayed EMPTY. The games
/// were never the problem. Asserting the survivors matters as much as the drop: a sanitizer that
/// cleared the whole struct would also "pass" a drop-only test.
#[test]
fn sanitize_drops_only_the_unservable_local_art() {
let dir = std::env::temp_dir().join(format!("pf-art-san-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let _env = confine_art_to(&dir);
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
let cover_url = file_url(&cover);
let outside = if cfg!(windows) {
r"C:\Program Files (x86)\Steam\appcache\librarycache\570\a\library_hero.jpg".to_string()
} else {
"/opt/steam/appcache/librarycache/570/a/library_hero.jpg".to_string()
};
let mut art = Artwork {
portrait: Some(cover_url.clone()),
hero: Some(outside.clone()),
logo: Some("https://cdn/l.png".into()),
header: Some("/api/v1/library/art/steam:570/header".into()),
};
let dropped = sanitize_art_paths(&mut art);
assert_eq!(
dropped,
vec![("hero", outside)],
"only the out-of-root local path is dropped, and it is reported"
);
assert!(art.hero.is_none(), "the unservable value is gone, not kept");
// A servable local cover, a remote URL and an already-proxied path all survive untouched —
// the entry still renders everything it legitimately can.
assert_eq!(art.portrait.as_deref(), Some(cover_url.as_str()));
assert_eq!(art.logo.as_deref(), Some("https://cdn/l.png"));
assert_eq!(
art.header.as_deref(),
Some("/api/v1/library/art/steam:570/header")
);
// Idempotent: what survived one pass survives the next, and nothing new is reported.
assert!(sanitize_art_paths(&mut art).is_empty());
// The invariant the hard 400 used to hold is still held — nothing the write gate would
// refuse comes out the other side.
assert!(validate_art_paths(&art).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
/// Windows only, and the actual bug report: a Steam cover under Program Files is servable with
/// NO `PUNKTFUNK_LIBRARY_ART_ROOTS` set.
///
/// Drives the whole chain the `steam` plugin's payload traverses — Program Files probe →
/// [`steam_art_roots`] → [`art_roots`] → confinement → [`art_path_is_servable`] →
/// [`local_art_bytes`] — against a synthetic Steam tree, by repointing `%ProgramFiles(x86)%` at
/// a temp dir. Hermetic on purpose: asserting over whatever Steam this box happens to have would
/// pass vacuously on every CI runner, which is exactly the shape of test that let this ship.
#[cfg(windows)]
#[test]
fn steam_librarycache_cover_is_servable_without_configuration() {
let base = std::env::temp_dir().join(format!("pf-art-steam-{}", std::process::id()));
// `appcache\librarycache\<appid>\<hash>\library_hero.jpg` — the exact shape the plugin
// publishes, and the exact field the reported failure named.
let hero = base
.join("Steam")
.join("appcache")
.join("librarycache")
.join("570")
.join("abcdef")
.join("library_hero.jpg");
std::fs::create_dir_all(hero.parent().unwrap()).unwrap();
std::fs::write(&hero, PNG).unwrap();
// No configured roots (that is the claim under test), and the Program Files probe pointed
// at the synthetic tree. Both restored on drop — `%ProgramFiles(x86)%` is a real variable
// on this box that later tests in the same process may legitimately read.
let _env = ArtRootsEnv::set(&[
("PUNKTFUNK_LIBRARY_ART_ROOTS", None),
("ProgramFiles(x86)", Some(&base)),
]);
let steam_root = base.join("Steam");
assert!(
steam_art_roots().contains(&steam_root),
"the Program Files probe must find the Steam install"
);
assert!(
art_roots().contains(&steam_root),
"the DEFAULT art roots must include it — the whole point is that no env var is needed"
);
// The plugin sends `file://`, so that is what has to be accepted; before the fix this was
// false and `validate_art_paths` 400'd the entire reconcile.
let url = file_url(&hero);
assert!(art_path_is_servable(&url), "{url} must be servable");
assert!(
validate_art_paths(&Artwork {
hero: Some(url.clone()),
..Default::default()
})
.is_ok(),
"a Steam-shaped payload must reconcile"
);
assert!(
sanitize_art_paths(&mut Artwork {
hero: Some(url.clone()),
..Default::default()
})
.is_empty(),
"and nothing about it is dropped"
);
assert_eq!(
local_art_bytes(&url).expect("read time serves it too").0,
PNG
);
// The confinement did not go slack on the way: a secret next door is still not servable,
// and neither is a non-image that merely wears the extension.
let secret = base.join("Steam").join("config").join("config.vdf");
std::fs::create_dir_all(secret.parent().unwrap()).unwrap();
std::fs::write(&secret, b"\"Accounts\"\n{\n\"user\" \"token\"\n}\n").unwrap();
assert!(
local_art_bytes(secret.to_str().unwrap()).is_none(),
"Steam's own credential blob must not be servable from an art root"
);
let disguised = base.join("Steam").join("config.png");
std::fs::write(&disguised, b"\"Accounts\" { \"user\" \"token\" }").unwrap();
assert!(
local_art_bytes(disguised.to_str().unwrap()).is_none(),
"an image extension is still not enough — the bytes must BE an image"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn sniff_image_type_recognizes_containers_and_rejects_secrets() {
assert_eq!(sniff_image_type(PNG), Some("image/png"));
+56 -7
View File
@@ -9,6 +9,10 @@ use axum::Extension;
/// Refuse a write whose payload carries an operator-privileged field to a lane that may not set one
/// (2026-08-05 review H-1), and refuse any local art path the proxy would not serve back (H-2).
///
/// The **single-entry writes** — the operator creating or editing one custom entry. The provider
/// reconcile takes [`check_privileged_fields`] and sanitizes art instead; the split is the whole
/// point, and [`crate::library::sanitize_art_paths`] carries the reasoning.
///
/// Both checks belong here rather than in the route gate: `PUT /library/provider/{p}` is a route a
/// provider plugin must be able to call — reconciling its own entry set is the whole point of a
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
@@ -22,14 +26,32 @@ use axum::Extension;
/// `reason` is the caller's log line. It exists because these are TWO different refusals — an
/// operator-privileged field (403) and an unservable art path (400) — and logging both as "carries
/// a field this lane may not set" sent the Lutris/Steam `file://` art rejection looking like an
/// auth problem. The plugin only ever sees `HostRequestError`, so this log line is the sole
/// diagnosis surface for whoever has to explain why a scanner syncs nothing.
/// auth problem.
fn check_entry_fields(
lane: AuthLane,
art: &crate::library::Artwork,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
icon: Option<&str>,
) -> Option<(String, Response)> {
check_privileged_fields(lane, launch, prep, icon).or_else(|| {
crate::library::validate_art_paths(art)
.err()
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
})
}
/// The half of [`check_entry_fields`] that is about *authority* rather than about art: an
/// operator-privileged field this lane may not set (403), or an unrepresentable icon token (400).
///
/// Split out for the provider reconcile, which must apply exactly these two and NOT the art check —
/// it sanitizes unservable covers instead of refusing the payload
/// ([`crate::library::sanitize_art_paths`] explains why the two callers want different answers).
fn check_privileged_fields(
lane: AuthLane,
launch: Option<&crate::library::LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
icon: Option<&str>,
) -> Option<(String, Response)> {
if !lane.may_set_privileged_fields() {
if let Some(field) = crate::library::privileged_field(launch, prep) {
@@ -55,9 +77,7 @@ fn check_entry_fields(
if let Err(e) = crate::library::validate_icon(icon) {
return Some((e.clone(), api_error(StatusCode::BAD_REQUEST, &e)));
}
crate::library::validate_art_paths(art)
.err()
.map(|e| (e.clone(), api_error(StatusCode::BAD_REQUEST, &e)))
None
}
#[derive(Deserialize)]
@@ -468,7 +488,7 @@ pub(crate) async fn reconcile_provider_entries(
Extension(lane): Extension<AuthLane>,
Path(provider): Path<String>,
Query(q): Query<ReconcileQuery>,
ApiJson(inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
ApiJson(mut inputs): ApiJson<Vec<crate::library::ProviderEntryInput>>,
) -> Response {
if let Err(e) = crate::library::validate_provider_name(&provider) {
return api_error(StatusCode::BAD_REQUEST, &e);
@@ -484,9 +504,15 @@ pub(crate) async fn reconcile_provider_entries(
}
// Every entry in the payload, not just the first — a reconcile replaces a whole entry set, so
// one privileged field anywhere in it is one command execution.
//
// Art is deliberately NOT part of this refusal. A privileged field is the plugin overreaching
// and must fail the write; an unservable cover is a path mismatch between where a launcher keeps
// its art and where the host is allowed to read, and failing the payload over one of those threw
// away a working library to save a thumbnail. Those covers are stripped below instead, which
// holds the same "no unservable path is ever persisted" invariant.
for (i, e) in inputs.iter().enumerate() {
if let Some((reason, denied)) =
check_entry_fields(lane, &e.art, e.launch.as_ref(), &e.prep, e.icon.as_deref())
check_privileged_fields(lane, e.launch.as_ref(), &e.prep, e.icon.as_deref())
{
tracing::warn!(
provider,
@@ -498,6 +524,29 @@ pub(crate) async fn reconcile_provider_entries(
return denied;
}
}
// One aggregated line, not one per entry: a root mismatch misses EVERY cover in the payload, and
// a per-entry warn would bury the rest of the log under a thousand copies of one fact.
let mut dropped_art = 0usize;
let mut first_dropped: Option<(String, &'static str, String)> = None;
for e in inputs.iter_mut() {
for (field, value) in crate::library::sanitize_art_paths(&mut e.art) {
dropped_art += 1;
first_dropped.get_or_insert_with(|| (e.title.clone(), field, value));
}
}
if let Some((title, field, path)) = first_dropped {
tracing::warn!(
provider,
dropped = dropped_art,
example_title = %title,
example_field = field,
example_path = %path,
"library reconcile: dropped local art the proxy may not serve — these entries still \
sync, but their covers will be blank. The path must be an image file (jpg/png/webp/\
gif/bmp/ico/tga) inside an allowed art root; set PUNKTFUNK_LIBRARY_ART_ROOTS if this \
library's art lives outside the defaults"
);
}
match crate::library::reconcile_provider(&provider, store.as_deref(), inputs) {
Ok(crate::library::MutateOutcome::Done(entries)) => {
tracing::info!(
+1 -1
View File
@@ -218,7 +218,7 @@ it — leave it or delete it, it makes no difference.
| `PUNKTFUNK_PLUGIN_TOKEN` | token | The scoped token the [plugin/scripting runner](/docs/plugins) uses — a narrower credential than `PUNKTFUNK_MGMT_TOKEN`, never full admin. Same precedence: if unset it's generated and persisted to `~/.config/punktfunk/plugin-token`. Set only to pin a specific token. |
| `PUNKTFUNK_CONFIG_DIR` | path | Override the config directory (default `~/.config/punktfunk`) — pairing state, certs, apps.json, captures. |
| `PUNKTFUNK_UI_PLUGIN_PORT` | port *(default: console port + 1)* | The separate port [plugin](/docs/plugins) UIs are served from. They get their own origin on purpose — a plugin page can never act as *you* on the console. If the console log says this port couldn't be opened (plugin UIs then stay disabled rather than sharing the console's origin), point it at a free port and restart. |
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, `;`-separated | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots (your home directory on Linux/macOS); set it when box art lives elsewhere — a second drive, a network mount. The host log's "not under an allowed art root" line is this knob's cue. |
| `PUNKTFUNK_LIBRARY_ART_ROOTS` | directories, separated like `PATH` (`;` on Windows, `:` on Linux/macOS) | Where the host is allowed to read game artwork from when serving your library. Defaults to sensible platform roots: your home directory on Linux/macOS, and on Windows the users base (`C:\Users`) plus your Steam install, wherever it is. Set it when box art lives somewhere else again — a second drive, a network mount, or a launcher installed outside all of those. Setting it **replaces** the defaults, so list every root you need. The host log's "dropped local art the proxy may not serve" line is this knob's cue: those entries still appear in your library, but their covers stay blank until the root is allowed. |
## Updates
+1 -1
View File
@@ -182,7 +182,7 @@ crates/pf-vkdecode/tests/gpu_parity.rs:5
crates/pf-win-display/src/win_display.rs:2
crates/punktfunk-core/src/quic/endpoint.rs:2
crates/punktfunk-host/src/identity.rs:3
crates/punktfunk-host/src/library/art.rs:4
crates/punktfunk-host/src/library/art.rs:2
crates/punktfunk-host/src/mgmt/tests.rs:3
crates/punktfunk-host/src/native.rs:4
crates/punktfunk-host/src/windows/service.rs:1