fix: give playlists the mirror's ownership rather than root's
Build and publish container / build (pull_request) Successful in 2m40s

Playlists were being written owned by root. The image runs as root by default,
deliberately, so that a bind-mounted dataset of any ownership stays writable --
but everything it writes then comes out root-owned, and a root-owned playlist
inside a mirror owned by the apps account is unreadable to whatever serves it.
The group-read bit does not help when the group is also root.

Copy the ownership of the mirror the playlist is being written into. That needs
no configuration, cannot drift from whatever the mirror actually is, and does
nothing at all when the two already agree -- which is the case whenever the
container is run with an explicit user.

Applied to the temporary file before the rename, so a playlist is never briefly
visible owned by the wrong account, and to the _playlists directory when this is
the run that creates it. A chown that is refused is ignored rather than fatal:
that only happens when not running as root, which is precisely the case where
the ownership was already right.
This commit is contained in:
Emma Thorpe
2026-08-24 18:45:25 +01:00
parent 8c4da6e14e
commit a7d16ca0b2
3 changed files with 104 additions and 3 deletions
+53
View File
@@ -1,4 +1,5 @@
import json
import os
import stat
import urllib.error
from pathlib import Path
@@ -1289,3 +1290,55 @@ def test_a_vibe_cannot_both_select_and_exclude_a_tag(tmp_path):
with pytest.raises(ValueError, match="selects on and excludes"):
music_curator.load_vibes(str(path))
def test_matching_ownership_is_a_no_op_when_it_already_agrees(tmp_path):
target = tmp_path / "file"
target.write_text("x")
assert music_curator.match_ownership(target, tmp_path) is False
def test_ownership_failure_is_tolerated(tmp_path):
"""Not permitted unless running as root -- which is exactly the case where
the ownership is already whatever the caller runs as."""
target = tmp_path / "file"
target.write_text("x")
# uid 0 from a non-root test process: refused, and must not raise.
assert music_curator.set_ownership(target, 0, 0) is False
def test_a_playlist_is_chowned_to_match_the_mirror(tmp_path, monkeypatch):
"""The image runs as root, so its output is root-owned, and a root-owned
playlist in an apps-owned mirror is unreadable to whatever serves it."""
api, source, mirror = playlist_library(tmp_path)
store = store_at(tmp_path)
music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=api), store
)
music_curator.match_library(store)
attempted = []
real_stat = music_curator.Path.stat
def pretend_mirror_is_owned_by_568(self, *args, **kwargs):
info = real_stat(self, *args, **kwargs)
if self == mirror:
return os.stat_result(
(info.st_mode, info.st_ino, info.st_dev, info.st_nlink, 568, 568,
info.st_size, int(info.st_atime), int(info.st_mtime), int(info.st_ctime))
)
return info
monkeypatch.setattr(music_curator.Path, "stat", pretend_mirror_is_owned_by_568)
monkeypatch.setattr(
music_curator.os, "chown", lambda p, u, g: attempted.append((str(p), u, g))
)
music_curator.build_playlists(store, mirror, str(source), 100, NOW)
assert attempted, "no ownership was applied"
assert all(tuple(owner) == (568, 568) for _, *owner in attempted)
# The temporary file, before the rename, never the finished playlist.
assert all(path.endswith(".part") or path.endswith("_playlists") for path, *_ in attempted)