fix: give playlists the mirror's ownership rather than root's
Build and publish container / build (pull_request) Successful in 2m40s
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user