feat(sync): gzip the sync wire, without touching what a hash means (#160)

Nothing on the /store/* wire was compressed, while the corpus it carries is
markdown and source. Compression lands as a pure transport concern: content
addressing, the storage layout and the journal format all stay over the
uncompressed bytes.

The two legs are not symmetric. Pull needs no negotiation — net/http already
sends Accept-Encoding: gzip and inflates transparently — so devices built
before this get it the day the hub ships; a real pre-compression binary
receives 19,958 bytes for a 148 KB corpus (7.4x) with no client change. Push
is negotiated through sign()'s accept_encoding, because a gzip body posted to
an old hub would be stored under the sha256 of its plaintext.

The hub inflates ABOVE spool — the sha a key promises, the ops a journal
carries and the size that gets billed are all plaintext properties — and the
inflate is bounded at 256 MiB, because Content-Encoding severs the
one-wire-byte-one-disk-byte relationship that made spool safe unbounded. The
presigned direct-to-storage leg stays raw and is asserted to.

Known deployment caveat: a compressed push clears ContentLength, so it goes
out chunked where every push was sized before. A reverse proxy that buffers or
rejects chunked request bodies would fail pushes (degrading to Offline and
retrying, not losing data).
This commit is contained in:
Snow Lee (Sungwon)
2026-08-13 12:20:36 -07:00
committed by GitHub
parent edfe46c0aa
commit 3de8590b6b
10 changed files with 1275 additions and 11 deletions
+82
View File
@@ -0,0 +1,82 @@
package remote
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"strings"
)
// Transport compression for the sync wire. Nothing on it was compressed:
// blobs and journals crossed as raw application/octet-stream in both
// directions, while the corpus they carry is 510 KB markdown and source
// files that gzip ~3.4x (TestCompressionTextCorpusRatio measures it).
//
// This is a TRANSPORT concern only, and the whole feature depends on that
// staying true: content addressing is over the UNCOMPRESSED bytes, storage
// holds uncompressed objects, and the journal format is untouched. A hub that
// stored a gzip body under the sha256 of its plaintext would have broken
// every device's blob check, so the hub inflates before it hashes
// (handleStorePut) and never after.
//
// The codec is gzip rather than zstd for one decisive reason: net/http already
// sends `Accept-Encoding: gzip` on every request whose caller did not set that
// header itself, and transparently inflates the response. httpBackend.do does
// not set it, so the entire pull leg compresses for binaries built before this
// existed, with no client change at all. zstd would forfeit that.
// probeWindow is how much of a stream is sampled to decide whether the rest is
// worth compressing. Big enough to see past a file header, small enough that
// the sample is a buffer rather than a spool.
const probeWindow = 64 << 10
// probeMargin is the share of the sample compression has to save before it is
// worth paying for. Already-compressed content (JPEG, zip, model weights) gets
// ~0.1% BIGGER under gzip, so the margin is really a sign test with slack.
const probeMargin = 0.9
// Compressible reports whether a stream is worth gzipping, by compressing its
// first probeWindow bytes and checking that the sample actually shrank.
//
// It returns the stream REJOINED — the sampled bytes followed by whatever is
// left — because the probe has to consume the bytes it judges. That identity is
// the property this helper lives or dies on: a probe that eats bytes silently
// corrupts every push and every pull that runs through it, and the failure
// shows up as a sha mismatch far from here. compress_test.go asserts it for a
// stream longer than the window, one shorter, and an empty one.
func Compressible(r io.Reader) (io.Reader, bool, error) {
sample, err := io.ReadAll(io.LimitReader(r, probeWindow))
rejoined := io.MultiReader(bytes.NewReader(sample), r)
if err != nil {
return rejoined, false, err
}
if len(sample) == 0 {
return rejoined, false, nil
}
var n countingSink
gz := gzip.NewWriter(&n)
if _, err := gz.Write(sample); err != nil {
return rejoined, false, err
}
if err := gz.Close(); err != nil {
return rejoined, false, err
}
return rejoined, float64(n) < float64(len(sample))*probeMargin, nil
}
type countingSink int
func (c *countingSink) Write(p []byte) (int, error) { *c += countingSink(len(p)); return len(p), nil }
// AcceptsGzip reports whether a request's Accept-Encoding allows a gzipped
// answer. Go's own transport sets that header on every request this package
// makes, which is why old devices get the compressed pull leg for free.
func AcceptsGzip(r *http.Request) bool {
for _, enc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
if name, _, _ := strings.Cut(enc, ";"); strings.EqualFold(strings.TrimSpace(name), "gzip") {
return true
}
}
return false
}
+77
View File
@@ -0,0 +1,77 @@
package remote
import (
"bytes"
"io"
"math/rand"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The probe consumes the bytes it judges, so the stream it hands back must be
// byte-identical to the one it was given — for a stream longer than the probe
// window, one shorter, and an empty one. A probe that eats bytes corrupts every
// push and pull that runs through it, and the damage surfaces as a sha
// mismatch nowhere near this file.
func TestCompressibleRejoinsTheStream(t *testing.T) {
cases := []struct {
name string
in []byte
want bool
}{
{"text past the window", []byte(strings.Repeat("package main // hello hello\n", 5000)), true},
{"text under the window", []byte(strings.Repeat("hello beardrive\n", 100)), true},
{"already compressed", randomBytes(200 << 10), false},
{"tiny", []byte("hi"), false},
{"empty", nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, worth, err := Compressible(bytes.NewReader(c.in))
if err != nil {
t.Fatal(err)
}
if worth != c.want {
t.Errorf("worth = %v, want %v", worth, c.want)
}
rejoined, err := io.ReadAll(got)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(rejoined, c.in) {
t.Fatalf("rejoined stream is %d bytes, want the original %d", len(rejoined), len(c.in))
}
})
}
}
func TestAcceptsGzip(t *testing.T) {
cases := map[string]bool{
"": false,
"identity": false,
"gzip": true,
"deflate, gzip;q=1.0, *;q=0": true,
"GZIP": true,
"x-gzip": false, // a different token, not a prefix match
}
for hdr, want := range cases {
r := httptest.NewRequest(http.MethodGet, "/", nil)
if hdr != "" {
r.Header.Set("Accept-Encoding", hdr)
}
if got := AcceptsGzip(r); got != want {
t.Errorf("AcceptsGzip(%q) = %v, want %v", hdr, got, want)
}
}
}
// randomBytes stands in for already-compressed content (JPEG, zip, model
// weights): incompressible by construction, which is the whole point.
func randomBytes(n int) []byte {
b := make([]byte, n)
rng := rand.New(rand.NewSource(1))
rng.Read(b)
return b
}
+62 -2
View File
@@ -2,6 +2,7 @@ package remote
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
@@ -165,6 +166,13 @@ func refuseOffOriginRedirect(req *http.Request, via []*http.Request) error {
// do sends the request with this device's credential attached, plus the
// identity headers the server's device registry records for history (name,
// OS; the server observes the IP itself).
//
// It deliberately does NOT set Accept-Encoding. net/http adds `gzip` itself
// whenever the caller has not, and transparently inflates the response — which
// is the entire pull half of transport compression, free and backward
// compatible. Setting the header here turns that off silently: the hub would
// still answer `Content-Encoding: gzip`, nothing would inflate it, and every
// blob would fail its sha check while looking like a corrupt hub.
func (b *httpBackend) do(req *http.Request) (*http.Response, error) {
if b.token != "" {
req.Header.Set("Authorization", "Bearer "+b.token)
@@ -354,7 +362,7 @@ func (b *httpBackend) Put(ctx context.Context, key string, r io.Reader, size int
// No usable destination: relay through the hub, which already holds
// this device's credential and is the party it chose to trust.
}
return b.putViaServer(ctx, key, r, size)
return b.putViaServer(ctx, plan, key, r, size)
}
// directTargetOK decides whether this device will hand a file's bytes to the
@@ -389,6 +397,23 @@ type putPlan struct {
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
// AcceptEncoding is what the hub will accept on the relayed PUT body.
// Push cannot be unilateral the way pull is: a gzipped body posted to a
// hub that does not inflate is stored verbatim under the sha256 of its
// PLAINTEXT — a 400 for a blob, and a silently mis-stored journal. So the
// client compresses only when the hub says so, and an older hub says
// nothing at all (absent field → nil → raw). sign() runs before every
// single put, so this costs no extra round trip and needs no config flag.
AcceptEncoding []string `json:"accept_encoding"`
}
func (p putPlan) acceptsGzip() bool {
for _, enc := range p.AcceptEncoding {
if strings.EqualFold(strings.TrimSpace(enc), "gzip") {
return true
}
}
return false
}
func (b *httpBackend) sign(ctx context.Context, key string, size int64) (putPlan, error) {
@@ -444,7 +469,34 @@ func (b *httpBackend) putDirect(ctx context.Context, plan putPlan, r io.Reader,
return nil
}
func (b *httpBackend) putViaServer(ctx context.Context, key string, r io.Reader, size int64) error {
// putViaServer relays the bytes through the hub, gzipping them when the hub
// advertised that it inflates (plan.AcceptEncoding) and the content is worth
// compressing. putDirect deliberately stays raw: a presigned upload lands in
// the object store under the sha256 of the plaintext, with no hub in the path
// to inflate it, so compressing that leg would corrupt content addressing at
// rest.
func (b *httpBackend) putViaServer(ctx context.Context, plan putPlan, key string, r io.Reader, size int64) error {
gzipped := false
if plan.acceptsGzip() {
probed, worth, err := Compressible(r)
if err != nil {
return err
}
r, gzipped = probed, worth
}
if gzipped {
pr, pw := io.Pipe()
src := r
go func() {
gz := gzip.NewWriter(pw)
_, err := io.Copy(gz, src)
if cerr := gz.Close(); err == nil {
err = cerr
}
pw.CloseWithError(err)
}()
r = pr
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
b.endpoint("object", url.Values{"key": {key}}), r)
if err != nil {
@@ -452,6 +504,14 @@ func (b *httpBackend) putViaServer(ctx context.Context, key string, r io.Reader,
}
nameJournalDevice(req, key)
req.ContentLength = size
if gzipped {
req.Header.Set("Content-Encoding", "gzip")
// The compressed length is not knowable without compressing twice, so
// the request goes out chunked. The hub's spool() already treats a -1
// length as the normal case — it is why it measures the body instead of
// believing a header.
req.ContentLength = -1
}
resp, err := b.do(req)
if err != nil {
return err
+187
View File
@@ -0,0 +1,187 @@
package remote
import (
"bytes"
"compress/gzip"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The mixed-fleet contract for the push leg, both directions, at the one place
// it is decided: what sign() advertised.
//
// New client ↔ OLD hub is the dangerous half. An old hub does not inflate, so a
// gzipped body would be stored verbatim under the sha256 of its plaintext —
// rejected outright for a blob, silently mis-stored for a journal. The old hub
// says nothing about encodings, so the client must send raw.
func TestPushCompressesOnlyWhenTheHubAdvertisesIt(t *testing.T) {
payload := strings.Repeat("# notes\nthe corpus is markdown and source, which gzips well\n", 500)
for _, tc := range []struct {
name string
signAnswer string
wantGzip bool
}{
{"old hub says nothing", `{"mode":"server"}`, false},
{"old hub with an empty list", `{"mode":"server","accept_encoding":[]}`, false},
{"hub speaks another codec", `{"mode":"server","accept_encoding":["zstd"]}`, false},
{"new hub", `{"mode":"server","accept_encoding":["gzip"]}`, true},
} {
t.Run(tc.name, func(t *testing.T) {
var gotBody []byte
var gotEncoding string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/store/sign") {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(tc.signAnswer))
return
}
gotEncoding = r.Header.Get("Content-Encoding")
gotBody, _ = io.ReadAll(r.Body)
w.Write([]byte(`{"ok":true}`))
}))
defer ts.Close()
be, err := Open(context.Background(), ts.URL+"/p/p-0123abcd")
if err != nil {
t.Fatal(err)
}
defer be.Close()
if err := be.Put(context.Background(), "blobs/"+strings.Repeat("a", 64),
strings.NewReader(payload), int64(len(payload))); err != nil {
t.Fatal(err)
}
if !tc.wantGzip {
if gotEncoding != "" {
t.Fatalf("Content-Encoding = %q, want none", gotEncoding)
}
if string(gotBody) != payload {
t.Fatal("body is not the plaintext it was handed")
}
return
}
if gotEncoding != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", gotEncoding)
}
if len(gotBody) >= len(payload) {
t.Fatalf("compressed body is %d bytes, larger than the %d it started as", len(gotBody), len(payload))
}
gz, err := gzip.NewReader(bytes.NewReader(gotBody))
if err != nil {
t.Fatal(err)
}
plain, err := io.ReadAll(gz)
if err != nil {
t.Fatal(err)
}
// Content addressing is over the UNCOMPRESSED bytes: what the hub
// inflates has to be exactly what the key names.
if string(plain) != payload {
t.Fatal("the body does not inflate to what was pushed")
}
})
}
}
// Incompressible content must cross untouched even against a hub that offers
// gzip — chunked large files are mostly already-compressed binary, where gzip
// pays CPU to make the payload ~0.1% bigger.
func TestPushSkipsIncompressibleContent(t *testing.T) {
payload := randomBytes(300 << 10)
var gotEncoding string
var gotLen int
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/store/sign") {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"mode":"server","accept_encoding":["gzip"]}`))
return
}
gotEncoding = r.Header.Get("Content-Encoding")
body, _ := io.ReadAll(r.Body)
gotLen = len(body)
w.Write([]byte(`{"ok":true}`))
}))
defer ts.Close()
be, err := Open(context.Background(), ts.URL+"/p/p-0123abcd")
if err != nil {
t.Fatal(err)
}
defer be.Close()
if err := be.Put(context.Background(), "blobs/"+strings.Repeat("b", 64),
bytes.NewReader(payload), int64(len(payload))); err != nil {
t.Fatal(err)
}
if gotEncoding != "" {
t.Fatalf("Content-Encoding = %q on incompressible content, want none", gotEncoding)
}
if gotLen != len(payload) {
t.Fatalf("wire carried %d bytes for a %d-byte payload", gotLen, len(payload))
}
}
// The presigned leg must stay raw even when the hub advertises gzip on the
// same sign() response. A direct upload goes to the object store under the
// sha256 of the PLAINTEXT with no hub in the path to inflate it, so a stray
// compression here would corrupt content addressing at rest — the one failure
// in this change that storage would keep forever rather than reject.
//
// Not reachable through any hub fixture in the tree: they all run on file://
// storage, which implements no PutSigner, so every plan is mode:"server".
// Managed hubs are S3/GCS-backed, which makes this the production path.
func TestPresignedUploadIsNeverCompressed(t *testing.T) {
payload := strings.Repeat("# highly compressible markdown\n", 2000)
var gotBody []byte
var gotEncoding string
var relayed bool
// One origin serving both roles: directTargetOK refuses a presign target
// that is neither https nor the hub's own origin, so a second httptest
// server would be declined and silently relayed instead — which would pass
// this test while proving nothing about putDirect.
var hub *httptest.Server
hub = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/store/sign"):
w.Header().Set("Content-Type", "application/json")
// The hub advertises gzip — it does on every sign answer — AND
// hands back a presigned destination. The client must honor the
// second and ignore the first.
w.Write([]byte(`{"mode":"direct","exists":false,"accept_encoding":["gzip"],"url":"` +
hub.URL + `/presigned-blob","method":"PUT"}`))
case r.URL.Path == "/presigned-blob":
gotEncoding = r.Header.Get("Content-Encoding")
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
default:
relayed = true
http.Error(w, "relayed through the hub", http.StatusNotFound)
}
}))
defer hub.Close()
be, err := Open(context.Background(), hub.URL+"/p/p-0123abcd")
if err != nil {
t.Fatal(err)
}
defer be.Close()
if err := be.Put(context.Background(), "blobs/"+strings.Repeat("c", 64),
strings.NewReader(payload), int64(len(payload))); err != nil {
t.Fatal(err)
}
if relayed {
t.Fatal("the upload was relayed through the hub; putDirect was never exercised")
}
if gotEncoding != "" {
t.Fatalf("presigned upload carried Content-Encoding %q — the object store has no hub to inflate it", gotEncoding)
}
if string(gotBody) != payload {
t.Fatalf("presigned upload sent %d bytes, want the %d-byte plaintext the key is the hash of",
len(gotBody), len(payload))
}
}
+189
View File
@@ -0,0 +1,189 @@
package syncer
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/webapp"
)
// The measurement that justifies the feature, taken where it is real: HTTP
// bodies in and out of the hub.
//
// It has to be measured HERE and not one layer up. A counter wrapping a
// remote.Backend sits ABOVE httpBackend and can only ever see the plaintext it
// hands down — it cannot observe transport encoding at all, and the file://
// backend those counters usually wrap is never compressed by this change.
// countingHub proxies the hub's own handler, so what it counts is the wire.
// countingHub is a hub whose HTTP bodies are counted in both directions.
func countingHub(t *testing.T, storage remote.Backend) (*httptest.Server, webapp.Project, *wireCount) {
t.Helper()
db, err := webapp.OpenProjectDB(filepath.Join(t.TempDir(), "projects.json"))
if err != nil {
t.Fatal(err)
}
p, _, err := db.GetOrCreate("vol", "")
if err != nil {
t.Fatal(err)
}
srv := &webapp.Server{
Root: storage, Projects: db, Refresh: 0,
Device: webapp.Identity{ID: "hubdev", Name: "hub", Author: "hub@test"},
Upload: webapp.UploadConfig{Enabled: true},
}
n := &wireCount{}
h := srv.Handler()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = &countingReadCloser{rc: r.Body, n: &n.up}
h.ServeHTTP(&countingRW{ResponseWriter: w, n: &n.down}, r)
}))
t.Cleanup(ts.Close)
return ts, p, n
}
type wireCount struct{ up, down atomic.Int64 }
func (w *wireCount) reset() { w.up.Store(0); w.down.Store(0) }
type countingReadCloser struct {
rc interface {
Read([]byte) (int, error)
Close() error
}
n *atomic.Int64
}
func (c *countingReadCloser) Read(p []byte) (int, error) {
n, err := c.rc.Read(p)
c.n.Add(int64(n))
return n, err
}
func (c *countingReadCloser) Close() error { return c.rc.Close() }
type countingRW struct {
http.ResponseWriter
n *atomic.Int64
}
func (c *countingRW) Write(p []byte) (int, error) {
n, err := c.ResponseWriter.Write(p)
c.n.Add(int64(n))
return n, err
}
// A text-heavy project must cross the wire at ≥2.5x reduction in BOTH
// directions, and the same project must still converge byte for byte.
func TestCompressionWireRatio(t *testing.T) {
storage := sharedRemote(t)
ts, p, wire := countingHub(t, storage)
viaServer, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
if err != nil {
t.Fatal(err)
}
defer viaServer.Close()
a := newDevice(t, "deva", viaServer)
raw := 0
for i := 0; i < 20; i++ {
body := fmt.Sprintf("# note %d\n\n%s", i, textish(i))
write(t, a.Folder, fmt.Sprintf("notes/note-%02d.md", i), body)
raw += len(body)
}
wire.reset()
cycle(t, a)
up := wire.up.Load()
t.Logf("push: %d bytes on the wire for a %d-byte corpus (%.2fx)", up, raw, float64(raw)/float64(up))
if ratio := float64(raw) / float64(up); ratio < 2.5 {
t.Fatalf("push carried %d bytes for a %d-byte corpus (%.2fx), want at least 2.5x", up, raw, ratio)
}
// A second device pulls the same corpus back down through the same hub.
b, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
if err != nil {
t.Fatal(err)
}
defer b.Close()
dev := newDevice(t, "devb", b)
wire.reset()
if res := cycle(t, dev); res.PulledOps != 20 {
t.Fatalf("pulled %d ops, want 20", res.PulledOps)
}
down := wire.down.Load()
t.Logf("pull: %d bytes on the wire for a %d-byte corpus (%.2fx)", down, raw, float64(raw)/float64(down))
if ratio := float64(raw) / float64(down); ratio < 2.5 {
t.Fatalf("pull carried %d bytes for a %d-byte corpus (%.2fx), want at least 2.5x", down, raw, ratio)
}
// Compression is a transport concern: the bytes on disk are the bytes that
// were written, or none of the above matters.
for i := 0; i < 20; i++ {
want := fmt.Sprintf("# note %d\n\n%s", i, textish(i))
if got := read(t, dev.Folder, fmt.Sprintf("notes/note-%02d.md", i)); got != want {
t.Fatalf("note %d did not converge", i)
}
}
}
// Already-compressed content must cross within ~1% of its raw size, in both
// directions: the skip path exists so gzip does not pay CPU to grow a JPEG.
func TestCompressionSkipsIncompressiblePayload(t *testing.T) {
storage := sharedRemote(t)
ts, p, wire := countingHub(t, storage)
viaServer, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
if err != nil {
t.Fatal(err)
}
defer viaServer.Close()
a := newDevice(t, "deva", viaServer)
payload := string(pseudoJPEG(512 << 10))
write(t, a.Folder, "photo.jpg", payload)
wire.reset()
cycle(t, a)
if up := wire.up.Load(); float64(up) > float64(len(payload))*1.01+4096 {
t.Fatalf("push carried %d bytes for a %d-byte incompressible payload", up, len(payload))
}
b, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID)
if err != nil {
t.Fatal(err)
}
defer b.Close()
dev := newDevice(t, "devb", b)
wire.reset()
cycle(t, dev)
if down := wire.down.Load(); float64(down) > float64(len(payload))*1.01+4096 {
t.Fatalf("pull carried %d bytes for a %d-byte incompressible payload", down, len(payload))
}
if read(t, dev.Folder, "photo.jpg") != payload {
t.Fatal("the incompressible payload did not converge")
}
}
// textish is a stand-in for the real corpus: markdown and source, 510 KB.
func textish(seed int) string {
var b []byte
for i := 0; i < 120; i++ {
b = append(b, fmt.Sprintf("- item %d/%d: the sync wire carries markdown and source files\n", seed, i)...)
}
return string(b)
}
// pseudoJPEG stands in for content that is already compressed.
func pseudoJPEG(n int) []byte {
b := make([]byte, n)
x := uint32(2463534242)
for i := range b {
x ^= x << 13
x ^= x >> 17
x ^= x << 5
b[i] = byte(x)
}
return b
}
+222
View File
@@ -0,0 +1,222 @@
package webapp
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
)
// spyHub is a proxy in front of an existing hub that watches ONE client: it
// counts the bodies both ways and remembers which content encodings crossed.
//
// Per-client, and deliberately not countingHub with a mark taken between
// phases. `bdrive init` starts a daemon, so a mark placed after it measures
// whatever the daemon had not already done — which is how the first draft of
// this test reported a 282x pull ratio and proved nothing. Giving the client
// its own front door makes every counted byte and every observed header that
// client's, whenever it happened.
type spy struct {
up, down atomic.Int64
mu sync.Mutex
sentEnc map[string]bool // Content-Encoding the client PUT with
gotEnc map[string]bool // Content-Encoding the hub answered it with
}
func (s *spy) note(m map[string]bool, enc string) {
s.mu.Lock()
defer s.mu.Unlock()
m[enc] = true
}
func (s *spy) saw(m map[string]bool, enc string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return m[enc]
}
func spyHub(t *testing.T, inner *httptest.Server) (*httptest.Server, *spy) {
t.Helper()
sp := &spy{sentEnc: map[string]bool{}, gotEnc: map[string]bool{}}
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/store/") {
sp.note(sp.sentEnc, r.Header.Get("Content-Encoding"))
}
r.Body = &countingReader{r: r.Body, n: &sp.up}
rec := &encSpyRW{ResponseWriter: w, sp: sp}
inner.Config.Handler.ServeHTTP(rec, r)
}))
t.Cleanup(proxy.Close)
return proxy, sp
}
type encSpyRW struct {
http.ResponseWriter
sp *spy
noted bool
}
func (e *encSpyRW) WriteHeader(code int) {
e.note()
e.ResponseWriter.WriteHeader(code)
}
func (e *encSpyRW) Write(p []byte) (int, error) {
e.note()
n, err := e.ResponseWriter.Write(p)
e.sp.down.Add(int64(n))
return n, err
}
func (e *encSpyRW) note() {
if !e.noted {
e.noted = true
e.sp.note(e.sp.gotEnc, e.Header().Get("Content-Encoding"))
}
}
// Transport compression against the REAL binary that predates it (oldBinRef,
// pinned at the commit before delta sync — which is also the commit before
// this). Both halves of the mixed-fleet claim, driven end to end rather than
// argued:
//
// - the old binary's PULL is compressed with no client change, because
// net/http asks for gzip and inflates on its own. This is the whole reason
// the read leg needed no negotiation.
// - the old binary's PUSH stays raw and still converges, because it never
// reads the accept_encoding sign() now answers — the same field an old HUB
// omits, which is what keeps a new client from posting gzip bytes to a hub
// that would store them under the sha256 of the plaintext.
func TestCompressionE2E_OldBinaryPullsCompressedAndPushesRaw(t *testing.T) {
inner := startTestHub(t)
fresh := newCLIEnvOn(t, inner)
oldDoor, sp := spyHub(t, inner)
old := newCLIEnvBin(t, oldDoor, buildOldBinary(t))
dirA := filepath.Join(t.TempDir(), "proj")
initProject(t, fresh, dirA, "compress-e2e", false)
raw := 0
for i := 0; i < 40; i++ {
body := []byte(fmt.Sprintf("# note %d\n\n%s", i, markdownish(i)))
if err := os.WriteFile(filepath.Join(dirA, fmt.Sprintf("note-%02d.md", i)), body, 0o644); err != nil {
t.Fatal(err)
}
raw += len(body)
}
syncNow(t, fresh, dirA)
// The old binary pulls the corpus it has never seen. Everything it has
// ever received is behind its own door, so no window has to be guessed.
dirOld := filepath.Join(t.TempDir(), "proj")
initProject(t, old, dirOld, "compress-e2e", true)
syncNow(t, old, dirOld)
for i := 0; i < 40; i++ {
want := fmt.Sprintf("# note %d\n\n%s", i, markdownish(i))
got, err := os.ReadFile(filepath.Join(dirOld, fmt.Sprintf("note-%02d.md", i)))
if err != nil || string(got) != want {
t.Fatalf("old binary did not converge on note %d: %v", i, err)
}
}
pulled := sp.down.Load()
t.Logf("old binary received %d bytes total for a %d-byte corpus (%.2fx)", pulled, raw, float64(raw)/float64(pulled))
if !sp.saw(sp.gotEnc, "gzip") {
t.Fatal("the hub never answered the old binary with Content-Encoding: gzip — the free win is not happening")
}
// Everything that client has ever been sent, sign-in and listings included,
// against the corpus alone.
if ratio := float64(raw) / float64(pulled); ratio < 2.5 {
t.Fatalf("old binary received %d bytes for a %d-byte corpus (%.2fx), want at least 2.5x", pulled, raw, ratio)
}
// The old binary pushes. It never learned to read accept_encoding, so every
// body it PUT must be raw — asserted on the headers rather than on a byte
// count, so a daemon tick cannot change the answer.
back := []byte("# from the old client\n\n" + markdownish(99))
if err := os.WriteFile(filepath.Join(dirOld, "from-old.md"), back, 0o644); err != nil {
t.Fatal(err)
}
syncNow(t, old, dirOld)
if sp.saw(sp.sentEnc, "gzip") {
t.Fatal("the old binary sent a compressed body — it cannot know how, so the hub must have been asked to inflate one it never sent")
}
if !sp.saw(sp.sentEnc, "") {
t.Fatal("the old binary never PUT anything; the push half was not exercised")
}
syncNow(t, fresh, dirA)
if got, err := os.ReadFile(filepath.Join(dirA, "from-old.md")); err != nil || !bytes.Equal(got, back) {
t.Fatalf("the new client did not converge on the old binary's raw push: %v", err)
}
}
// Delta sync grew the key space by two classes after this feature was written.
// Both ride the same compressed wire, and both keep the gates that make them
// safe — because those gates read the spooled PLAINTEXT, below the inflate.
//
// The manifest is the one that matters: it is never presigned, so in production
// it ALWAYS goes through the relay path that compresses. A large file that
// syncs as chunks + manifest is the case where a gzip body would otherwise have
// been the thing the write-once compare and the chunks-exist gate saw.
func TestCompressionE2E_ChunksAndManifestsOverGzip(t *testing.T) {
inner := startTestHub(t)
door, sp := spyHub(t, inner)
a := newCLIEnvBin(t, door, "")
b := newCLIEnvOn(t, inner)
dirA := filepath.Join(t.TempDir(), "proj")
initProject(t, a, dirA, "compress-chunks", false)
// Past the chunking threshold, and compressible — so the manifest, the
// chunks and the journal all take the gzip path rather than the skip path.
var big []byte
for len(big) < 12<<20 {
big = append(big, fmt.Sprintf("line %d: %s\n", len(big), markdownish(len(big)%7))...)
}
if err := os.WriteFile(filepath.Join(dirA, "big.md"), big, 0o644); err != nil {
t.Fatal(err)
}
syncNow(t, a, dirA)
// The push really did compress — otherwise the rest of this test is only
// re-testing delta sync.
if !sp.saw(sp.sentEnc, "gzip") {
t.Fatal("the chunked push sent nothing compressed")
}
dirB := filepath.Join(t.TempDir(), "proj")
initProject(t, b, dirB, "compress-chunks", true)
syncNow(t, b, dirB)
got, err := os.ReadFile(filepath.Join(dirB, "big.md"))
if err != nil || !bytes.Equal(got, big) {
t.Fatalf("chunked file did not converge over the compressed wire: %v, %d bytes", err, len(got))
}
// Edit it: the second push re-puts a manifest under a NEW key and re-sends
// only the changed chunks, all compressed. If the write-once compare or the
// chunks-exist gate had been reading gzip bytes, this is where it breaks.
copy(big[6<<20:], []byte("EDITED"))
if err := os.WriteFile(filepath.Join(dirA, "big.md"), big, 0o644); err != nil {
t.Fatal(err)
}
syncNow(t, a, dirA)
syncNow(t, b, dirB)
if got, err := os.ReadFile(filepath.Join(dirB, "big.md")); err != nil || !bytes.Equal(got, big) {
t.Fatalf("the edit did not converge: %v", err)
}
// A repeat sync must be a no-op, not a manifest conflict: an identical
// re-put is the retry path, and it is compared on the plaintext.
syncNow(t, a, dirA)
}
// markdownish is a stand-in for the real corpus — markdown and source, the
// content that made compression worth doing.
func markdownish(seed int) string {
var b []byte
for i := 0; i < 60; i++ {
b = append(b, fmt.Sprintf("- item %d/%d: the sync wire carries markdown and source files\n", seed, i)...)
}
return string(b)
}
+113 -5
View File
@@ -2,6 +2,7 @@ package webapp
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
@@ -45,6 +46,26 @@ func validStoreKey(key string) bool {
chunkKeyRe.MatchString(key) || manifestKeyRe.MatchString(key)
}
// storeAcceptEncoding is what handleStoreSign tells a client this hub will
// inflate on a relayed PUT. A hub older than this answers without the field,
// and the client sends raw — which is the whole mixed-fleet story for the push
// leg (the pull leg needs no negotiation at all, see remote/compress.go).
var storeAcceptEncoding = []string{"gzip"}
// maxInflatedPut bounds what one compressed PUT may write to the hub's disk.
//
// spool() is unbounded, which is safe exactly as long as a byte on the wire
// costs a byte on disk. Content-Encoding breaks that: a 1 MB body can inflate
// to an arbitrary write, and it lands BEFORE CheckWrite can refuse it, because
// nothing knows the size until the inflate is done. The bound applies only when
// the body declares an encoding, so no honest raw push that works today can
// start failing.
//
// ponytail: 256 MiB, mirroring maxImportBlob on the archive path — a precedent,
// not a measurement. A real workload that hits it wants a server-side knob, not
// a bigger constant.
const maxInflatedPut = 256 << 20
// storeSource returns the volume's RemoteSource; only real beardrive
// remotes have a store to expose.
func storeSource(v *volume, w http.ResponseWriter) *RemoteSource {
@@ -197,7 +218,25 @@ func (s *Server) handleStoreList(v *volume, w http.ResponseWriter, r *http.Reque
storageErr(w, http.StatusBadGateway, "storage is temporarily unavailable", err)
return
}
writeJSON(w, map[string]any{"objects": objs})
writeStoreJSON(w, r, map[string]any{"objects": objs})
}
// writeStoreJSON is writeJSON that compresses when the caller accepts it. The
// listing is the first call of every sync cycle on every device, it is JSON,
// and it is highly repetitive — one key per blob — so it is compressed without
// probing. Only this route uses it: writeJSON serves the whole browser API and
// is out of this change's scope, and handleStoreExists answers one boolean,
// which is smaller than a gzip header.
func writeStoreJSON(w http.ResponseWriter, r *http.Request, v any) {
w.Header().Set("Content-Type", "application/json")
if !remote.AcceptsGzip(r) {
json.NewEncoder(w).Encode(v)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
json.NewEncoder(gz).Encode(v)
}
func (s *Server) handleStoreGet(v *volume, w http.ResponseWriter, r *http.Request) {
@@ -226,6 +265,17 @@ func (s *Server) handleStoreGet(v *volume, w http.ResponseWriter, r *http.Reques
return
}
defer rc.Close()
// Compression is decided before a single header is written, because
// Content-Encoding cannot be added after the first Write.
var src io.Reader = rc
gzipOK := false
if remote.AcceptsGzip(r) {
src, gzipOK, err = remote.Compressible(rc)
if err != nil {
storageErr(w, http.StatusBadGateway, "could not read the object", err)
return
}
}
w.Header().Set("Content-Type", "application/octet-stream")
// The sync proxy is a stored-bytes door like the other two: a
// cookie-authenticated GET whose URL one member can hand another, answering
@@ -234,8 +284,20 @@ func (s *Server) handleStoreGet(v *volume, w http.ResponseWriter, r *http.Reques
// Recorded, never checked. This is a device syncing: refusing it here
// surfaces as ErrForbidden, which the syncer reads as "access is gone —
// pause and touch nothing". Sync must not break over a bill.
//
// The counter wraps the SOCKET and gzip writes into it, never the other way
// round: RecordEgress is a bandwidth meter, so it has to report what left
// the machine. Inverted, it silently bills plaintext for every compressed
// response and no test fails.
cw := &countingWriter{w: w}
io.Copy(cw, rc)
if gzipOK {
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(cw)
io.Copy(gz, src)
gz.Close()
} else {
io.Copy(cw, src)
}
s.quota().RecordEgress(s.orgOf(r.PathValue("project")), cw.n)
}
@@ -316,7 +378,7 @@ func (s *Server) handleStoreSign(v *volume, w http.ResponseWriter, r *http.Reque
return
}
if exists, err := rs.Backend.Exists(r.Context(), req.Key); err == nil && exists {
writeJSON(w, map[string]any{"mode": "direct", "exists": true})
writeJSON(w, map[string]any{"mode": "direct", "exists": true, "accept_encoding": storeAcceptEncoding})
return
}
if signer, ok := rs.Backend.(remote.PutSigner); ok {
@@ -335,13 +397,14 @@ func (s *Server) handleStoreSign(v *volume, w http.ResponseWriter, r *http.Reque
writeJSON(w, map[string]any{
"mode": "direct", "url": signed.URL, "method": signed.Method,
"headers": signed.Headers, "expires": signed.Expires.UTC(),
"accept_encoding": storeAcceptEncoding,
})
return
}
s.claimGrant(project, req.Key) // nothing was granted: give it back
}
}
writeJSON(w, map[string]any{"mode": "server"})
writeJSON(w, map[string]any{"mode": "server", "accept_encoding": storeAcceptEncoding})
}
// journalOps reads the operations a spooled journal body carries, exactly the
@@ -517,6 +580,32 @@ func journalKeepsItsOps(ctx context.Context, be remote.Backend, key string, ops
return true, nil
}
// storePutBody hands back the plaintext of a PUT body, inflating it when the
// client declared an encoding, and reports whether it did. The reader it
// returns is capped one byte past maxInflatedPut so a bomb can never write more
// than that to disk before the caller measures it and refuses.
//
// It answers the client itself on the two ways this can be the client's fault:
// an encoding this hub does not implement, and a body that says gzip and is
// not one (gzip.NewReader reads the header eagerly, so that is caught here
// rather than halfway through a spool).
func storePutBody(w http.ResponseWriter, r *http.Request) (io.Reader, bool, bool) {
enc := strings.TrimSpace(r.Header.Get("Content-Encoding"))
if enc == "" {
return r.Body, false, true
}
if !strings.EqualFold(enc, "gzip") {
http.Error(w, "unsupported Content-Encoding "+enc, http.StatusUnsupportedMediaType)
return nil, false, false
}
gz, err := gzip.NewReader(r.Body)
if err != nil {
http.Error(w, "body declares Content-Encoding: gzip but is not gzip", http.StatusBadRequest)
return nil, false, false
}
return io.LimitReader(gz, maxInflatedPut+1), true, true
}
func (s *Server) handleStorePut(v *volume, w http.ResponseWriter, r *http.Request) {
rs := storeSource(v, w)
if rs == nil {
@@ -536,13 +625,32 @@ func (s *Server) handleStorePut(v *volume, w http.ResponseWriter, r *http.Reques
// (Content-Length is -1 on any chunked request, which made every unsized
// put free), and how many ops a journal write actually authors.
// Cost: one temp file per put on the hub's busiest write path.
tmp, size, sum, err := spool(r.Body)
//
// Inflating sits ABOVE the spool, because every single thing this handler
// goes on to be sure of is a property of the plaintext: the sha a blob key
// promises, the ops journalOps counts, the size CheckWrite bills, and the
// append-only check. Nothing below this line knows compression happened.
body, inflated, ok := storePutBody(w, r)
if !ok {
return
}
tmp, size, sum, err := spool(body)
if err != nil {
if inflated {
// A gzip stream that truncates or fails its CRC is the client's
// body, not the hub's storage; 502 would blame the wrong machine.
http.Error(w, "could not decompress the body", http.StatusBadRequest)
return
}
storageErr(w, http.StatusBadGateway, "could not store the object", err)
return
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if inflated && size > maxInflatedPut {
http.Error(w, "compressed body inflates past this hub's limit", http.StatusRequestEntityTooLarge)
return
}
// Blobs and chunks are content-addressed: the key IS the content's hash.
// Manifests are not — their key is the whole FILE's sha, which the hub
// cannot check without reading every chunk; readers verify by reassembly.
+329
View File
@@ -0,0 +1,329 @@
package webapp
import (
"bytes"
"compress/gzip"
"encoding/json"
"io"
"math/rand"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
)
// Transport compression on /store/*. Everything here is about one invariant
// holding while the wire changes underneath it: content addressing is over the
// UNCOMPRESSED bytes. The hub inflates before it hashes, stores plaintext, and
// bills plaintext — the only thing that is ever compressed is the transfer.
const textCorpus = "# release notes\n\nthe corpus is markdown and source files, " +
"five to ten kilobytes each, which is exactly what gzip is good at.\n"
// meterQuota records what the hub bills, separately per meter: usage is a
// storage bill (uncompressed), egress is a bandwidth bill (compressed).
type meterQuota struct {
UnlimitedQuota
mu sync.Mutex
usage int64
egress int64
}
func (q *meterQuota) RecordUsage(_ string, b int64) {
q.mu.Lock()
defer q.mu.Unlock()
q.usage += b
}
func (q *meterQuota) RecordEgress(_ string, b int64) {
q.mu.Lock()
defer q.mu.Unlock()
q.egress += b
}
// doRaw sends an exact body with exact headers — reads_test.go's doHdr
// JSON-marshals what it is given, and here the bytes on the wire are the
// subject.
func doRaw(t *testing.T, h http.Handler, method, url string, body []byte, hdr map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, url, bytes.NewReader(body))
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func gzipBytes(t *testing.T, b []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(b); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func gunzipBytes(t *testing.T, b []byte) []byte {
t.Helper()
gz, err := gzip.NewReader(bytes.NewReader(b))
if err != nil {
t.Fatal(err)
}
out, err := io.ReadAll(gz)
if err != nil {
t.Fatal(err)
}
return out
}
func incompressible(n int) []byte {
b := make([]byte, n)
rand.New(rand.NewSource(7)).Read(b)
return b
}
// The pull leg. It needs no client change at all — net/http sends
// Accept-Encoding: gzip on its own and inflates transparently — so this is what
// a device built before this feature existed gets for free, and it must still
// arrive as the exact stored bytes.
func TestStoreGetCompresses(t *testing.T) {
srv, p, root := newHub(t, false, nil)
q := &meterQuota{}
srv.Quota = q
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
text := strings.Repeat(textCorpus, 200)
f.put("deva", "notes.md", text)
h := srv.Handler()
key := "blobs/" + shaOf(text)
url := "/api/p/" + p.ID + "/store/object?key=" + key
rec := doRaw(t, h, "GET", url, nil, map[string]string{"Accept-Encoding": "gzip"})
if rec.Code != 200 || rec.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("gzip GET: %d %q", rec.Code, rec.Header().Get("Content-Encoding"))
}
wire := rec.Body.Bytes()
if got := string(gunzipBytes(t, wire)); got != text {
t.Fatal("the compressed response does not inflate to the stored bytes")
}
if ratio := float64(len(text)) / float64(len(wire)); ratio < 2.5 {
t.Fatalf("wire ratio %.2fx, want at least 2.5x", ratio)
}
// The bandwidth meter reports what left the socket, not what was stored.
// Inverted (counter inside the gzip writer) this reads len(text) and
// nothing else fails.
if q.egress != int64(len(wire)) {
t.Fatalf("RecordEgress = %d, want the compressed %d", q.egress, len(wire))
}
// A caller that did not ask gets exactly what it always got.
rec = doRaw(t, h, "GET", url, nil, nil)
if rec.Code != 200 || rec.Header().Get("Content-Encoding") != "" || rec.Body.String() != text {
t.Fatalf("plain GET: %d %q", rec.Code, rec.Header().Get("Content-Encoding"))
}
}
// Already-compressed content must cross untouched: gzip on a JPEG pays CPU to
// make the payload ~0.1% bigger.
func TestStoreGetSkipsIncompressible(t *testing.T) {
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
raw := incompressible(300 << 10)
f.put("deva", "photo.jpg", string(raw))
h := srv.Handler()
url := "/api/p/" + p.ID + "/store/object?key=blobs/" + shaOf(string(raw))
for _, ae := range []string{"gzip", ""} {
rec := doRaw(t, h, "GET", url, nil, map[string]string{"Accept-Encoding": ae})
if rec.Code != 200 || rec.Header().Get("Content-Encoding") != "" {
t.Fatalf("Accept-Encoding %q: %d %q", ae, rec.Code, rec.Header().Get("Content-Encoding"))
}
if n := rec.Body.Len(); float64(n) > float64(len(raw))*1.01 {
t.Fatalf("wire carried %d bytes for a %d-byte payload", n, len(raw))
}
if !bytes.Equal(rec.Body.Bytes(), raw) {
t.Fatal("body is not the stored bytes")
}
}
}
// The listing is the first call of every cycle on every device, and it is
// repetitive JSON.
func TestStoreListCompresses(t *testing.T) {
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
for i := 0; i < 50; i++ {
f.put("deva", "notes/"+strings.Repeat("x", i+1)+".md", textCorpus+strings.Repeat("y", i+1))
}
h := srv.Handler()
url := "/api/p/" + p.ID + "/store/list?prefix=blobs/"
rec := doRaw(t, h, "GET", url, nil, map[string]string{"Accept-Encoding": "gzip"})
if rec.Code != 200 || rec.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("gzip list: %d %q", rec.Code, rec.Header().Get("Content-Encoding"))
}
var list struct {
Objects []remote.Object `json:"objects"`
}
if err := json.Unmarshal(gunzipBytes(t, rec.Body.Bytes()), &list); err != nil {
t.Fatal(err)
}
if len(list.Objects) != 50 {
t.Fatalf("objects = %d, want 50", len(list.Objects))
}
plain := doRaw(t, h, "GET", url, nil, nil)
if plain.Header().Get("Content-Encoding") != "" {
t.Fatal("a caller that did not ask for gzip got it anyway")
}
if rec.Body.Len() >= plain.Body.Len() {
t.Fatalf("compressed listing is %d bytes, raw is %d", rec.Body.Len(), plain.Body.Len())
}
}
// The push leg is negotiated, and this is the advertisement the client reads.
func TestStoreSignAdvertisesGzip(t *testing.T) {
srv, p, _ := newHub(t, true, nil)
h := srv.Handler()
rec := do(t, h, "POST", "/api/p/"+p.ID+"/store/sign",
map[string]any{"key": "blobs/" + shaOf("hi"), "size": 2})
var plan struct {
Mode string `json:"mode"`
AcceptEncoding []string `json:"accept_encoding"`
}
mustJSON(t, rec, &plan)
if plan.Mode != "server" || len(plan.AcceptEncoding) != 1 || plan.AcceptEncoding[0] != "gzip" {
t.Fatalf("plan = %+v", plan)
}
}
// A compressed PUT is stored as plaintext under the plaintext's hash, and
// billed for the plaintext — the object stored is uncompressed, so that is what
// the storage bill is for.
func TestStorePutInflatesBeforeItHashes(t *testing.T) {
srv, p, root := newHub(t, true, nil)
q := &meterQuota{}
srv.Quota = q
h := srv.Handler()
text := strings.Repeat(textCorpus, 200)
key := "blobs/" + shaOf(text)
body := gzipBytes(t, []byte(text))
if len(body) >= len(text) {
t.Fatal("fixture is not actually compressed")
}
rec := doRaw(t, h, "PUT", "/api/p/"+p.ID+"/store/object?key="+key, body,
map[string]string{"Content-Encoding": "gzip"})
if rec.Code != 200 {
t.Fatalf("gzip put: %d %s", rec.Code, rec.Body)
}
stored, err := os.ReadFile(filepath.Join(root, p.ID, key))
if err != nil {
t.Fatal(err)
}
if string(stored) != text {
t.Fatal("storage does not hold the plaintext")
}
if q.usage != int64(len(text)) {
t.Fatalf("RecordUsage = %d, want the uncompressed %d", q.usage, len(text))
}
}
// Content addressing is unchanged: a compressed body that decodes to bytes
// which are not what the key names is refused, exactly like a raw one.
func TestStorePutGzipMustStillHashToItsKey(t *testing.T) {
srv, p, _ := newHub(t, true, nil)
h := srv.Handler()
body := gzipBytes(t, []byte("the plaintext nobody asked for"))
rec := doRaw(t, h, "PUT", "/api/p/"+p.ID+"/store/object?key=blobs/"+shaOf("something else"), body,
map[string]string{"Content-Encoding": "gzip"})
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "does not hash to its key") {
t.Fatalf("mismatched gzip put: %d %s", rec.Code, rec.Body)
}
}
// Content-Encoding severs the "a byte on the wire costs a byte on disk"
// relationship that made spool() safe unbounded, and the inflate lands before
// CheckWrite can refuse anything. A small body must not become an arbitrary
// hub-side write.
func TestStorePutRefusesAGzipBomb(t *testing.T) {
srv, p, root := newHub(t, true, nil)
h := srv.Handler()
// ~256 MiB of zeros compresses to a few hundred KB — the shape of the bomb,
// small enough to keep the test fast.
bomb := gzipBytes(t, make([]byte, maxInflatedPut+1))
if len(bomb) > 1<<20 {
t.Fatalf("bomb fixture is %d bytes, expected it to compress much harder", len(bomb))
}
rec := doRaw(t, h, "PUT", "/api/p/"+p.ID+"/store/object?key=blobs/"+strings.Repeat("a", 64), bomb,
map[string]string{"Content-Encoding": "gzip"})
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("bomb: %d %s, want 413", rec.Code, rec.Body)
}
blobs, _ := os.ReadDir(filepath.Join(root, p.ID, "blobs"))
if len(blobs) != 0 {
t.Fatalf("the bomb stored %d objects", len(blobs))
}
}
// The two ways a declared encoding can be the client's fault, kept apart from
// "the hub's storage broke".
func TestStorePutRejectsBadEncodings(t *testing.T) {
srv, p, _ := newHub(t, true, nil)
h := srv.Handler()
url := "/api/p/" + p.ID + "/store/object?key=blobs/" + shaOf("hi")
rec := doRaw(t, h, "PUT", url, []byte("not gzip at all"), map[string]string{"Content-Encoding": "gzip"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("lying Content-Encoding: %d %s, want 400", rec.Code, rec.Body)
}
rec = doRaw(t, h, "PUT", url, []byte("hi"), map[string]string{"Content-Encoding": "br"})
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("unsupported codec: %d %s, want 415", rec.Code, rec.Body)
}
// Truncated gzip: the header parses, the stream does not finish.
full := gzipBytes(t, []byte(strings.Repeat(textCorpus, 50)))
rec = doRaw(t, h, "PUT", url, full[:len(full)/2], map[string]string{"Content-Encoding": "gzip"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("truncated gzip: %d %s, want 400", rec.Code, rec.Body)
}
}
// A journal body is read for its ops after the inflate, so every rule that
// protects the log still applies to a compressed push — including the
// append-only one, which is the invariant a compressed body could otherwise
// have smuggled past.
func TestStorePutGzippedJournalKeepsItsOps(t *testing.T) {
srv, p, _ := newHub(t, true, nil)
h := srv.Handler()
url := "/api/p/" + p.ID + "/store/object?key=journal/deva.jsonl"
hdr := map[string]string{"Content-Encoding": "gzip", "X-Bdrive-Device": "deva"}
line := func(seq int64, path string) []byte {
b, err := json.Marshal(journal.Op{
Kind: journal.KindPut, Path: path, Seq: seq, Device: "deva",
Blob: shaOf(path), Size: 1, Lamport: seq,
})
if err != nil {
t.Fatal(err)
}
return append(b, '\n')
}
two := append(line(1, "a.md"), line(2, "b.md")...)
if rec := doRaw(t, h, "PUT", url, gzipBytes(t, two), hdr); rec.Code != 200 {
t.Fatalf("gzipped journal put: %d %s", rec.Code, rec.Body)
}
// Same journal, one op short: refused, compressed or not.
if rec := doRaw(t, h, "PUT", url, gzipBytes(t, line(1, "a.md")), hdr); rec.Code != http.StatusConflict {
t.Fatalf("truncating journal put: %d %s, want 409", rec.Code, rec.Body)
}
}