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:
1 parent
c26bcba012
commit
2cc02941b9
7 files changed
+501
-10
No files matched your search
+61
-3
@@ -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".
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user