package xfer
import (
"bytes"
"compress/zlib"
"encoding/binary"
"net/http"
"net/http/httptest"
"runtime"
"testing"
"time"
)
// bomb is a container claiming claim bytes whose zlib stream inflates to
// n zeros. The zeros are streamed into the compressor, so building one
// costs the test almost nothing whatever n is.
func bomb(t *testing.T, claim uint32, n int64) []byte {
t.Helper()
var buf bytes.Buffer
var lenb [4]byte
binary.BigEndian.PutUint32(lenb[:], claim)
buf.Write(lenb[:])
zw, err := zlib.NewWriterLevel(&buf, zlib.BestCompression)
if err != nil {
t.Fatal(err)
}
zeros := make([]byte, 1<<20)
for left := n; left > 0; left -= int64(len(zeros)) {
if left < int64(len(zeros)) {
zeros = zeros[:left]
}
zw.Write(zeros)
}
zw.Close()
return buf.Bytes()
}
// gated is a Fronter with the push gate on, so the request is decoded,
// drawing on a budget of its own rather than the process's.
func gated() *Fronter {
f := New()
f.budget = &decodeBudget{size: requestBudget.size}
f.SetAuth(secrets{})
return f
}
// postRaw sends raw to f and reports the answer, how much the handler
// allocated, and how long it took.
func postRaw(t *testing.T, f *Fronter, raw []byte) (*httptest.ResponseRecorder, uint64, time.Duration) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/r/alpha", bytes.NewReader(raw))
req.SetPathValue("repo", "alpha")
req.Header.Set("Content-Type", "application/x-fossil")
w := httptest.NewRecorder()
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
start := time.Now()
f.ServeHTTP(w, req)
took := time.Since(start)
runtime.ReadMemStats(&after)
return w, after.TotalAlloc - before.TotalAlloc, took
}
// An anonymous request claiming 4 GiB is refused on its claim, before a
// byte of it is inflated. Before this, the front inflated it in full,
// several times per request, ahead of any authentication.
func TestAContainerClaimingGigabytesIsRefusedWithoutInflating(t *testing.T) {
raw := bomb(t, 0xFFFFFFFF, 128<<20)
w, grew, took := postRaw(t, gated(), raw)
if w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("FAIL: a 4 GiB claim answered %d, want 413", w.Code)
} else {
t.Log("ok: a 4 GiB claim is refused 413")
}
if grew > 32<<20 {
t.Errorf("FAIL: refusing it allocated %d MiB", grew>>20)
} else {
t.Logf("ok: refusing it allocated %d KiB in %v", grew>>10, took)
}
}
// A claim just past the decoded limit is refused, however small the
// stream behind it really is.
func TestAClaimJustPastTheDecodedLimitIsRefused(t *testing.T) {
w, _, _ := postRaw(t, gated(), bomb(t, 2*maxRequest+1, 16))
if w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("FAIL: a claim one past the limit answered %d, want 413", w.Code)
}
}
// A container whose stream inflates past what it claimed is cut at the
// claim and refused, rather than handed to the child as plaintext for
// the child to inflate in full.
func TestAContainerInflatingPastItsClaimIsRefused(t *testing.T) {
w, grew, _ := postRaw(t, gated(), bomb(t, 1<<10, 128<<20))
if w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("FAIL: a lying claim answered %d, want 413", w.Code)
}
if grew > 32<<20 {
t.Errorf("FAIL: refusing it allocated %d MiB", grew>>20)
}
}
// An ordinary container is decoded and goes on to the gates: here the
// repository is unknown, so the answer is 404, not a size refusal.
func TestAnOrdinaryContainerIsNotRefusedForSize(t *testing.T) {
w, _, _ := postRaw(t, gated(), encodeContainer([]byte("pragma client-version 1\npull SRV PRJ\n")))
if w.Code == http.StatusRequestEntityTooLarge || w.Code == http.StatusServiceUnavailable {
t.Errorf("FAIL: an ordinary container answered %d", w.Code)
}
}
// Decodes in flight share one budget. A request whose claim does not
// fit is told to come back, before anything is inflated, and the budget
// is whole again once the requests holding it finish.
func TestDecodesPastTheBudgetAreToldToRetry(t *testing.T) {
f := gated()
held := f.budget.size - maxDecoded/2
if !f.budget.take(held) {
t.Fatal("the budget was not free at the start")
}
raw := bomb(t, maxDecoded, 16)
w, _, _ := postRaw(t, f, raw)
if w.Code != http.StatusServiceUnavailable || w.Header().Get("Retry-After") == "" {
t.Errorf("FAIL: a decode past the budget answered %d (Retry-After %q), want 503 with one", w.Code, w.Header().Get("Retry-After"))
}
f.budget.give(held)
w, _, _ = postRaw(t, f, raw)
if w.Code == http.StatusServiceUnavailable || w.Code == http.StatusRequestEntityTooLarge {
t.Errorf("FAIL: with the budget free the same request answered %d", w.Code)
}
if f.budget.used != 0 {
t.Errorf("FAIL: %d bytes still reserved after the requests finished", f.budget.used)
}
}
// An over-claim costs the budget only what it really inflated to, so a
// few tiny containers each claiming the limit cannot pin it.
func TestOnlyDecodedBytesStayReserved(t *testing.T) {
f := gated()
var during int64 = -1
f.SetReadGate(func(*http.Request, string, string) bool {
during = f.budget.used
return false
})
postRaw(t, f, bomb(t, maxDecoded, 16))
if during != 16 {
t.Errorf("FAIL: a 16-byte stream claiming %d held %d of the budget, want 16", maxDecoded, during)
}
}
// The relay to the child lasts as long as the client takes to read the
// reply, so nothing is reserved while it runs.
func TestTheBudgetIsFreeWhileTheReplyIsRelayed(t *testing.T) {
f := gated()
defer f.Close()
srv, _ := fakeChild(t, f, "alpha")
var during int64 = -1
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
during = f.budget.used
w.Header().Set("Content-Type", "application/x-fossil")
w.Write([]byte("pragma ok\n"))
})
w, _, _ := postRaw(t, f, encodeContainer([]byte("pull SRV PRJ\n")))
if w.Code != http.StatusOK {
t.Fatalf("the pull answered %d", w.Code)
}
if during != 0 {
t.Errorf("FAIL: %d bytes were reserved while the child answered, want 0", during)
}
}
// With no gate nothing reads the body, so nothing inflates it: only the
// claim is checked.
func TestWithoutAGateTheBodyIsNotInflated(t *testing.T) {
f := New()
f.budget = &decodeBudget{size: requestBudget.size}
w, grew, _ := postRaw(t, f, bomb(t, 1<<10, 128<<20))
if w.Code == http.StatusRequestEntityTooLarge || grew > 32<<20 {
t.Errorf("FAIL: an ungated request answered %d and allocated %d MiB", w.Code, grew>>20)
}
if w, _, _ := postRaw(t, f, bomb(t, 0xFFFFFFFF, 16)); w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("FAIL: an ungated 4 GiB claim answered %d, want 413", w.Code)
}
}
// Nothing parsed from the body may keep it alive through the relay: a
// login card, verified or not, used to hold a slice of the decoded body
// until the tally ran, so a slow reader kept it while the budget said it
// was free.
func TestTheDecodedBodyIsUnreachableDuringTheRelay(t *testing.T) {
f := gated()
defer f.Close()
f.SetCounted(func(string, Reader) {})
srv, _ := fakeChild(t, f, "alpha")
var heap uint64
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
heap = m.HeapAlloc
w.Header().Set("Content-Type", "application/x-fossil")
w.Write([]byte("pragma ok\n"))
})
const filler = 64 << 20
body := append([]byte("login bob 0000000000000000000000000000000000000000 0000000000000000000000000000000000000000\npull SRV PRJ\n"), make([]byte, filler)...)
raw := encodeContainer(body)
body = nil
w, _, _ := postRaw(t, f, raw)
if w.Code != http.StatusOK {
t.Fatalf("the pull answered %d", w.Code)
}
if heap > filler/2 {
t.Errorf("FAIL: %d MiB live while the child answered; the decoded body is %d MiB", heap>>20, filler>>20)
}
}