diff --git a/BEFUNDE.md b/BEFUNDE.md index 6089801..6e6e56e 100644 --- a/BEFUNDE.md +++ b/BEFUNDE.md @@ -94,7 +94,65 @@ Zweitens ließe sich die Verbindung abbrechen, statt sie sauber zu beenden: Der Browser zeigt dann einen abgebrochenen Download statt einer heilen Datei mit falschem Inhalt. Das ist die ehrlichere Ausgabe. -**Vorschlag:** Logeintrag jetzt, Abbruch dazu. Beides zusammen unter zehn Zeilen. +**✅ Erledigt am 12.09.2026.** Beides – und ein Drittes, das dabei herauskam. + +Der Logeintrag nennt Datei-ID, Speicherschlüssel, erwartete und gelesene Summe. +Dazu wird die Verbindung fallen gelassen (`panic(http.ErrAbortHandler)`, den +chis Recoverer ausdrücklich durchreicht). + +**Damit das überhaupt wirkt, mussten die letzten 32 KiB zurückgehalten +werden.** Sonst wäre die Datei beim Erkennen des Fehlers längst vollständig +beim Empfänger, und der Abbruch käme zu spät: Die Kopfzeilen sind draußen, die +Bytes auch. Ein `tailWriter` reicht deshalb alles durch außer dem Schluss; erst +wenn die Summe stimmt, darf der hinterher. Stimmt sie nicht, bekommt die +Gegenstelle weniger Bytes als im `Content-Length` angekündigt – und wertet das +als Fehler statt als Datei. + +Geprüft in `scripts/pruefsumme-selbsttest.py` (14 Prüfungen): Der Test kippt +ein Bit **auf der Platte**, nicht im Programm, und zwar in der Mitte der Datei, +damit der Fehler nicht schon an der Länge auffällt. Zwei Größen – über und +unter den 32 KiB, denn bei einer kleinen Datei wird gar nichts herausgegeben. +Und er prüft zuerst, dass eine **heile** Datei vollständig durchkommt; ohne das +bewiese der Rest nichts. + +--- + +## 2b · Und der Fund dahinter: 34 Logaufrufe schrieben ins Nichts + +**Schwere: hoch, indirekt. Ein Fehler, der andere Fehler versteckt.** + +Der neue Logeintrag blieb beim ersten Testlauf aus, obwohl der Abbruch +funktionierte. Ursache: + +```go +func Ctx(ctx context.Context) *Logger { + if l, ok := ctx.Value(ctxKey{}).(*Logger); ok { return l } + else if l = DefaultContextLogger; l != nil { return l } + return disabledLogger // ← hier landete alles +} +``` + +`zerolog.Ctx` liefert einen **abgeschalteten** Logger, wenn im Kontext keiner +steckt und kein Standard gesetzt ist. Aethers `middleware.Logger` hängte nie +einen hinein, und `DefaultContextLogger` war nie gesetzt. + +**Alle 34 `log.Ctx(...)`-Aufrufe im Baum schrieben damit ins Nichts.** Darunter: + +* die Warnung, dass beim Zurückrollen einer Fassung ein Blob verwaist ist + (`files/handler.go`, `files/replace.go`, `handler_versions.go`) +* gescheiterte Suchen +* Fehler der Speicherbereinigung +* die neue Meldung über beschädigte Dateien + +Das ist die unangenehmste Art Fehler: Der Aufruf sieht richtig aus, `go vet` +schweigt, und auffallen kann es nur, wenn man den Fehler herbeiführt, den die +Zeile melden soll. + +**Behoben zweifach:** `zerolog.DefaultContextLogger` beim Start gesetzt (deckt +auch Hintergrundarbeit mit eigenem Kontext ab), und `middleware.Logger` hängt +zusätzlich einen Logger mit `request_id` in den Anfragekontext – damit bei zwei +gleichzeitigen Downloads erkennbar bleibt, welche Meldung zu welchem gehört. +Zwei Tests in `pkg/middleware/logger_test.go` halten es fest. --- @@ -163,8 +221,8 @@ gibt: ## Reihenfolge, die ich vorschlagen würde 1. ~~`recover` in die Hintergrund-Goroutinen~~ ✅ 12.09.2026 -2. **Prüfsummen-Abweichung melden** (Punkt 2). Zehn Zeilen für eine Auskunft, - die es sonst nie gibt. +2. ~~Prüfsummen-Abweichung melden~~ ✅ 12.09.2026 – und dabei fiel auf, dass + **alle** kontextgebundenen Logzeilen ins Nichts gingen (2b). 3. **Tests für `documents`** (Punkt 3). Das Paket, dessen stilles Versagen am teuersten ist: Die Suche findet dann einfach nichts, und das sieht aus wie „ist nicht drin". diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index a9c09a1..e851cba 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -127,6 +127,20 @@ func main() { } zerolog.SetGlobalLevel(zerolog.InfoLevel) + // **Ohne diese Zeile schreibt jedes log.Ctx(ctx) ins Nichts.** + // + // zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im Kontext + // keiner steckt und kein Standard gesetzt ist. Im Baum stehen 34 solcher + // Aufrufe – darunter die Warnung, dass beim Zurückrollen einer Fassung ein + // Blob verwaist ist, und die Meldung über eine beschädigte Datei beim + // Download. Alle gingen bis zum 12.09.2026 verloren, und zwar lautlos: + // Der Aufruf sieht richtig aus, und ausprobieren kann man ihn nur, indem + // man den Fehler herbeiführt, den er melden soll. + // + // Gefunden beim Schreiben des Prüfsummen-Selbsttests – der neue + // Logeintrag blieb aus, obwohl der Abbruch funktionierte. + zerolog.DefaultContextLogger = &log.Logger + log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting") ctx := context.Background() diff --git a/backend/internal/files/handler.go b/backend/internal/files/handler.go index 6af695b..e987cc0 100644 --- a/backend/internal/files/handler.go +++ b/backend/internal/files/handler.go @@ -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. diff --git a/backend/internal/files/tailwriter_test.go b/backend/internal/files/tailwriter_test.go new file mode 100644 index 0000000..b207dfe --- /dev/null +++ b/backend/internal/files/tailwriter_test.go @@ -0,0 +1,100 @@ +package files + +import ( + "bytes" + "io" + "testing" +) + +// Der tailWriter ist die Stelle, an der ein beschädigter Download noch +// aufzuhalten ist. Was er falsch macht, merkt man sonst erst an einer Datei, +// die heil aussieht und keine ist. + +func TestTailWriterHaeltDenSchlussZurueck(t *testing.T) { + var raus bytes.Buffer + tw := &tailWriter{w: &raus, keep: 4} + + if _, err := tw.Write([]byte("0123456789")); err != nil { + t.Fatal(err) + } + if got := raus.String(); got != "012345" { + t.Fatalf("durchgereicht %q, erwartet %q", got, "012345") + } + if err := tw.Flush(); err != nil { + t.Fatal(err) + } + if got := raus.String(); got != "0123456789" { + t.Fatalf("nach Flush %q", got) + } +} + +// Der Fall, auf den es ankommt: Ohne Flush darf der Empfänger die Datei +// **nicht** vollständig haben. +func TestOhneFlushBleibtEsUnvollstaendig(t *testing.T) { + var raus bytes.Buffer + tw := &tailWriter{w: &raus, keep: 4} + io.Copy(tw, bytes.NewReader([]byte("0123456789"))) + if raus.Len() != 6 { + t.Fatalf("%d Bytes draußen, erwartet 6", raus.Len()) + } +} + +// Eine Datei, die kleiner ist als der zurückgehaltene Schluss, darf ohne +// Flush gar nichts hinauslassen. +func TestKleineDateiBleibtGanzZurueck(t *testing.T) { + var raus bytes.Buffer + tw := &tailWriter{w: &raus, keep: 32} + tw.Write([]byte("kurz")) + if raus.Len() != 0 { + t.Fatalf("%d Bytes zu früh draußen", raus.Len()) + } + tw.Flush() + if raus.String() != "kurz" { + t.Fatalf("nach Flush %q", raus.String()) + } +} + +// Viele kleine Schreibvorgänge dürfen sich nicht anders verhalten als einer +// großer – io.Copy zerlegt in Häppchen. +func TestHaeppchenweiseGleichesErgebnis(t *testing.T) { + var raus bytes.Buffer + tw := &tailWriter{w: &raus, keep: 5} + for _, c := range []byte("abcdefghijklmnop") { + if _, err := tw.Write([]byte{c}); err != nil { + t.Fatal(err) + } + } + if raus.String() != "abcdefghijk" { + t.Fatalf("durchgereicht %q", raus.String()) + } + tw.Flush() + if raus.String() != "abcdefghijklmnop" { + t.Fatalf("nach Flush %q", raus.String()) + } +} + +// Der Zwischenspeicher darf nicht mitwachsen: Bei einer 5-GB-Datei läge sonst +// alles im Arbeitsspeicher. +func TestZwischenspeicherWaechstNicht(t *testing.T) { + tw := &tailWriter{w: io.Discard, keep: 1024} + block := make([]byte, 32*1024) + for range 100 { + tw.Write(block) + if len(tw.buf) > len(block)+tw.keep { + t.Fatalf("Zwischenspeicher auf %d Bytes gewachsen", len(tw.buf)) + } + } +} + +// Bricht die Gegenstelle weg, muss der Fehler nach oben durchkommen – sonst +// liefe io.Copy weiter und läse die ganze Datei ins Nichts. +type kaputterWriter struct{} + +func (kaputterWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestSchreibfehlerKommtDurch(t *testing.T) { + tw := &tailWriter{w: kaputterWriter{}, keep: 2} + if _, err := tw.Write([]byte("abcdef")); err == nil { + t.Fatal("Schreibfehler wurde verschluckt") + } +} diff --git a/backend/pkg/middleware/logger.go b/backend/pkg/middleware/logger.go index 6138b99..0e23790 100644 --- a/backend/pkg/middleware/logger.go +++ b/backend/pkg/middleware/logger.go @@ -4,6 +4,8 @@ import ( "net/http" "time" + chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) @@ -13,6 +15,17 @@ func Logger(next http.Handler) http.Handler { start := time.Now() ww := &responseWriter{ResponseWriter: w, status: http.StatusOK} + // Den Logger in den Kontext hängen, damit log.Ctx(r.Context()) in den + // Handlern etwas findet – und damit jede Zeile aus einer Anfrage die + // Kennung dieser Anfrage trägt. Ohne das schreibt log.Ctx gar nicht + // (zerolog gibt dann einen abgeschalteten Logger zurück), und mit + // einem globalen Standard allein ließe sich nicht mehr zuordnen, + // welche Meldung zu welchem Aufruf gehört. + l := log.Logger.With(). + Str("request_id", chimiddleware.GetReqID(r.Context())). + Logger() + r = r.WithContext(l.WithContext(r.Context())) + next.ServeHTTP(ww, r) level := zerolog.InfoLevel diff --git a/backend/pkg/middleware/logger_test.go b/backend/pkg/middleware/logger_test.go new file mode 100644 index 0000000..f2f492b --- /dev/null +++ b/backend/pkg/middleware/logger_test.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// Der Test, der gefehlt hat. +// +// zerolog.Ctx liefert einen **abgeschalteten** Logger, wenn im Kontext keiner +// steckt. 34 Aufrufe von log.Ctx(...) im Baum schrieben deshalb ins Nichts – +// darunter Warnungen über verwaiste Blobs und beschädigte Dateien. Der Aufruf +// sieht dabei völlig richtig aus; auffallen kann es nur, wenn man den Fehler +// herbeiführt, den er melden soll. +func TestLoggerHaengtLoggerInDenKontext(t *testing.T) { + var aus bytes.Buffer + alt := log.Logger + log.Logger = zerolog.New(&aus) + defer func() { log.Logger = alt }() + + h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Genau der Aufruf, der überall im Baum steht. + log.Ctx(r.Context()).Error().Msg("kaputte-datei") + })) + + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil)) + + if !strings.Contains(aus.String(), "kaputte-datei") { + t.Fatalf("log.Ctx im Handler schrieb nichts. Ausgabe: %q", aus.String()) + } +} + +// Jede Zeile aus einer Anfrage soll deren Kennung tragen – sonst weiß man bei +// zwei gleichzeitigen Downloads nicht, welche Meldung zu welchem gehört. +func TestZeilenTragenDieAnfrageKennung(t *testing.T) { + var aus bytes.Buffer + alt := log.Logger + log.Logger = zerolog.New(&aus) + defer func() { log.Logger = alt }() + + h := Logger(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Ctx(r.Context()).Error().Msg("x") + })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil)) + + if !strings.Contains(aus.String(), "request_id") { + t.Fatalf("keine request_id in der Zeile: %q", aus.String()) + } +} diff --git a/scripts/pruefsumme-selbsttest.py b/scripts/pruefsumme-selbsttest.py new file mode 100644 index 0000000..c3d960a --- /dev/null +++ b/scripts/pruefsumme-selbsttest.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Selbsttest: Wird eine beschädigte Datei beim Herunterladen erkannt? + + python3 scripts/pruefsumme-selbsttest.py [BASIS-URL] [DATENPFAD] [BENUTZER] [PASSWORT] + +Aether speichert zu jeder Datei eine SHA-256-Summe und rechnet sie beim +Ausliefern mit. Bis zum 12.09.2026 wurde das Ergebnis **weggeworfen**: Ein +gekipptes Bit auf der Platte wurde erkannt und verschwiegen, und der Empfänger +bekam eine Datei, die heil aussah und keine war. + +Der Test kippt deshalb wirklich ein Bit – im Blob auf der Platte, nicht im +Programm – und prüft, was danach herauskommt. Geprüft wird: + + * Eine heile Datei kommt unverändert durch (sonst bewiese der Rest nichts). + * Eine beschädigte Datei kommt **nicht vollständig** an. + * Der Server sagt es im Log, mit Datei-ID, erwarteter und gelesener Summe. + * Groß und klein: über und unter den 32 KiB, die bis zur Prüfung + zurückgehalten werden – beim kleinen Fall wird gar nichts herausgegeben. + +Der Test muss auf dem Rechner laufen, auf dem die Daten liegen, und braucht +Docker-Zugriff für den Logeintrag. +""" +import hashlib +import http.cookiejar +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +import uuid + +BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/") +DATEN = sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser("~/aether-data") +USER = sys.argv[3] if len(sys.argv) > 3 else "admin" +PASSWORT = sys.argv[4] if len(sys.argv) > 4 else "admin123" +CONTAINER = os.environ.get("AETHER_CONTAINER", "aether-app") + +ok_count = 0 +fail = [] + + +def check(name, cond, detail=""): + global ok_count + if cond: + ok_count += 1 + print(f" \033[32mok\033[0m {name}") + else: + fail.append(name) + print(f" \033[31mFEHL\033[0m {name} {detail}") + + +jar = http.cookiejar.CookieJar() +OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + + +def api(method, path, body=None, headers=None): + hdrs = dict(headers or {}) + data = json.dumps(body).encode() if body is not None else None + if body is not None: + hdrs["Content-Type"] = "application/json" + req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method) + try: + with OPENER.open(req) as r: + text = r.read().decode() + return r.status, (json.loads(text).get("data") if text else None) + except urllib.error.HTTPError as e: + return e.code, None + + +def hochladen(name, inhalt: bytes, csrf): + grenze = "----aether" + uuid.uuid4().hex + kopf = (f'--{grenze}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n' + f'Content-Type: application/octet-stream\r\n\r\n') + leib = kopf.encode() + inhalt + f"\r\n--{grenze}--\r\n".encode() + req = urllib.request.Request(BASE + "/api/v1/files", data=leib, method="POST", + headers={"Content-Type": f"multipart/form-data; boundary={grenze}", **csrf}) + with OPENER.open(req) as r: + return json.loads(r.read().decode())["data"] + + +def herunterladen(file_id): + """(vollstaendig, bytes|None, fehler)""" + req = urllib.request.Request(BASE + f"/api/v1/files/{file_id}/download") + try: + with OPENER.open(req) as r: + erwartet = int(r.headers.get("Content-Length", "-1")) + daten = r.read() + return (erwartet < 0 or len(daten) == erwartet), daten, "" + except Exception as e: + # Abgebrochene Antwort: genau das ist der gewuenschte Ausgang. + return False, None, type(e).__name__ + ": " + str(e)[:80] + + +def blobpfad(name): + """Wo der Blob liegt. + + **Nicht** aus der Antwort: Der interne Speicherpfad steht dort bewusst + nicht drin, er geht einen Client nichts an. Für eine Datei im + Wurzelverzeichnis ist er aber vorhersagbar – siehe BuildStorageKey. + """ + return os.path.join(DATEN, "users", USER, "home", name) + + +def logzeilen(seit): + try: + aus = subprocess.run(["docker", "logs", "--since", seit, CONTAINER], + capture_output=True, text=True, timeout=20) + return aus.stdout + aus.stderr + except Exception: + return "" + + +print(f"Pruefsummen-Selbsttest gegen {BASE}\n") + +st, login = api("POST", "/api/v1/auth/login", {"username": USER, "password": PASSWORT}) +assert st == 200, f"Anmeldung fehlgeschlagen: {st}" +CSRF = {"X-CSRF-Token": login["csrf_token"]} + +angelegt = [] + + +def aufraeumen(): + for fid in angelegt: + api("DELETE", f"/api/v1/files/{fid}", None, CSRF) + st, papier = api("GET", "/api/v1/trash", None, CSRF) + for e in (papier or []): + if e.get("name", "").startswith("pruefsumme-"): + api("DELETE", f"/api/v1/trash/{e['id']}", None, CSRF) + + +try: + for bezeichnung, groesse in [("gross", 200 * 1024), ("klein", 900)]: + print(f"── {bezeichnung}e Datei ({groesse} Bytes) ──") + name = f"pruefsumme-{bezeichnung}-{uuid.uuid4().hex[:6]}.bin" + inhalt = bytes((i * 7 + 13) % 256 for i in range(groesse)) + datei = hochladen(name, inhalt, CSRF) + angelegt.append(datei["id"]) + check("hochgeladen", datei["size"] == groesse, datei["size"]) + check("Pruefsumme stimmt mit der eigenen ueberein", + datei["checksum"] == hashlib.sha256(inhalt).hexdigest(), datei["checksum"][:16]) + + # 1. Heil: Das muss durchgehen, sonst beweist der Rest nichts. + voll, daten, fehler = herunterladen(datei["id"]) + check("heile Datei kommt vollstaendig an", voll and daten == inhalt, + fehler or f"{len(daten or b'')} von {groesse}") + + # 2. Ein Bit kippen – auf der Platte, nicht im Programm. + pfad = blobpfad(name) + if not os.path.isfile(pfad): + check(f"Blob gefunden ({pfad})", False, "nicht da – falscher DATENPFAD?") + continue + with open(pfad, "r+b") as f: + # In der Mitte, damit auch der zurueckgehaltene Schluss heil bleibt: + # Der Fehler darf nicht schon an der Laenge auffallen. + f.seek(groesse // 2) + b = f.read(1) + f.seek(groesse // 2) + f.write(bytes([b[0] ^ 0xFF])) + check("ein Bit auf der Platte gekippt", True) + + # 3. Jetzt darf sie nicht mehr heil herauskommen. + marke = "1m" + voll, daten, fehler = herunterladen(datei["id"]) + check("beschaedigte Datei kommt NICHT vollstaendig an", not voll, + f"{len(daten or b'')} von {groesse} Bytes – heil ausgeliefert!") + if daten is not None: + check("und was ankam, ist kuerzer als angekuendigt", len(daten) < groesse, + f"{len(daten)} von {groesse}") + + # 4. Der Server muss es gesagt haben. + log = logzeilen(marke) + if log: + check("Server meldet die Beschaedigung im Log", + "Pruefsumme stimmt nicht" in log, log[-200:] if log else "") + check("mit der Datei-ID", datei["id"] in log, "") + else: + print(" \033[33m!\033[0m Log nicht lesbar (docker logs) – Logpruefung uebersprungen") + print() + +finally: + aufraeumen() + +if fail: + print(f"\033[31m{len(fail)} fehlgeschlagen\033[0m: " + ", ".join(fail)) +print(f"{ok_count} bestanden, {len(fail)} fehlgeschlagen") +sys.exit(1 if fail else 0)