fix(files): beschaedigte Downloads werden gemeldet und abgebrochen

Die Pruefsumme wurde beim Ausliefern mitgerechnet und das Ergebnis
weggeworfen -- ein gekipptes Bit wurde erkannt und verschwiegen.

Jetzt: Logeintrag mit Datei-ID, Speicherschluessel, erwarteter und
gelesener Summe, dazu panic(http.ErrAbortHandler), den chis Recoverer
ausdruecklich durchreicht.

Damit das wirkt, werden die letzten 32 KiB zurueckgehalten. Sonst waere die
Datei beim Erkennen laengst vollstaendig beim Empfaenger und der Abbruch
kaeme zu spaet. Erst wenn die Summe stimmt, darf der Schluss hinterher;
sonst bekommt die Gegenstelle weniger Bytes als angekuendigt und wertet das
als Fehler statt als Datei.

scripts/pruefsumme-selbsttest.py, 14 Pruefungen: kippt ein Bit auf der
Platte (nicht im Programm), in der Mitte der Datei, damit es nicht schon an
der Laenge auffaellt; zwei Groessen ueber und unter den 32 KiB; und zuerst
die Gegenprobe, dass eine heile Datei durchkommt.

**Dabei gefunden:** Der neue Logeintrag blieb aus, obwohl der Abbruch
funktionierte. zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im
Kontext keiner steckt -- und Aether legte nie einen hinein. Alle 34
log.Ctx-Aufrufe im Baum schrieben ins Nichts, darunter die Warnung ueber
verwaiste Blobs beim Zurueckrollen einer Fassung.

Behoben zweifach: zerolog.DefaultContextLogger beim Start (deckt auch
Hintergrundarbeit ab) und ein Logger mit request_id im Anfragekontext. Zwei
Tests halten es fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-12 22:15:22 +02:00
1 parent c26bcba012
commit 2cc02941b9
7 files changed
+501 -10

No files matched your search

+71 -7
View File
@@ -486,20 +486,84 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size))
// Verify checksum while streaming.
// Prüfsumme beim Durchreichen mitrechnen – und den **Schluss
// zurückhalten**, bis sie stimmt.
//
// Vorher wurde hier zwar gerechnet und verglichen, das Ergebnis aber
// weggeworfen: kein Logeintrag, kein Hinweis, nichts. Der Kommentar an
// dieser Stelle sagte selbst, was fehlt. Ein gekipptes Bit auf der Platte
// wurde also erkannt und **verschwiegen** – bei einem Rechnungsarchiv,
// in das man einmal im Jahr sieht, die teuerste Art von Stille.
//
// Die Kopfzeilen sind längst draußen, der Statuscode also nicht mehr zu
// ändern – das stimmte. Nur folgt daraus nicht, dass man nichts tun kann:
// Wer die letzten Kilobytes erst nach der Prüfung herausgibt, kann die
// Verbindung noch fallen lassen, und der Browser sieht einen
// abgebrochenen Download statt einer heilen Datei mit falschem Inhalt.
h256 := sha256.New()
reader := io.TeeReader(rc, h256)
zurueck := &tailWriter{w: w, keep: verifyTail}
if _, err := io.Copy(w, reader); err != nil {
return // client disconnected
if _, err := io.Copy(zurueck, io.TeeReader(rc, h256)); err != nil {
return // Gegenstelle ist weg
}
actual := hex.EncodeToString(h256.Sum(nil))
if actual != f.Checksum {
// Log corruption; headers already sent, can't change status.
// TODO: alert / observability hook
_ = actual
log.Ctx(r.Context()).Error().
Str("file_id", f.ID.String()).
Str("storage_key", f.StorageKey).
Str("erwartet", f.Checksum).
Str("gelesen", actual).
Int64("size", f.Size).
Msg("Pruefsumme stimmt nicht - Datei auf der Platte ist beschaedigt")
// ErrAbortHandler ist der vorgesehene Weg, eine Antwort mittendrin
// fallen zu lassen: net/http schließt die Verbindung und schreibt
// **keinen** eigenen Logeintrag dazu (chis Recoverer reicht diese eine
// Panik ausdrücklich durch). Weil der Schluss noch nicht draußen ist,
// bekommt die Gegenstelle weniger Bytes als im Content-Length
// angekündigt – und wertet das als Fehler.
panic(http.ErrAbortHandler)
}
// Stimmt sie, darf der Rest hinterher.
_ = zurueck.Flush()
}
// verifyTail: So viele Bytes werden bis zur Prüfung zurückgehalten.
//
// Sie sind der ganze Hebel: Ohne sie wäre die Datei beim Erkennen des Fehlers
// längst vollständig beim Empfänger, und ein Abbruch käme zu spät. 32 KiB
// kosten nichts und liegen über dem, was io.Copy in einem Zug bewegt.
const verifyTail = 32 * 1024
// tailWriter reicht alles durch außer den letzten `keep` Bytes.
type tailWriter struct {
w io.Writer
keep int
buf []byte
}
func (t *tailWriter) Write(p []byte) (int, error) {
t.buf = append(t.buf, p...)
if ueber := len(t.buf) - t.keep; ueber > 0 {
if _, err := t.w.Write(t.buf[:ueber]); err != nil {
return 0, err
}
t.buf = append(t.buf[:0], t.buf[ueber:]...)
}
return len(p), nil
}
// Flush gibt heraus, was zurückgehalten wurde. Wird es **nicht** gerufen,
// bleibt die Antwort unvollständig – genau das ist der Zweck.
func (t *tailWriter) Flush() error {
if len(t.buf) == 0 {
return nil
}
_, err := t.w.Write(t.buf)
t.buf = t.buf[:0]
return err
}
// inlineSafeTypes sind die Typen, die im Browser angezeigt werden dürfen.