feat(drives): V2-4 (Teil 1) — Windows-Laufwerkstreiber, ohne Hardware geprueft
Ampel / ampel (push) Successful in 45s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 09:45:43 +02:00
co-authored by Claude Opus 5
parent 4337253797
commit b723fee3a2
4 changed files with 805 additions and 0 deletions
+84
View File
@@ -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
+232
View File
@@ -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()
+128
View File
@@ -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() + ":"
+361
View File
@@ -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": "",
}