ef0a574a70
Vier Funde aus der Durchsicht, alle auf der VM gemessen.
1. DER PLATTEN-SCHUTZ AUS c065967 WAR WIRKUNGSLOS
_original_aufheben() entschied per os.stat().st_dev, ob umgehaengt oder
kopiert werden muss. Im Worker-Container gemessen - beides gleichzeitig wahr:
st_dev /app/temp = 2050
st_dev /app/media = 2050 → identisch
os.rename(...) → EXDEV, "Invalid cross-device link"
Der Kernel vergleicht bei rename() den MOUNT, nicht das Geraet. /app/temp
(Docker-Volume) und /app/media (Bind-Mount) sind zwei Mounts DERSELBEN
ext4-Partition. Die Pruefung sah "gleiches Dateisystem", uebersprang die
Platzpruefung, und shutil.move kopierte doch - 75 GB bei 37 GB frei. Der
Schutz haette genau den Schaden zugelassen, gegen den er gebaut wurde.
Jetzt wird os.rename VERSUCHT statt vorhergesagt: klappt es, ist es
umgehaengt und fertig; kommt EXDEV, steht die Kopie fest und ERST DANN wird
der Platz geprueft. Das ist keine Vermutung mehr, sondern die Antwort des
Kernels. Vier Tests in test_original_aufheben.py, darunter genau der Fall,
der die Platte fuellte. Die zwei alten Tests in test_medien.py sind dorthin
gewandert - sie taeuschten per gefaelschtem os.stat "verschiedene
Dateisysteme" vor, also genau die Annahme, an der der Schutz scheiterte.
2. DIE FORTSCHRITTSANZEIGE ZEIGTE DEN SCAN, NICHT DEN ENCODE
get_progress_from_line matchte jede Zahl vor einem Prozentzeichen. HandBrake
gibt Prozente aber in drei Phasen aus (Formatstrings aus dem Binary gelesen):
Scanning title %d of %d, preview %d, %.2f %% → laeuft VOR dem
Encode bis 100 %
Encoding: task %d of %d, %.2f %% (%.2f fps, avg → der echte Wert
Encoding: task %d of %d, Searching for start time, ... → Vorlauf
Dazu warf `if progress > 0` im Aufrufer jeden Wert unter 1,00 % weg. Live
beobachtet: Anzeige stand auf 99 %, der Encode bei 1,06 %; sie fiel erst auf
1, als der Encode die 1-%-Marke ueberschritt. Jetzt wird nur die
Encoding-Zeile gelesen, `task N of M` mitgerechnet (sonst springt die
Anzeige bei Zwei-Pass-Presets mitten in der Datei zurueck), und -1 heisst
"keine Angabe" - dasselbe Muster wie bei get_progress_from_prgv.
3. "AUTOMATISCHER AUSWURF" WURDE VON NIEMANDEM GELESEN
Die Einstellung (Standard: ein, "Disc nach erfolgreichem Ripping automatisch
auswerfen") kam in keiner Zeile Backend-Code vor. DVD/Blu-ray warfen deshalb
NIE aus, Audio-CDs IMMER, weil abcde `-x` fest verdrahtet bekam. Jetzt
entscheidet die Einstellung beides: wirf_disc_aus() per CDROMEJECT-ioctl
(fcntl-guarded, der native Windows-Worker laedt das Modul auch) und `-x` nur
noch, wenn gewuenscht.
4. PFAD-PRUEFUNG FIEL AUF PRAEFIX-NAMEN HEREIN
Elf Stellen prueften mit nacktem startswith(MEDIA_ROOT). "/app/media-boese/x"
beginnt mit "/app/media", liegt aber ausserhalb - betroffen waren auch
/browse und /browse/mkdir, wo der Pfad vom Nutzer kommt. Neuer
Zwillings-Helfer unter_wurzel() in api/main.py und worker/tasks.py, alle elf
Stellen umgestellt, Tests in beiden.
Nebenbefund: _zielbasis() benutzte os.path.normpath - unter Windows werden
daraus Backslashes, die MEDIA_ROOT-Pruefung greift nicht mehr, und das
gewaehlte Ziel faellt still auf den Standard zurueck. Genau die Falle, die
_arbeitsverzeichnis() drei Zeilen weiter dokumentiert und mit posixpath
vermeidet. Live war es nie (nur aus rip_disc, das auf Windows verriegelt
ist), jetzt konsistent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
3.9 KiB
Python
97 lines
3.9 KiB
Python
"""Import-Smoke-Test: bricht, wenn main.py kaputte Imports oder Verdrahtung hat.
|
|
|
|
Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst
|
|
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
|
|
Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort,
|
|
wo auch die Ampel läuft.
|
|
"""
|
|
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
if sys.platform == "win32": # pragma: no cover
|
|
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
|
|
|
|
|
|
def test_main_importierbar_und_routen_verdrahtet():
|
|
from main import app
|
|
|
|
routen = {route.path for route in app.routes}
|
|
for pfad in (
|
|
"/health", "/jobs", "/devices", "/logs", "/settings", "/prescan",
|
|
# KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte
|
|
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
|
|
# mehr online nach). Ohne diese Routen ist die Seite im UI tot.
|
|
"/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
|
|
# Der Hauptweg fuer 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
|
|
# unter Linux nie selbst, sie kommen von Hand ueber diesen Endpunkt.
|
|
"/system/keystore",
|
|
):
|
|
assert pfad in routen, f"Route {pfad} fehlt"
|
|
|
|
|
|
def test_dateiname_validierung_blockt_pfad_tricks():
|
|
"""Download-Endpoint: nur nackte Dateinamen — kein .., kein Slash, kein Dotfile."""
|
|
from main import _sicherer_dateiname
|
|
|
|
assert _sicherer_dateiname("film.mkv") is True
|
|
assert _sicherer_dateiname("../../etc/passwd") is False
|
|
assert _sicherer_dateiname("a/b.mkv") is False
|
|
assert _sicherer_dateiname("a\\b.mkv") is False
|
|
assert _sicherer_dateiname(".versteckt") is False
|
|
assert _sicherer_dateiname("") is False
|
|
|
|
|
|
def test_worker_task_name_passt_zum_celery_client():
|
|
"""API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker."""
|
|
import inspect
|
|
|
|
import celery_client
|
|
|
|
quelle = inspect.getsource(celery_client.start_rip)
|
|
assert '"worker.tasks.rip_disc"' in quelle
|
|
|
|
|
|
def test_remount_blockiert_den_api_start_nicht():
|
|
"""Regression (Vorfall 24.07.): ein haengender Netz-Mount (CIFS-Schreibtest kann
|
|
im Kernel haengen, wait_for_response) darf den API-Start NICHT blockieren. remount
|
|
muss als Hintergrund-Task laufen (create_task), nicht direkt awaited werden."""
|
|
import inspect
|
|
|
|
import main
|
|
|
|
quelle = inspect.getsource(main.startup_event)
|
|
assert "create_task(asyncio.to_thread(remount))" in quelle, \
|
|
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
|
|
assert "await asyncio.to_thread(remount)" not in quelle, \
|
|
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
|
|
|
|
|
|
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
|
"""Befund 25.07.2026: In main.py prueften neun Stellen mit nacktem
|
|
startswith(MEDIA_ROOT) — darunter /browse und /browse/mkdir, wo der Pfad
|
|
vom Nutzer kommt. „/app/media-boese/x" beginnt mit „/app/media", liegt
|
|
aber ausserhalb. Zwilling von tasks.unter_wurzel im Worker."""
|
|
from main import unter_wurzel
|
|
|
|
assert unter_wurzel("/app/media", "/app/media") is True
|
|
assert unter_wurzel("/app/media/movies", "/app/media") is True
|
|
assert unter_wurzel("/app/media-boese/x", "/app/media") is False
|
|
assert unter_wurzel("/app/mediaX", "/app/media") is False
|
|
assert unter_wurzel("/etc/passwd", "/app/media") is False
|
|
assert unter_wurzel("", "/app/media") is False
|
|
assert unter_wurzel("/app/media", "") is False
|
|
assert unter_wurzel("/app/media/movies", "/app/media/") is True
|
|
|
|
|
|
def test_sicherer_dateiname_blockt_pfad_tricks():
|
|
from main import _sicherer_dateiname
|
|
|
|
assert _sicherer_dateiname("film.mkv") is True
|
|
assert _sicherer_dateiname("../../etc/passwd") is False
|
|
assert _sicherer_dateiname("a/b.mkv") is False
|
|
assert _sicherer_dateiname("a\b.mkv") is False
|
|
assert _sicherer_dateiname(".versteckt") is False
|
|
assert _sicherer_dateiname("") is False
|