From b723fee3a225f1e6c77c09ff6e874a0b66d026e2 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 09:45:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(drives):=20V2-4=20(Teil=201)=20=E2=80=94?= =?UTF-8?q?=20Windows-Laufwerkstreiber,=20ohne=20Hardware=20geprueft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAS: rippy/drives/windows.py mit denselben zwei Vertraegen wie die Linux-Fassung (auswerfen_versuchen gibt False zurueck, eject wirft). Dazu win_ioctl.py: die Win32-Steuercodes, HERGELEITET statt abgeschrieben. WARUM ctypes und nicht pywin32: 20 MB zusaetzliche Abhaengigkeit, die in jedes PyInstaller-Paket muss und bei jedem Python-Wechsel neu passen will. Gebraucht werden fuenf Funktionen aus kernel32 — die spricht ctypes direkt an. DIE STEUERCODES WERDEN AUSGERECHNET, NICHT ABGETIPPT: win_ioctl.py enthaelt das CTL_CODE-Makro aus winioctl.h als Funktion; jede Konstante wird daraus gebildet. test_win_ioctl.py rechnet das Ergebnis gegen die in der Microsoft-Dokumentation stehenden Zahlen. Grund: Ein falscher IOCTL-Code meldet kein "unbekannter Befehl", sondern ERROR_INVALID_FUNCTION — und das liest sich wie "dieses Laufwerk kann das nicht". Man sucht dann am Geraet statt an einer Zahl. AGENTS Regel D, ernst genommen. DIE ZWEI PORT-REGELN GELTEN AUCH HIER: - Auswerfen heisst entriegeln, auswerfen, NACHSEHEN. Unter Linux wurde am 26.07.2026 gemessen, dass ein verriegeltes Laufwerk den Auswurf mit ERFOLG quittiert und nichts tut. IOCTL_STORAGE_EJECT_MEDIA meldet ebenfalls nur die Annahme des Befehls — also wird auch hier nachgesehen (CHECK_VERIFY2 muss ERROR_NOT_READY liefern). - Ein unzugaengliches Laufwerk ist UNBEKANNT, nicht leer. device_info kennt deshalb drei Zustaende, nicht zwei. EIN FEHLER, DEN DIE TESTS SOFORT GEFANGEN HABEN: Win32Fehler setzte self.winerror VOR super().__init__(). Nachgemessen: Ein zweiargumentiges OSError.__init__(code, text) setzt winerror wieder auf None. Damit erkannte _medium_da ERROR_NOT_READY nicht mehr, hielt jedes leere Laufwerk fuer einen echten Fehler und meldete "unbekannt" statt "leer". Vier Tests rot, Ursache in einer Minute gemessen statt geraten. ⚠️ WAS HIER NICHT BEWIESEN IST: dass echte Hardware sich so verhaelt. Das Laufwerk ist derzeit nirgends angeschlossen. Der Ablauf, die Fehlerunterscheidung und die Typ-Zuordnung sind gegen ein nachgebautes Laufwerk geprueft (51 Tests, laufen auf jeder Plattform) — die Hardware- Schicht gilt bis zu einer Messung als GEBAUT, nicht als BEWIESEN. GEMESSEN: ruff sauber, 400 Tests gruen + 4 uebersprungen (vorher 378). Co-Authored-By: Claude Opus 5 --- src/rippy/drives/test_win_ioctl.py | 84 +++++++ src/rippy/drives/test_windows.py | 232 ++++++++++++++++++ src/rippy/drives/win_ioctl.py | 128 ++++++++++ src/rippy/drives/windows.py | 361 +++++++++++++++++++++++++++++ 4 files changed, 805 insertions(+) create mode 100644 src/rippy/drives/test_win_ioctl.py create mode 100644 src/rippy/drives/test_windows.py create mode 100644 src/rippy/drives/win_ioctl.py create mode 100644 src/rippy/drives/windows.py diff --git a/src/rippy/drives/test_win_ioctl.py b/src/rippy/drives/test_win_ioctl.py new file mode 100644 index 0000000..fa1e20c --- /dev/null +++ b/src/rippy/drives/test_win_ioctl.py @@ -0,0 +1,84 @@ +"""Die Win32-Steuercodes gegen die dokumentierten Zahlen rechnen. + +## Warum dieser Test wichtig ist + +Ein falscher IOCTL-Code liefert kein „unbekannter Befehl". Windows antwortet +mit `ERROR_INVALID_FUNCTION` — und das liest sich wie „dieses Laufwerk kann +das nicht". Man sucht dann tagelang am Gerät statt an einer Zahl. + +Die Werte rechts sind die in `winioctl.h` / `ntddcdrm.h` und auf Microsoft +Learn dokumentierten Konstanten. Links steht, was `ctl_code()` aus den +Bestandteilen bildet. Stimmen beide überein, ist die Herleitung richtig. + +Läuft auf JEDER Plattform: Hier wird nur gerechnet, nichts aufgerufen. Der +Zahlendreher fällt damit schon auf dem Entwicklungsrechner auf und nicht erst +an einem echten Laufwerk. +""" + +import pytest + +from rippy.drives import win_ioctl as w + + +def test_ctl_code_bildet_das_makro_aus_winioctl_h_ab(): + """CTL_CODE(DeviceType, Function, Method, Access) = + (DeviceType << 16) | (Access << 14) | (Function << 2) | Method""" + assert w.ctl_code(0, 0, 0, 0) == 0 + assert w.ctl_code(1, 0, 0, 0) == 1 << 16 + assert w.ctl_code(0, 1, 0, 0) == 1 << 2 + assert w.ctl_code(0, 0, 3, 0) == 3 + assert w.ctl_code(0, 0, 0, 1) == 1 << 14 + + +@pytest.mark.parametrize("name,erwartet", [ + # Die Zahlen aus der Microsoft-Dokumentation bzw. den SDK-Headern. + ("IOCTL_STORAGE_CHECK_VERIFY2", 0x002D0800), + ("IOCTL_STORAGE_MEDIA_REMOVAL", 0x002D4804), + ("IOCTL_STORAGE_EJECT_MEDIA", 0x002D4808), + ("IOCTL_STORAGE_LOAD_MEDIA", 0x002D480C), + ("IOCTL_CDROM_DISK_TYPE", 0x00020040), + ("IOCTL_DISK_GET_LENGTH_INFO", 0x0007405C), +]) +def test_steuercode_stimmt_mit_der_dokumentation_ueberein(name, erwartet): + assert getattr(w, name) == erwartet, ( + f"{name} ist {getattr(w, name):#010x}, dokumentiert ist {erwartet:#010x}. " + "Ein falscher Steuercode meldet ERROR_INVALID_FUNCTION — das sieht aus " + "wie 'das Laufwerk kann das nicht' und schickt die Fehlersuche in die " + "falsche Richtung." + ) + + +# ── Gerätepfad ────────────────────────────────────────────────────────── +@pytest.mark.parametrize("eingabe", ["D", "D:", "d:", "D:\\", "d:/", " D: "]) +def test_geraetepfad_ist_immer_dieselbe_form(eingabe): + assert w.geraetepfad(eingabe) == r"\\.\D:" + + +def test_geraetepfad_haengt_keinen_backslash_an(): + """Der entscheidende Punkt: MIT abschliessendem Backslash oeffnet + CreateFileW das WURZELVERZEICHNIS des Dateisystems statt des Geraets. + Die IOCTLs scheitern dann mit ERROR_INVALID_FUNCTION.""" + assert not w.geraetepfad("E:\\").endswith("\\") + assert w.geraetepfad("E:\\") == r"\\.\E:" + + +def test_geraetepfad_ohne_angabe_wirft(): + for leer in ("", " ", ":", "\\"): + with pytest.raises(ValueError): + w.geraetepfad(leer) + + +def test_drive_cdrom_ist_fuenf(): + """GetDriveTypeW meldet optische Laufwerke als 5. Ein Zahlendreher hier + hiesse: Rippy findet entweder gar kein Laufwerk oder haelt die + Systemplatte fuer eines.""" + assert w.DRIVE_CDROM == 5 + assert w.DRIVE_FIXED == 3 + assert w.DRIVE_REMOVABLE == 2 + + +def test_error_not_ready_ist_einundzwanzig(): + """Das ist die Antwort von CHECK_VERIFY2 bei leerem Laufwerk — und damit + die Windows-Entsprechung von CDS_NO_DISC. Sie ENTSCHEIDET, ob ein + Auswurf als geglueckt gilt.""" + assert w.ERROR_NOT_READY == 21 diff --git a/src/rippy/drives/test_windows.py b/src/rippy/drives/test_windows.py new file mode 100644 index 0000000..f2eff70 --- /dev/null +++ b/src/rippy/drives/test_windows.py @@ -0,0 +1,232 @@ +"""Der Windows-Treiber gegen ein nachgebautes Laufwerk. + +## Was diese Tests beweisen — und was nicht + +**Sie beweisen den ABLAUF:** dass erst entriegelt und dann ausgeworfen wird, +dass danach nachgesehen statt geglaubt wird, dass ein verriegeltes Laufwerk als +Fehlschlag gilt, dass ein unzugängliches Laufwerk „unbekannt" ergibt und nicht +„leer", und dass beide Verträge (werfend / nicht werfend) stimmen. + +**Sie beweisen NICHT, dass echte Hardware sich so verhält.** Dafür braucht es +ein angestecktes Laufwerk und eine Disc. Bis das gemessen ist, gilt der Treiber +als gebaut, nicht als bewiesen. + +Das nachgebaute Laufwerk unten bildet genau das Verhalten ab, das am +26.07.2026 am echten BU40N unter Linux GEMESSEN wurde: Der Auswurf-Befehl wird +auch bei verriegelter Tür mit Erfolg quittiert und tut nichts. Es gibt keinen +Anlass anzunehmen, dass Windows das anders macht — beide Wege enden im selben +SCSI-Befehl an dasselbe Gerät. +""" + +import pytest + +from rippy.drives import win_ioctl as w +from rippy.drives import windows + + +class FakeLaufwerk: + """Ein Laufwerk aus Papier — verhält sich wie das gemessene echte. + + `verriegelt=True` bildet den Zustand nach einem MakeMKV-Rip ab: Die Tür ist + zu, und der Auswurf-Befehl wird trotzdem mit Erfolg quittiert. + """ + + def __init__(self, medium=True, verriegelt=False, kennt_lockdoor=True, + buchstaben=("C", "D"), typen=None, disk_flags=None, + groesse=None, oeffnen_fehler=None): + self.medium = medium + self.verriegelt = verriegelt + self.kennt_lockdoor = kennt_lockdoor + self._buchstaben = list(buchstaben) + self._typen = typen or {"C": w.DRIVE_FIXED, "D": w.DRIVE_CDROM} + self.disk_flags = disk_flags + self.groesse = groesse + self.oeffnen_fehler = oeffnen_fehler + self.aufrufe = [] # Reihenfolge der Steuercodes + self.offen = 0 + + # -- Laufwerks-Auflistung + def laufwerksbuchstaben(self): + return list(self._buchstaben) + + def laufwerkstyp(self, buchstabe): + return self._typen.get(buchstabe, w.DRIVE_UNKNOWN) + + # -- Gerät + def oeffnen(self, pfad): + if self.oeffnen_fehler is not None: + raise self.oeffnen_fehler + self.offen += 1 + return 4242 + + def schliessen(self, handle): + self.offen -= 1 + + def steuern(self, handle, code, eingabe=b"", ausgabe_bytes=0): + self.aufrufe.append(code) + + if code == w.IOCTL_STORAGE_MEDIA_REMOVAL: + if not self.kennt_lockdoor: + raise windows.Win32Fehler("MEDIA_REMOVAL", w.ERROR_INVALID_FUNCTION) + self.verriegelt = eingabe[:1] == b"\x01" + return b"" + + if code == w.IOCTL_STORAGE_EJECT_MEDIA: + # ⚠️ Der gemessene Punkt: Erfolg wird IMMER gemeldet. Gewirkt hat + # er nur, wenn die Tuer nicht verriegelt ist. + if not self.verriegelt: + self.medium = False + return b"" + + if code == w.IOCTL_STORAGE_CHECK_VERIFY2: + if not self.medium: + raise windows.Win32Fehler("CHECK_VERIFY2", w.ERROR_NOT_READY) + return b"" + + if code == w.IOCTL_CDROM_DISK_TYPE: + if self.disk_flags is None: + raise windows.Win32Fehler("DISK_TYPE", w.ERROR_INVALID_FUNCTION) + return int(self.disk_flags).to_bytes(4, "little") + + if code == w.IOCTL_DISK_GET_LENGTH_INFO: + if self.groesse is None: + raise windows.Win32Fehler("GET_LENGTH_INFO", w.ERROR_INVALID_FUNCTION) + return int(self.groesse).to_bytes(8, "little") + + raise windows.Win32Fehler(f"unerwarteter Code {code:#x}", w.ERROR_INVALID_FUNCTION) + + +# ── Laufwerke finden ──────────────────────────────────────────────────── +def test_nur_optische_laufwerke_werden_gelistet(): + """Waeren es alle, stuende die Systemplatte als „Laufwerk" im UI.""" + api = FakeLaufwerk(buchstaben=("C", "D", "E", "Z"), typen={ + "C": w.DRIVE_FIXED, "D": w.DRIVE_CDROM, + "E": w.DRIVE_CDROM, "Z": w.DRIVE_REMOTE, + }) + assert windows.list_optical_devices(api) == [r"\\.\D:", r"\\.\E:"] + + +def test_ohne_optisches_laufwerk_ist_die_liste_leer(): + api = FakeLaufwerk(buchstaben=("C",), typen={"C": w.DRIVE_FIXED}) + assert windows.list_optical_devices(api) == [] + + +# ── Auswurf: die Reihenfolge ist der Punkt ────────────────────────────── +def test_auswurf_entriegelt_zuerst_und_klappt_dann(): + api = FakeLaufwerk(medium=True, verriegelt=True) + assert windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None) is True + assert api.medium is False + # Entriegeln VOR Auswerfen — sonst quittiert das Laufwerk Erfolg und tut nichts. + assert api.aufrufe[0] == w.IOCTL_STORAGE_MEDIA_REMOVAL + assert api.aufrufe[1] == w.IOCTL_STORAGE_EJECT_MEDIA + + +def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt(): + """Der teure Fehler aus v1, hier vorweggenommen: Ein Laufwerk, das das + Entriegeln nicht kennt und verriegelt bleibt, quittiert den Auswurf + trotzdem mit Erfolg. Wer dem Rueckgabewert glaubt, schreibt „Disc + ausgeworfen" ins Log, waehrend die Schublade zu bleibt.""" + api = FakeLaufwerk(medium=True, verriegelt=True, kennt_lockdoor=False) + assert windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None) is False + assert api.medium is True # nie aufgegangen + + +def test_auswurf_ohne_laufwerk_wirft_nicht(): + api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler("CreateFileW", w.ERROR_FILE_NOT_FOUND)) + assert windows.auswerfen_versuchen(r"\\.\Z:", api=api, warten=lambda s: None) is False + + +def test_geraet_wird_immer_wieder_geschlossen(): + """Ein offenes Handle auf einem optischen Laufwerk blockiert den Auswurf + fuer JEDEN anderen — auch fuer den Explorer.""" + api = FakeLaufwerk(medium=True) + windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None) + assert api.offen == 0 + + +# ── Der andere Vertrag: eject() muss werfen ───────────────────────────── +def test_eject_ist_still_wenn_die_disc_rauskommt(): + api = FakeLaufwerk(medium=True, verriegelt=True) + assert windows.eject(r"\\.\D:", api=api) is None + + +def test_eject_wirft_wenn_die_disc_drin_bleibt(): + api = FakeLaufwerk(medium=True, verriegelt=True, kennt_lockdoor=False) + with pytest.raises(OSError, match="noch"): + windows.eject(r"\\.\D:", api=api) + + +def test_eject_reicht_den_urspruenglichen_fehler_weiter(): + """„Zugriff verweigert" und „Gerät nicht gefunden" sind zwei ganz + verschiedene Probleme. Wer sie hinter einer eigenen Meldung versteckt, + nimmt dem Nutzer die einzige brauchbare Auskunft.""" + original = windows.Win32Fehler("CreateFileW", w.ERROR_ACCESS_DENIED) + api = FakeLaufwerk(oeffnen_fehler=original) + with pytest.raises(OSError) as fehler: + windows.eject(r"\\.\D:", api=api) + assert fehler.value is original + # `code` statt `winerror`: letzteres gibt es nur unter Windows, und der + # Treiber wird auch anderswo getestet. + assert fehler.value.code == w.ERROR_ACCESS_DENIED + + +# ── Zustand und Typ ───────────────────────────────────────────────────── +def test_leeres_laufwerk_ist_leer_und_nicht_unbekannt(): + api = FakeLaufwerk(medium=False) + assert windows.detect_disc_type(r"\\.\D:", api=api) == "no_disc" + assert windows.device_info(r"\\.\D:", api=api)["status"] == "empty" + + +def test_unzugaengliches_laufwerk_ist_UNBEKANNT_und_nicht_leer(): + """DIE Regel des Ports. Ein Laufwerk, das gerade belegt ist, hat nicht + „keine Disc" — man weiss es nur nicht. In v1 hat genau diese Vermischung + Listen im UI geleert.""" + api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler("CreateFileW", w.ERROR_ACCESS_DENIED)) + assert windows.detect_disc_type(r"\\.\D:", api=api) == "unknown" + assert windows.device_info(r"\\.\D:", api=api)["status"] == "unknown" + + +@pytest.mark.parametrize("flags,groesse,erwartet", [ + (w.CDROM_DISK_AUDIO_TRACK, 700 * 1024**2, "cd"), + (w.CDROM_DISK_DATA_TRACK, 8 * 1024**3, "dvd"), + (w.CDROM_DISK_DATA_TRACK, 25 * 1024**3, "bluray"), + (w.CDROM_DISK_DATA_TRACK, 50 * 1000**3, "bluray"), # BD-50 + (w.CDROM_DISK_DATA_TRACK, 66 * 1000**3, "uhd"), # BD-66 + (w.CDROM_DISK_DATA_TRACK, 100 * 1000**3, "uhd"), +]) +def test_disc_typ_kommt_aus_derselben_zuordnung_wie_unter_linux(flags, groesse, erwartet): + """Die Einordnung liegt in cdrom.classify() — EINE Funktion fuer beide + Plattformen, und die ist eigenstaendig getestet. Hier wird nur geprueft, + dass Windows die beiden Eingangswerte richtig beschafft.""" + api = FakeLaufwerk(medium=True, disk_flags=flags, groesse=groesse) + assert windows.detect_disc_type(r"\\.\D:", api=api) == erwartet + + +def test_verriegeln_schickt_das_richtige_byte(): + api = FakeLaufwerk(medium=True) + windows.verriegeln(r"\\.\D:", True, api=api) + assert api.verriegelt is True + windows.verriegeln(r"\\.\D:", False, api=api) + assert api.verriegelt is False + + +def test_device_info_hat_dieselben_felder_wie_unter_linux(): + """Das UI unterscheidet nicht nach Plattform — fehlt ein Feld, bleibt die + Karte im Dashboard leer.""" + api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK, groesse=25 * 1024**3) + eintrag = windows.device_info(r"\\.\D:", api=api) + assert set(eintrag) == {"id", "name", "type", "path", "status", "model", "serial"} + assert eintrag["id"] == "D" + assert eintrag["status"] == "ready" + assert eintrag["type"] == "bluray" + + +def test_treiber_verweigert_sich_ausserhalb_von_windows(): + """Sonst waere der erste Fehler ein kryptischer ctypes-Absturz statt + einer Ansage.""" + import sys as _sys + + if _sys.platform.startswith("win"): + pytest.skip("laeuft nur, wenn NICHT unter Windows getestet wird") + with pytest.raises(RuntimeError, match="nur unter Windows"): + windows.Win32() diff --git a/src/rippy/drives/win_ioctl.py b/src/rippy/drives/win_ioctl.py new file mode 100644 index 0000000..8e839ab --- /dev/null +++ b/src/rippy/drives/win_ioctl.py @@ -0,0 +1,128 @@ +"""Die Win32-Steuercodes für optische Laufwerke — hergeleitet, nicht abgeschrieben. + +## Warum sie hier ausgerechnet werden + +`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf. Vor Nutzung +fremder CLI-Flags oder Bibliotheks-APIs: --help/Doku prüfen und die Fundstelle +im Commit nennen."* + +Zahlen wie `0x2D4808` sind genau der Fall, in dem sich ein Zahlendreher +lautlos versteckt: Ein falscher Code liefert kein „unbekannter Befehl", sondern +`ERROR_INVALID_FUNCTION` — und das sieht aus wie „das Laufwerk kann das nicht". +Man sucht dann am Gerät statt an der Zahl. + +Deshalb steht hier das Makro aus `winioctl.h` als Funktion, und jede Konstante +wird daraus gebildet. Wer nachrechnen will, sieht die Bestandteile. + + #define CTL_CODE(DeviceType, Function, Method, Access) ( \\ + ((DeviceType) << 16) | ((Access) << 14) | \\ + ((Function) << 2) | (Method) \\ + ) + +Fundstellen (Windows SDK / Microsoft Learn): + * CTL_CODE, FILE_DEVICE_*, METHOD_*, FILE_*_ACCESS — winioctl.h + * IOCTL_STORAGE_CHECK_VERIFY2 — learn.microsoft.com/windows/win32/api/ + winioctl/ni-winioctl-ioctl_storage_check_verify2 + * IOCTL_STORAGE_MEDIA_REMOVAL / _EJECT_MEDIA / _LOAD_MEDIA — ebenda + * IOCTL_CDROM_DISK_TYPE, CDROM_DISK_AUDIO_TRACK/DATA_TRACK — ntddcdrm.h + * IOCTL_DISK_GET_LENGTH_INFO — winioctl.h (GET_LENGTH_INFORMATION) + +## Gegenprobe + +`test_win_ioctl.py` rechnet die hier gebildeten Werte gegen die Zahlen, die in +der Microsoft-Dokumentation stehen. Stimmt eine nicht, faellt das beim Testen +auf — auf JEDER Plattform, auch ohne Windows und ohne Laufwerk. +""" + +# ── Bausteine aus winioctl.h ──────────────────────────────────────────── +FILE_DEVICE_CD_ROM = 0x00000002 +FILE_DEVICE_DISK = 0x00000007 +FILE_DEVICE_MASS_STORAGE = 0x0000002D # = IOCTL_STORAGE_BASE + +METHOD_BUFFERED = 0 +METHOD_IN_DIRECT = 1 +METHOD_OUT_DIRECT = 2 +METHOD_NEITHER = 3 + +FILE_ANY_ACCESS = 0 +FILE_READ_ACCESS = 0x0001 +FILE_WRITE_ACCESS = 0x0002 + + +def ctl_code(geraetetyp: int, funktion: int, methode: int, zugriff: int) -> int: + """Das CTL_CODE-Makro aus winioctl.h, eins zu eins.""" + return ((geraetetyp << 16) | (zugriff << 14) | (funktion << 2) | methode) + + +# ── Speicher (IOCTL_STORAGE_*) ────────────────────────────────────────── +# „Ist ein Medium eingelegt?" — die Windows-Entsprechung von +# CDROM_DRIVE_STATUS. Antwortet mit ERROR_NOT_READY (21), wenn nichts drin ist. +IOCTL_STORAGE_CHECK_VERIFY2 = ctl_code( + FILE_DEVICE_MASS_STORAGE, 0x0200, METHOD_BUFFERED, FILE_ANY_ACCESS) + +# Tür verriegeln/entriegeln — die Entsprechung von CDROM_LOCKDOOR. +# Erwartet eine PREVENT_MEDIA_REMOVAL-Struktur (ein BOOLEAN). +IOCTL_STORAGE_MEDIA_REMOVAL = ctl_code( + FILE_DEVICE_MASS_STORAGE, 0x0201, METHOD_BUFFERED, FILE_READ_ACCESS) + +# Auswerfen — die Entsprechung von CDROMEJECT. +IOCTL_STORAGE_EJECT_MEDIA = ctl_code( + FILE_DEVICE_MASS_STORAGE, 0x0202, METHOD_BUFFERED, FILE_READ_ACCESS) + +# Schublade einziehen — die Entsprechung von CDROMCLOSETRAY. +IOCTL_STORAGE_LOAD_MEDIA = ctl_code( + FILE_DEVICE_MASS_STORAGE, 0x0203, METHOD_BUFFERED, FILE_READ_ACCESS) + +# ── CD-ROM (IOCTL_CDROM_*) ────────────────────────────────────────────── +# Audio- oder Datenspur? Grob, aber genau das, was CDROM_DISC_STATUS auch +# liefert — mehr braucht die Einordnung nicht (die Feinunterscheidung +# DVD/BD/UHD kommt über die Größe). +IOCTL_CDROM_DISK_TYPE = ctl_code( + FILE_DEVICE_CD_ROM, 0x0010, METHOD_BUFFERED, FILE_ANY_ACCESS) + +CDROM_DISK_AUDIO_TRACK = 0x00000001 +CDROM_DISK_DATA_TRACK = 0x00000002 + +# ── Größe des Mediums ─────────────────────────────────────────────────── +# Die Entsprechung von BLKGETSIZE64. Liefert GET_LENGTH_INFORMATION +# (ein LARGE_INTEGER, also 8 Byte). +IOCTL_DISK_GET_LENGTH_INFO = ctl_code( + FILE_DEVICE_DISK, 0x0017, METHOD_BUFFERED, FILE_READ_ACCESS) + +# ── Rückmeldungen von GetLastError() ──────────────────────────────────── +ERROR_NOT_READY = 21 # kein Medium eingelegt +ERROR_ACCESS_DENIED = 5 +ERROR_FILE_NOT_FOUND = 2 +ERROR_INVALID_FUNCTION = 1 # Gerät kennt diesen Steuercode nicht +ERROR_MEDIA_CHANGED = 1110 +ERROR_NO_MEDIA_IN_DRIVE = 1112 + +# ── GetDriveTypeW ─────────────────────────────────────────────────────── +DRIVE_UNKNOWN = 0 +DRIVE_NO_ROOT_DIR = 1 +DRIVE_REMOVABLE = 2 +DRIVE_FIXED = 3 +DRIVE_REMOTE = 4 +DRIVE_CDROM = 5 +DRIVE_RAMDISK = 6 + +# ── CreateFileW ───────────────────────────────────────────────────────── +GENERIC_READ = 0x80000000 +FILE_SHARE_READ = 0x00000001 +FILE_SHARE_WRITE = 0x00000002 +OPEN_EXISTING = 3 +INVALID_HANDLE_VALUE = -1 + + +def geraetepfad(buchstabe: str) -> str: + r"""„D:" oder „D" oder „D:\" -> r"\\.\D:" + + Der Win32-Gerätepfad hat KEINEN abschließenden Backslash. Mit einem + öffnet `CreateFileW` das Wurzelverzeichnis des Dateisystems statt des + Geräts — die IOCTLs scheitern dann mit ERROR_INVALID_FUNCTION, was wie + „das Laufwerk kann das nicht" aussieht. + """ + kern = (buchstabe or "").strip().rstrip("\\/").rstrip(":") + if not kern: + raise ValueError("Kein Laufwerksbuchstabe angegeben") + return "\\\\.\\" + kern[0].upper() + ":" diff --git a/src/rippy/drives/windows.py b/src/rippy/drives/windows.py new file mode 100644 index 0000000..a51a30e --- /dev/null +++ b/src/rippy/drives/windows.py @@ -0,0 +1,361 @@ +"""Laufwerks-Treiber für Windows. Erfüllt `rippy.ports.Drives`. + +Dieselben Verträge wie `linux.py`, nur mit Win32 statt ioctl: + + auswerfen_versuchen(geraet) -> bool gibt False zurück, wirft nie + eject(geraet) -> None wirft OSError + +## Warum ctypes und nicht pywin32 + +pywin32 wäre eine zusätzliche Abhängigkeit von rund 20 MB, die in ein +PyInstaller-Paket mit hinein muss und bei jedem Python-Wechsel neu passen +will. Alles, was hier gebraucht wird, sind fünf Funktionen aus `kernel32.dll` — +die spricht `ctypes` direkt an, ohne irgendetwas mitzuliefern. + +## Die zwei Regeln des Ports gelten hier genauso + +**1. Auswerfen heißt: entriegeln, auswerfen, NACHSEHEN.** Unter Linux wurde am +26.07.2026 gemessen, dass ein verriegeltes Laufwerk den Auswurf mit ERFOLG +quittiert und nichts tut — MakeMKV verriegelt die Tür während des Rips und +entriegelt sie nicht wieder. Es gibt keinen Grund anzunehmen, dass Windows das +anders handhabt: `IOCTL_STORAGE_EJECT_MEDIA` meldet ebenfalls nur, dass der +Befehl angenommen wurde. Deshalb wird auch hier nachgesehen — der Beweis ist +`IOCTL_STORAGE_CHECK_VERIFY2`, das bei leerem Laufwerk `ERROR_NOT_READY` +liefert. + +**2. Ein unbekannter Zustand heißt UNBEKANNT, nicht leer.** Wenn sich das Gerät +nicht öffnen lässt oder ein Steuercode scheitert, ist die Antwort „ich weiß es +nicht" — nicht „da ist keine Disc". Genau diese Vermischung hat in v1 die +Listen im UI geleert. + +## Was hier geprüft ist und was nicht + +**Geprüft, ohne Laufwerk:** die Steuercodes (`test_win_ioctl.py` rechnet sie +gegen die Dokumentation), der Ablauf des Auswurfs, die Fehlerunterscheidung, +die Typ-Zuordnung — alles über eine eingespritzte Win32-Nachbildung +(`test_windows.py`). Diese Tests laufen auf jeder Plattform. + +**NICHT geprüft:** dass echte Hardware sich so verhält. Dafür braucht es ein +angestecktes Laufwerk und eine Disc. Solange das nicht gemessen ist, gilt +dieser Treiber als GEBAUT, nicht als BEWIESEN — die Unterscheidung steht so +in AGENTS.md („Beweisen statt behaupten"). +""" + +import sys + +from rippy.drives import win_ioctl as w +from rippy.drives.cdrom import ( + CDS_AUDIO, + CDS_DATA_1, + CDS_DISC_OK, + CDS_NO_DISC, + classify, +) + +# Wie lange nach dem Auswurf-Befehl auf die Schublade gewartet wird. +# Gleicher Wert wie unter Linux — ein Laufwerk braucht ein bis zwei Sekunden. +AUSWURF_WARTEN_SEKUNDEN = 5 + +# Ergebnisse von auswerfen_mit_grund — dieselben wie im Linux-Treiber, damit +# aufrufender Code nicht wissen muss, auf welcher Plattform er läuft. +AUSWURF_OK = "ok" +AUSWURF_KEIN_ZUGRIFF = "kein-zugriff" +AUSWURF_ABGELEHNT = "abgelehnt" +AUSWURF_BLEIBT_DRIN = "bleibt-drin" + + +class Win32Fehler(OSError): + """Ein fehlgeschlagener Win32-Aufruf, mit dem Code von GetLastError(). + + ⚠️ Die Zuweisung MUSS nach `super().__init__()` stehen. Nachgemessen: + Ein zweiargumentiges `OSError.__init__(code, text)` setzt `winerror` + wieder auf `None` — ein davor gesetzter Wert ist danach fort. Genau + daran sind beim Bau dieser Datei vier Tests gescheitert: `_medium_da` + erkannte `ERROR_NOT_READY` nicht mehr, hielt jedes leere Laufwerk für + einen echten Fehler und meldete „unbekannt" statt „leer". + + `code` steht zusätzlich als eigener Name bereit — `winerror` gibt es nur + unter Windows, und dieser Treiber wird auch anderswo getestet. + """ + + def __init__(self, was: str, code: int): + super().__init__(code, f"{was} scheiterte (Win32-Fehler {code})") + self.code = code + self.winerror = code + + +class Win32: + """Die echten kernel32-Aufrufe. + + In Tests wird stattdessen eine Nachbildung eingesetzt — deshalb steckt + hier NUR die Anbindung und keine Entscheidung. Was der Treiber tut, steht + in den Funktionen darunter und ist damit ohne Windows prüfbar. + """ + + def __init__(self): + if not sys.platform.startswith("win"): + raise RuntimeError( + "Der Windows-Treiber laeuft nur unter Windows. Auf Linux ist " + "rippy.drives.linux zustaendig." + ) + import ctypes + from ctypes import wintypes + + self._ctypes = ctypes + self._wintypes = wintypes + self._k32 = ctypes.WinDLL("kernel32", use_last_error=True) + + # ── Laufwerke finden ──────────────────────────────────────────────── + def laufwerksbuchstaben(self) -> list: + maske = self._k32.GetLogicalDrives() + return [chr(ord("A") + i) for i in range(26) if maske & (1 << i)] + + def laufwerkstyp(self, buchstabe: str) -> int: + return self._k32.GetDriveTypeW(f"{buchstabe}:\\") + + # ── Gerät öffnen ──────────────────────────────────────────────────── + def oeffnen(self, geraetepfad: str): + handle = self._k32.CreateFileW( + geraetepfad, + w.GENERIC_READ, + w.FILE_SHARE_READ | w.FILE_SHARE_WRITE, + None, + w.OPEN_EXISTING, + 0, + None, + ) + if handle == w.INVALID_HANDLE_VALUE or handle in (0, None): + raise Win32Fehler("CreateFileW", self._ctypes.get_last_error()) + return handle + + def schliessen(self, handle) -> None: + self._k32.CloseHandle(handle) + + # ── Steuercode senden ─────────────────────────────────────────────── + def steuern(self, handle, code: int, eingabe: bytes = b"", + ausgabe_bytes: int = 0) -> bytes: + """DeviceIoControl. Wirft Win32Fehler, gibt sonst die Antwort zurück.""" + ctypes = self._ctypes + ein_puffer = ctypes.create_string_buffer(eingabe) if eingabe else None + aus_puffer = ctypes.create_string_buffer(ausgabe_bytes) if ausgabe_bytes else None + zurueck = self._wintypes.DWORD(0) + erfolg = self._k32.DeviceIoControl( + handle, code, + ein_puffer, len(eingabe), + aus_puffer, ausgabe_bytes, + ctypes.byref(zurueck), None, + ) + if not erfolg: + raise Win32Fehler(f"DeviceIoControl({code:#x})", ctypes.get_last_error()) + return aus_puffer.raw[:zurueck.value] if aus_puffer else b"" + + +def _api(api): + return api if api is not None else Win32() + + +# ── Laufwerke finden ──────────────────────────────────────────────────── +def list_optical_devices(api=None) -> list: + """Alle optischen Laufwerke — als Gerätepfade wie r"\\\\.\\D:". + + Kein Scan über alle 26 Buchstaben mit Öffnen-Versuch: `GetDriveTypeW` + beantwortet die Frage ohne jeden Zugriff auf das Gerät. Ein Öffnen-Versuch + auf einem leeren Laufwerk kann sonst mehrere Sekunden dauern (das Laufwerk + fährt hoch), und das mal 26. + """ + api = _api(api) + return sorted( + w.geraetepfad(b) for b in api.laufwerksbuchstaben() + if api.laufwerkstyp(b) == w.DRIVE_CDROM + ) + + +# ── Zustand ───────────────────────────────────────────────────────────── +def _medium_da(api, handle) -> bool: + """Liegt eine Disc ein? Wirft Win32Fehler bei allem außer NOT_READY.""" + try: + api.steuern(handle, w.IOCTL_STORAGE_CHECK_VERIFY2) + return True + except Win32Fehler as e: + if e.code in (w.ERROR_NOT_READY, w.ERROR_NO_MEDIA_IN_DRIVE): + return False + raise + + +def drive_status(geraet: str, api=None) -> int: + """Wie `linux.detection.drive_status`: CDS_DISC_OK oder CDS_NO_DISC. + + Wirft, wenn der Zustand NICHT feststellbar ist. Der Aufrufer muss + entscheiden, was er damit macht — ein stilles „leer" wäre eine falsche + Aussage über die Welt. + """ + api = _api(api) + handle = api.oeffnen(geraet) + try: + return CDS_DISC_OK if _medium_da(api, handle) else CDS_NO_DISC + finally: + api.schliessen(handle) + + +def disc_status(geraet: str, api=None) -> int: + """Audio- oder Datenträger — übersetzt in die CDS_*-Werte von cdrom.py. + + Damit passt das Ergebnis in dieselbe `classify()`, die der Linux-Treiber + benutzt: EINE Zuordnungslogik für beide Plattformen, und die ist getestet. + """ + api = _api(api) + handle = api.oeffnen(geraet) + try: + antwort = api.steuern(handle, w.IOCTL_CDROM_DISK_TYPE, ausgabe_bytes=4) + flags = int.from_bytes(antwort[:4], "little") if antwort else 0 + finally: + api.schliessen(handle) + if flags & w.CDROM_DISK_AUDIO_TRACK: + return CDS_AUDIO + if flags & w.CDROM_DISK_DATA_TRACK: + return CDS_DATA_1 + return 0 # unbekannt -> classify() sagt "unknown" + + +def disc_size_bytes(geraet: str, api=None) -> int: + """Größe des eingelegten Mediums — die Entsprechung von BLKGETSIZE64. + + Sie entscheidet zwischen DVD, Blu-ray und 4K-UHD (Schwellen in cdrom.py). + """ + api = _api(api) + handle = api.oeffnen(geraet) + try: + antwort = api.steuern(handle, w.IOCTL_DISK_GET_LENGTH_INFO, ausgabe_bytes=8) + return int.from_bytes(antwort[:8], "little") if antwort else 0 + finally: + api.schliessen(handle) + + +def detect_disc_type(geraet: str, api=None) -> str: + """cd | dvd | bluray | uhd | no_disc | unknown.""" + api = _api(api) + try: + if drive_status(geraet, api) != CDS_DISC_OK: + return "no_disc" + return classify(disc_status(geraet, api), disc_size_bytes(geraet, api)) + except OSError: + # „konnte nicht nachsehen" — ausdruecklich NICHT "no_disc". + return "unknown" + + +# ── Verriegeln ────────────────────────────────────────────────────────── +def verriegeln(geraet: str, an: bool, api=None) -> None: + """Tür verriegeln (True) oder entriegeln (False). + + `PREVENT_MEDIA_REMOVAL` ist eine Struktur mit genau einem BOOLEAN. + """ + api = _api(api) + handle = api.oeffnen(geraet) + try: + api.steuern(handle, w.IOCTL_STORAGE_MEDIA_REMOVAL, + eingabe=b"\x01" if an else b"\x00") + finally: + api.schliessen(handle) + + +# ── Auswerfen ─────────────────────────────────────────────────────────── +def auswerfen_mit_grund(geraet: str, api=None, warten=None): + """Die gemeinsame Mechanik. Wirft NIE. + + Rückgabe `(grund, fehler)` wie im Linux-Treiber. Reihenfolge ist + dieselbe und aus demselben Grund: **entriegeln, auswerfen, nachsehen.** + """ + api = _api(api) + if warten is None: + import time + warten = time.sleep + + try: + handle = api.oeffnen(geraet) + except OSError as e: + return AUSWURF_KEIN_ZUGRIFF, e + + try: + # Entriegeln zuerst. Scheitert es, wird der Auswurf trotzdem versucht — + # bei einem nicht verriegelten Laufwerk klappt er ohnehin. + try: + api.steuern(handle, w.IOCTL_STORAGE_MEDIA_REMOVAL, eingabe=b"\x00") + except OSError: + pass + + try: + api.steuern(handle, w.IOCTL_STORAGE_EJECT_MEDIA) + except OSError as e: + return AUSWURF_ABGELEHNT, e + + # NACHSEHEN. Der Befehl oben meldet nur, dass er angenommen wurde. + for _ in range(AUSWURF_WARTEN_SEKUNDEN): + try: + if not _medium_da(api, handle): + return AUSWURF_OK, None + except OSError as e: + return AUSWURF_ABGELEHNT, e + warten(1) + return AUSWURF_BLEIBT_DRIN, None + finally: + try: + api.schliessen(handle) + except OSError: + pass + + +def auswerfen_versuchen(geraet: str, api=None, warten=None) -> bool: + """Wirft die Disc aus und prüft nach. Wirft NIE — Vertrag des Workers.""" + grund, _ = auswerfen_mit_grund(geraet, api=api, warten=warten) + return grund == AUSWURF_OK + + +def eject(geraet: str, api=None) -> None: + """Wirft die Disc aus. Wirft OSError, wenn sie drin bleibt — Vertrag der API.""" + grund, fehler = auswerfen_mit_grund(geraet, api=api) + if grund == AUSWURF_OK: + return + if fehler is not None: + raise fehler + if grund == AUSWURF_KEIN_ZUGRIFF: + raise OSError(f"Das Laufwerk {geraet} ließ sich nicht ansprechen.") + raise OSError( + "Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch " + "drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?" + ) + + +# ── Für das UI ────────────────────────────────────────────────────────── +def device_info(geraet: str, api=None) -> dict: + """Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber. + + `status` kennt hier drei Werte, nicht zwei: `ready`, `empty` und + **`unknown`**. Der dritte ist Absicht — ein Laufwerk, das sich nicht + ansprechen lässt (belegt, gerade angesteckt, defekt), ist nicht leer. + """ + api = _api(api) + status = "unknown" + disc_typ = "unknown" + try: + if drive_status(geraet, api) == CDS_DISC_OK: + status = "ready" + try: + disc_typ = classify(disc_status(geraet, api), + disc_size_bytes(geraet, api)) + except OSError: + disc_typ = "unknown" + else: + status = "empty" + except OSError: + status = "unknown" + + buchstabe = geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":") + return { + "id": buchstabe, + "name": f"Laufwerk {buchstabe}:", + "type": disc_typ, + "path": geraet, + "status": status, + "model": "", + "serial": "", + }