fix(v5): MakeMKV-Installer aus dem Internet Archive nur nach Rückfrage, mit Größe und SHA-256
WAS: makemkvInstallerHolen fragt vor der Archiv-Quelle über eine Freigabe-Funktion; ohne Freigabe wird das Archiv übersprungen und im Bericht genannt. Der Kern stellt die Frage ans Fenster (beschaffung-status mit `frage`), die MakeMKV-Karte zeigt sie mit „Archiv-Kopie laden" / „Lieber nicht" (beschaffung-antwort); ohne Antwort in zehn Minuten gilt Nein. Die geladene Datei wird mit Größe und SHA-256 gemeldet. WARUM: Durchsicht 12.09.2026, Fund F16 (Commander-Entscheid: Archiv nur nach Rückfrage) — eine unsignierte EXE von fremder Hand, die mit Adminrechten läuft, entscheidet der Nutzer, nicht Rippy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3dacd7cb4f
commit
ae82d8c0fd
@@ -22,6 +22,7 @@ import {
|
||||
} from '../bausteine'
|
||||
import {
|
||||
benachrichtigungTesten,
|
||||
beschaffungAntworten,
|
||||
einstellungSetzen,
|
||||
fremdenOrdnerLoeschen,
|
||||
kompressionAbbrechen,
|
||||
@@ -480,6 +481,19 @@ export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
{b !== null && (
|
||||
<div className="rounded-lg border border-tinte/10 bg-buehne/70 p-2.5">
|
||||
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
|
||||
{b.frage !== undefined && b.frage.length > 0 && (
|
||||
<div className="mt-2 rounded-lg border border-warn/45 bg-warn/10 p-2.5">
|
||||
<p className="text-xs leading-5 text-dunst">{b.frage}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button onClick={() => beschaffungAntworten(true)} className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25">
|
||||
Archiv-Kopie laden
|
||||
</button>
|
||||
<button onClick={() => beschaffungAntworten(false)} className={KNOPF_LEISE}>
|
||||
Lieber nicht
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{b.laeuft && b.anteil !== null && (
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-tinte/8">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
|
||||
|
||||
@@ -381,6 +381,11 @@ export function schluesselPruefen(): void {
|
||||
senden({ art: 'schluessel-pruefen' })
|
||||
}
|
||||
|
||||
/** 5.7.0 (F16): Ja/Nein auf eine Frage der MakeMKV-Beschaffung. */
|
||||
export function beschaffungAntworten(ja: boolean): void {
|
||||
anKern({ art: 'beschaffung-antwort', ja }, 'Antwort an die Beschaffung')
|
||||
}
|
||||
|
||||
export function einstellungSetzen(schluessel: string, wert: string): void {
|
||||
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
|
||||
}
|
||||
|
||||
@@ -257,6 +257,9 @@ export interface BeschaffungStand {
|
||||
/** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */
|
||||
anteil: number | null
|
||||
fehler: boolean
|
||||
/** 5.7.0 (F16): Rippy wartet auf ein Ja/Nein — z. B. „Archiv-Kopie laden?".
|
||||
* Fehlt: keine Frage offen. */
|
||||
frage?: string
|
||||
}
|
||||
|
||||
// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
|
||||
@@ -450,6 +453,8 @@ export type FensterNachricht =
|
||||
| { art: 'makemkv-update' }
|
||||
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
|
||||
| { art: 'schluessel-pruefen' }
|
||||
/** 5.7.0 (F16): Antwort auf eine Frage der MakeMKV-Beschaffung. */
|
||||
| { art: 'beschaffung-antwort'; ja: boolean }
|
||||
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
|
||||
| { art: 'vorgaenge-laden' }
|
||||
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
|
||||
@@ -712,6 +717,9 @@ export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
|
||||
if (wert.art === 'kompression-pause') {
|
||||
return typeof wert.an === 'boolean'
|
||||
}
|
||||
if (wert.art === 'beschaffung-antwort') {
|
||||
return typeof wert.ja === 'boolean'
|
||||
}
|
||||
if (wert.art === 'einstellung-setzen') {
|
||||
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ export class WerkzeugDienst {
|
||||
schluesselStand: SchluesselStand | null = null
|
||||
private beschaffungLaeuft = false
|
||||
private installerAntwort: ((fehler: string | null) => void) | null = null
|
||||
/** 5.7.0 (F16): Die offene Ja/Nein-Frage der Beschaffung — null, wenn keine. */
|
||||
private frageAntwort: ((ja: boolean) => void) | null = null
|
||||
private letzterBeschaffungsText = ''
|
||||
|
||||
constructor(
|
||||
private readonly kontext: KernKontext,
|
||||
@@ -115,9 +118,40 @@ export class WerkzeugDienst {
|
||||
this.installerAntwort = null
|
||||
}
|
||||
|
||||
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void {
|
||||
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } })
|
||||
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}`)
|
||||
private beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false, frage?: string): void {
|
||||
this.letzterBeschaffungsText = text
|
||||
this.kontext.anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler, ...(frage !== undefined ? { frage } : {}) } })
|
||||
this.kontext.protokoll.schreiben(`MakeMKV-Update: ${text}${frage !== undefined ? ` — FRAGE: ${frage}` : ''}`)
|
||||
}
|
||||
|
||||
/** Antwort des Fensters auf eine offene Frage (beschaffung-antwort). */
|
||||
beschaffungAntwort(ja: boolean): void {
|
||||
const antwort = this.frageAntwort
|
||||
this.frageAntwort = null
|
||||
if (antwort === null) {
|
||||
this.kontext.hinweis('Es ist keine Frage der MakeMKV-Beschaffung offen.')
|
||||
return
|
||||
}
|
||||
antwort(ja)
|
||||
}
|
||||
|
||||
/** Eine Ja/Nein-Frage ans Fenster; ohne Antwort in zehn Minuten gilt Nein. */
|
||||
private frage(text: string): Promise<boolean> {
|
||||
return new Promise((fertig) => {
|
||||
this.frageAntwort = (ja) => {
|
||||
clearTimeout(zeiger)
|
||||
this.beschaffungMelden(ja ? 'Freigegeben — die Archiv-Kopie wird geladen.' : 'Nicht freigegeben.', null, true)
|
||||
fertig(ja)
|
||||
}
|
||||
const zeiger = setTimeout(() => {
|
||||
if (this.frageAntwort === null) return
|
||||
this.frageAntwort = null
|
||||
this.beschaffungMelden('Keine Antwort — die Archiv-Kopie wird nicht geladen.', null, true)
|
||||
fertig(false)
|
||||
}, 10 * 60_000)
|
||||
zeiger.unref()
|
||||
this.beschaffungMelden(this.letzterBeschaffungsText, null, true, false, text)
|
||||
})
|
||||
}
|
||||
|
||||
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
|
||||
@@ -168,8 +202,25 @@ export class WerkzeugDienst {
|
||||
const alteExe = this.makemkvcon()
|
||||
const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : ''
|
||||
|
||||
const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => this.beschaffungMelden(text, anteil, true))
|
||||
this.beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true)
|
||||
const ergebnis = await makemkvInstallerHolen(
|
||||
neueste,
|
||||
(text, anteil) => this.beschaffungMelden(text, anteil, true),
|
||||
'',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
// F16: Das Archiv liefert eine unsignierte EXE, die gleich mit
|
||||
// Adminrechten läuft — das entscheidet der Nutzer, nicht Rippy.
|
||||
(quelle) =>
|
||||
this.frage(
|
||||
`makemkv.com antwortet nicht. Soll Rippy den MakeMKV-Installer ${neueste} aus dem ${quelle} laden? Das ist eine Kopie von fremder Hand, geprüft wird nur, dass sie ein MakeMKV-Programm ist — er läuft danach mit Adminrechten. Wer sicher gehen will, lädt später selbst von makemkv.com.`,
|
||||
),
|
||||
)
|
||||
this.beschaffungMelden(
|
||||
`Geprüft (Quelle: ${ergebnis.quelle}, ${(ergebnis.bytes / 1024 ** 2).toFixed(1)} MB, SHA-256 ${ergebnis.sha256}) — der Installer wird gestartet …`,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
|
||||
const startFehler = await new Promise<string | null>((fertig) => {
|
||||
this.installerAntwort = fertig
|
||||
|
||||
@@ -288,6 +288,9 @@ function fensterNachricht(frage: unknown): void {
|
||||
case 'schluessel-pruefen':
|
||||
void werkzeuge.schluesselLauf().then(() => werkzeuge.senden())
|
||||
return
|
||||
case 'beschaffung-antwort':
|
||||
werkzeuge.beschaffungAntwort(frage.ja)
|
||||
return
|
||||
case 'vorgaenge-laden':
|
||||
vorgaenge.neuMessen()
|
||||
return
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
|
||||
// `.neu`, geprüft, erst dann getauscht.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createWriteStream, openSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createWriteStream, openSync, readFileSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
|
||||
import { win32 as pfad } from 'node:path'
|
||||
import { werkzeugOrdner } from './katalog'
|
||||
|
||||
@@ -285,6 +286,22 @@ export interface BeschaffungErgebnis {
|
||||
pfad: string
|
||||
quelle: string
|
||||
version: string
|
||||
/** 5.7.0 (F16): Größe und SHA-256 der geladenen Datei — damit man sie
|
||||
* gegen die Herstellerangabe halten kann, sobald die Seite wieder da ist. */
|
||||
bytes: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
/** SHA-256 einer Datei als Hex — für die Anzeige neben dem Installer. */
|
||||
export function pruefsumme(dateiPfad: string): string {
|
||||
return createHash('sha256').update(readFileSync(dateiPfad)).digest('hex')
|
||||
}
|
||||
|
||||
/** Welche Quelle ein Rückfrage braucht: alles außer Hersteller und eigener
|
||||
* Basis. Das Internet Archive liefert eine unsignierte EXE, die mit
|
||||
* Adminrechten läuft — das entscheidet der Nutzer, nicht Rippy (F16). */
|
||||
export function brauchtFreigabe(quellenName: string): boolean {
|
||||
return quellenName === 'Internet Archive'
|
||||
}
|
||||
|
||||
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
|
||||
@@ -297,6 +314,9 @@ export async function makemkvInstallerHolen(
|
||||
holen: typeof fetch = fetch,
|
||||
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
|
||||
pruefen: typeof dateiPruefen = dateiPruefen,
|
||||
/** 5.7.0 (F16): Darf eine Quelle mit Rückfrage benutzt werden? Ohne
|
||||
* diese Funktion wird sie übersprungen — nie still geladen. */
|
||||
freigabe?: (quellenName: string, url: string) => Promise<boolean>,
|
||||
): Promise<BeschaffungErgebnis> {
|
||||
mkdirSync(ordner, { recursive: true })
|
||||
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
|
||||
@@ -304,6 +324,14 @@ export async function makemkvInstallerHolen(
|
||||
|
||||
const fehlschlaege: string[] = []
|
||||
for (const [name, url] of makemkvQuellen(version, basis)) {
|
||||
if (brauchtFreigabe(name)) {
|
||||
const erlaubt = freigabe !== undefined && (await freigabe(name, url))
|
||||
if (!erlaubt) {
|
||||
fehlschlaege.push(`${name}: nicht freigegeben`)
|
||||
melden(`${name} wurde nicht freigegeben.`, null)
|
||||
continue
|
||||
}
|
||||
}
|
||||
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
|
||||
try {
|
||||
await dateiLaden(url, vorlaeufig, melden, holen)
|
||||
@@ -312,7 +340,7 @@ export async function makemkvInstallerHolen(
|
||||
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
|
||||
// Datei nicht ersetzen.
|
||||
renameSync(vorlaeufig, ziel)
|
||||
return { pfad: ziel, quelle: name, version }
|
||||
return { pfad: ziel, quelle: name, version, bytes: statSync(ziel).size, sha256: pruefsumme(ziel) }
|
||||
} catch (fehler) {
|
||||
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
|
||||
melden(`${name} hat nicht geliefert.`, null)
|
||||
|
||||
@@ -136,10 +136,11 @@ describe('dateiPruefen', () => {
|
||||
describe('makemkvInstallerHolen', () => {
|
||||
const GROSS = Buffer.concat([Buffer.from('MZ'), Buffer.alloc(1024 * 1024 + 100, 7)])
|
||||
|
||||
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt', async () => {
|
||||
it('Fehlerseite mit HTTP 200 fällt durch die Mindestgröße — die nächste Quelle gewinnt (mit Freigabe)', async () => {
|
||||
const ordner = tempOrdner()
|
||||
const holen = (async (url: string | URL | Request) =>
|
||||
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
|
||||
const gefragt: string[] = []
|
||||
const ergebnis = await makemkvInstallerHolen(
|
||||
'1.18.4',
|
||||
() => {},
|
||||
@@ -147,16 +148,35 @@ describe('makemkvInstallerHolen', () => {
|
||||
holen,
|
||||
ordner,
|
||||
(p, v) => dateiPruefen(p, v, () => ({ firma: 'GuinpinSoft inc', produkt: 'MakeMKV', version: 'v1.18.4' }), 'win32'),
|
||||
async (quelle) => {
|
||||
gefragt.push(quelle)
|
||||
return true
|
||||
},
|
||||
)
|
||||
expect(gefragt).toEqual(['Internet Archive'])
|
||||
expect(ergebnis.quelle).toBe('Internet Archive')
|
||||
expect(readFileSync(ergebnis.pfad).length).toBe(GROSS.length)
|
||||
expect(ergebnis.bytes).toBe(GROSS.length)
|
||||
expect(ergebnis.sha256).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(existsSync(`${ergebnis.pfad}.neu`)).toBe(false)
|
||||
})
|
||||
|
||||
// 5.7.0 (Durchsicht F16): Ohne Freigabe wird das Archiv NIE benutzt — es
|
||||
// liefert eine unsignierte EXE, die mit Adminrechten läuft.
|
||||
it('ohne Freigabe bleibt das Archiv aus — und der Bericht sagt es', async () => {
|
||||
const ordner = tempOrdner()
|
||||
const holen = (async (url: string | URL | Request) =>
|
||||
String(url).includes('web.archive.org') ? antwort(GROSS) : antwort('<html>kaputt</html>')) as typeof fetch
|
||||
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(/Internet Archive: nicht freigegeben/)
|
||||
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner, undefined, async () => false)).rejects.toThrow(
|
||||
/Internet Archive: nicht freigegeben/,
|
||||
)
|
||||
})
|
||||
|
||||
it('liefert keine Quelle, steht im Wurf der Bericht ALLER Versuche', async () => {
|
||||
const ordner = tempOrdner()
|
||||
const holen = (async () => antwort('weg', 404)) as typeof fetch
|
||||
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner)).rejects.toThrow(
|
||||
await expect(makemkvInstallerHolen('1.18.4', () => {}, '', holen, ordner, undefined, async () => true)).rejects.toThrow(
|
||||
/Hersteller: HTTP 404[\s\S]*Internet Archive: HTTP 404[\s\S]*makemkv\.com/,
|
||||
)
|
||||
})
|
||||
@@ -166,6 +186,8 @@ describe('Nachrichten-Schema der Update-Strecke', () => {
|
||||
it('Fenster → Kern: makemkv-update und schluessel-pruefen', () => {
|
||||
expect(istFensterNachricht({ art: 'makemkv-update' })).toBe(true)
|
||||
expect(istFensterNachricht({ art: 'schluessel-pruefen' })).toBe(true)
|
||||
expect(istFensterNachricht({ art: 'beschaffung-antwort', ja: true })).toBe(true)
|
||||
expect(istFensterNachricht({ art: 'beschaffung-antwort' })).toBe(false)
|
||||
})
|
||||
it('Haupt → Kern: installer-ergebnis', () => {
|
||||
expect(istHauptNachricht({ art: 'installer-ergebnis', fehler: null })).toBe(true)
|
||||
|
||||
Reference in New Issue
Block a user