fix(library): serve art/load for songs mounted through a library junction (#766)

* fix(library): serve art/load songs mounted through a library junction

A song library mounted through a directory JUNCTION/symlink subfolder (a
library shared across app installs; the desktop app's own mounts) had broken
album art and couldn't load: the scanner's rglob follows the junction and
indexes the songs, but _resolve_dlc_path (via safe_join's .resolve()) followed
the junction to its real target, saw it outside DLC_DIR, and rejected every
song reached through it → 403 on /art, 404 on /art/candidates, broken covers.

- _resolve_dlc_path now uses LEXICAL containment (os.path.normpath, no symlink
  following) so an in-library junction is allowed, while `..` traversal and
  absolute paths are still rejected (the traversal tests pin this).
- safe_join is left STRICT (.resolve()-based) — it is the zip-slip / plugin-
  asset / avatar guard, where following a symlink out IS the defense — but
  gains an explicit NUL guard (on Python 3.13/Windows resolve() no longer
  raises on an embedded NUL, so the byte was leaking through; strictly-more-
  rejection, no effect on the zip-slip contract).

Tests: test_dlc_junction (junction allowed; `..`/absolute/NUL rejected; the
safe_join-stays-strict contrast). Existing traversal/safepath/art-candidates
suites stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): reject Windows drive-letter paths in _resolve_dlc_path

The new test_absolute_path_rejected pins 'C:/Windows/system32/x' → None, but on
POSIX a drive-letter path isn't absolute, so Path(dlc)/'C:/…' becomes the
contained relative dir '<dlc>/C:/…' and slipped through the lexical containment
check (red on the Linux CI). Not an escape, but the traversal contract should
hold cross-platform (a shared library is reached from either OS). Reject a path
that is absolute or drive-qualified in either POSIX or Windows semantics before
the containment check. Legitimate relative/junction paths are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-07-05 00:15:50 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2c1c6f7eac
commit 41e907fa52
3 changed files with 144 additions and 3 deletions
+7
View File
@@ -26,6 +26,13 @@ def safe_join(root: Path, name: str) -> Path | None:
"""
if not name:
return None
# Reject embedded NULs explicitly. This used to ride on `.resolve()`
# raising ValueError, but on Python 3.13 (Windows) resolve() no longer
# raises for an embedded NUL, so the byte would otherwise leak through
# containment. An explicit guard is strictly-more-rejection (no effect on
# the zip-slip / traversal contract).
if "\x00" in name:
return None
safe = name.replace("\\", "/")
try:
root_resolved = root.resolve()