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
+44 -3
View File
@@ -1534,7 +1534,7 @@ def build_vibe_playlists(store, vibes, mirror_root, library_root, limit, now):
continue
entries.append({**dict(row), "mirror": mirror})
write_playlist(directory / f"{vibe['name']}.m3u", entries)
write_playlist(directory / f"{vibe['name']}.m3u", entries, Path(mirror_root))
total += len(entries)
logger.info("playlist %-20s %4d tracks -- by tag", vibe["name"], len(entries))
@@ -1572,7 +1572,40 @@ def mirror_path_for(source, library_root, mirror_root):
return (Path(mirror_root) / relative).with_suffix(MIRROR_SUFFIX)
def write_playlist(path, entries):
def set_ownership(path, uid, gid):
"""Give a path an owner and group. Returns whether anything changed."""
try:
current = path.stat()
if (current.st_uid, current.st_gid) == (uid, gid):
return False
os.chown(path, uid, gid)
except OSError:
# Not permitted unless running as root, which is the case where the
# ownership is already whatever the caller runs as.
return False
return True
def match_ownership(path, reference):
"""Give a path the owner and group of the tree it is joining.
The image runs as root by default, so that a bind mount of any ownership
stays writable. The cost is that everything it writes comes out root-owned,
and a root-owned playlist inside a mirror owned by the apps account is
unreadable to the thing that serves it -- the group bit does not help when
the group is root.
Copying the mirror's own ownership avoids having to be told what it should
be, and is a no-op when the two already agree.
"""
try:
wanted = reference.stat()
except OSError:
return False
return set_ownership(path, wanted.st_uid, wanted.st_gid)
def write_playlist(path, entries, reference=None):
"""Write one extended M3U, atomically.
Paths are relative to the playlist file, so the same playlist works from the
@@ -1584,7 +1617,11 @@ def write_playlist(path, entries):
lines.append(f"#EXTINF:{seconds},{entry['artist']} - {entry['title']}")
lines.append(os.path.relpath(entry["mirror"], path.parent))
fresh = not path.parent.exists()
path.parent.mkdir(parents=True, exist_ok=True)
if fresh and reference is not None:
match_ownership(path.parent, reference)
handle, temporary = tempfile.mkstemp(dir=path.parent, suffix=".m3u.part")
os.close(handle)
temporary = Path(temporary)
@@ -1595,6 +1632,10 @@ def write_playlist(path, entries):
mode = temporary.stat().st_mode
if not mode & GROUP_READ:
temporary.chmod(mode | GROUP_READ)
# Before the rename, so the playlist is never briefly visible owned by
# the wrong account.
if reference is not None:
match_ownership(temporary, reference)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
@@ -1625,7 +1666,7 @@ def build_playlists(store, mirror_root, library_root, limit, now):
missing += 1
continue
entries.append({**dict(row), "mirror": mirror})
write_playlist(directory / f"{name}.m3u", entries)
write_playlist(directory / f"{name}.m3u", entries, Path(mirror_root))
total += len(entries)
logger.info("playlist %-20s %4d tracks -- %s", name, len(entries), description)