// Das Rettungs-Abbild (5.5.0, Commander: „korrupte Discs … selbst // reparieren, mit 0 füllen") — Rippys eigener Sektor-Leser, wie beim // ISO-Backup (iso.ts), aber mit Wiederholung in immer kleineren Stücken und // Nullen für das, was die Disc nicht mehr hergibt: // // 1 MiB am Stück → scheitert: 64-KiB-Stücke, je zwei Versuche → // scheitert: einzelne 2048-Byte-Sektoren, je zwei Versuche → Nullen. // // Das Ergebnis ist ein Abbild ohne Loch, das MakeMKV als `iso:` lesen kann // (Manpage makemkvcon; MakeMKV-FAQ „decrypted backup"): Ein genullter // Sektor ist für MakeMKV kein Lesefehler mehr, an dem es hängen bleibt — // nur eine Bildstörung von Sekundenbruchteilen an dieser Stelle. Was // wirklich unlesbar ist, holt niemand zurück; aber der Rest der Disc geht // nicht mehr verloren. Läuft NUR, wenn Rippy vorher Lesefehler gesehen // hat (Defekt-Wächter, Lesefehler-Meldung) — eine heile Disc braucht das // nicht (Commander 01.09.2026). // // PUR bis auf die injizierten Lese-/Schreibfunktionen: Die Ampel prüft die // Wiederholungs- und Nullfüll-Logik mit einem nachgebauten Gerät. import { open } from 'node:fs/promises' import { writeFileSync } from 'node:fs' export const BLOCK_BYTES = 1024 * 1024 export const FEIN_BYTES = 64 * 1024 export const SEKTOR_BYTES = 2048 export interface UnlesbareStelle { offset: number bytes: number } export type RettungsErgebnis = | { status: 'success'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] } | { status: 'cancelled'; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] } | { status: 'error'; fehler: string; bytes: number; unlesbareSektoren: number; stellen: UnlesbareStelle[] } /** Liest `laenge` Bytes ab `position` in `puffer` (ab 0) und liefert, wie * viele es wurden. Wirft bei einem Lesefehler. */ export type Leser = (puffer: Buffer, laenge: number, position: number) => Promise export type Schreiber = (puffer: Buffer, laenge: number) => Promise export interface RettungsOptionen { leser: Leser schreiber: Schreiber erwarteteBytes: number aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void abbruch?: AbortSignal /** Versuche je Stück — Vorgabe 2. */ versuche?: number } async function lesenMitVersuchen(leser: Leser, puffer: Buffer, laenge: number, position: number, versuche: number): Promise { for (let i = 0; i < versuche; i++) { try { const n = await leser(puffer, laenge, position) if (n === laenge) return true } catch { // Ein Fehler ist ein Versuch weniger — mehr sagt das Gerät nicht. } } return false } /** Zusammenhängende unlesbare Bereiche zu Stellen zusammenfassen (pur). */ export function stelleHinzu(stellen: UnlesbareStelle[], offset: number, bytes: number): void { const letzte = stellen[stellen.length - 1] if (letzte !== undefined && letzte.offset + letzte.bytes === offset) { letzte.bytes += bytes return } stellen.push({ offset, bytes }) } export async function rettungsAbbild(o: RettungsOptionen): Promise { const versuche = o.versuche ?? 2 const puffer = Buffer.alloc(BLOCK_BYTES) const feinPuffer = Buffer.alloc(FEIN_BYTES) const sektorPuffer = Buffer.alloc(SEKTOR_BYTES) const stellen: UnlesbareStelle[] = [] let unlesbareSektoren = 0 let gelesen = 0 let letzterProzent = -1 const melden = (): void => { const prozent = o.erwarteteBytes > 0 ? Math.floor((gelesen * 100) / o.erwarteteBytes) : 0 if (prozent !== letzterProzent) { letzterProzent = prozent o.aufFortschritt?.(prozent, unlesbareSektoren, stellen.length) } } if (o.erwarteteBytes <= 0) { return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein Abbild.', bytes: 0, unlesbareSektoren, stellen } } try { while (gelesen < o.erwarteteBytes) { if (o.abbruch?.aborted === true) return { status: 'cancelled', bytes: gelesen, unlesbareSektoren, stellen } const n = Math.min(BLOCK_BYTES, o.erwarteteBytes - gelesen) if (!(await lesenMitVersuchen(o.leser, puffer, n, gelesen, 1))) { // Der Block klemmt — feiner lesen, nur das wirklich Kaputte nullen. for (let teil = 0; teil < n; teil += FEIN_BYTES) { const m = Math.min(FEIN_BYTES, n - teil) if (await lesenMitVersuchen(o.leser, feinPuffer, m, gelesen + teil, versuche)) { feinPuffer.copy(puffer, teil, 0, m) continue } for (let s = 0; s < m; s += SEKTOR_BYTES) { const k = Math.min(SEKTOR_BYTES, m - s) if (await lesenMitVersuchen(o.leser, sektorPuffer, k, gelesen + teil + s, versuche)) { sektorPuffer.copy(puffer, teil + s, 0, k) } else { puffer.fill(0, teil + s, teil + s + k) unlesbareSektoren += Math.ceil(k / SEKTOR_BYTES) stelleHinzu(stellen, gelesen + teil + s, k) } } } } await o.schreiber(puffer, n) gelesen += n melden() } } catch (fehler) { return { status: 'error', fehler: `Schreiben brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen, unlesbareSektoren, stellen } } return { status: 'success', bytes: gelesen, unlesbareSektoren, stellen } } /** Der Bericht neben dem Abbild — Klartext für den Commander. */ export function rettungsBericht(quelle: string, ergebnis: RettungsErgebnis, erwarteteBytes: number): string { const zeilen = [ `Rettungs-Abbild von ${quelle}`, `Stand: ${ergebnis.status === 'success' ? 'vollständig' : ergebnis.status === 'cancelled' ? 'abgebrochen' : 'Fehler'}`, `Gelesen: ${ergebnis.bytes} von ${erwarteteBytes} Bytes`, `Unlesbare Sektoren: ${ergebnis.unlesbareSektoren} (${((ergebnis.unlesbareSektoren * SEKTOR_BYTES * 100) / Math.max(1, erwarteteBytes)).toFixed(4)} %) an ${ergebnis.stellen.length} Stellen`, ] if (ergebnis.status === 'error') zeilen.push(`Fehler: ${ergebnis.fehler}`) for (const s of ergebnis.stellen.slice(0, 200)) zeilen.push(` ab Byte ${s.offset}: ${s.bytes} Bytes genullt`) if (ergebnis.stellen.length > 200) zeilen.push(` … und ${ergebnis.stellen.length - 200} weitere Stellen`) return zeilen.join('\n') + '\n' } /** Vom Gerät in eine Datei — mit Bericht daneben (.rettung.txt). */ export async function rettungsAbbildDatei( geraetepfad: string, zielDatei: string, erwarteteBytes: number, aufFortschritt?: (prozent: number, unlesbareSektoren: number, stellen: number) => void, abbruch?: AbortSignal, ): Promise { let quelle try { quelle = await open(geraetepfad, 'r') } catch (fehler) { return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] } } let ziel try { ziel = await open(zielDatei, 'w') } catch (fehler) { await quelle.close() return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0, unlesbareSektoren: 0, stellen: [] } } const q = quelle const z = ziel let ergebnis: RettungsErgebnis try { ergebnis = await rettungsAbbild({ leser: async (puffer, laenge, position) => (await q.read(puffer, 0, laenge, position)).bytesRead, schreiber: async (puffer, laenge) => { await z.write(puffer, 0, laenge) }, erwarteteBytes, aufFortschritt, abbruch, }) } finally { await q.close() await z.close() } try { writeFileSync(`${zielDatei}.rettung.txt`, rettungsBericht(geraetepfad, ergebnis, erwarteteBytes)) } catch { // Der Bericht ist Beigabe — das Abbild zählt. } return ergebnis }