mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
@@ -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 5–10 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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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, 5–10 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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user