mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
* feat(sync): delta sync — large files move as content-defined chunks Files over 4 MiB push as chunks/<sha256> pieces plus a manifests/<sha256> chunk list keyed by the whole file's hash, so Op.Blob alone locates it and the journal format is byte-identical. A 1-byte edit to a 20 MiB file now transfers ~2 MB instead of ~21 MB, both directions; chunk boundaries come from a rolling hash (restic/chunker), so front insertions stay cheap. The hub reassembles whole blobs on demand (spool, verify, backfill, serve), which is the entire backward-compatibility story: old clients ask for blobs/<sha> and never learn anything changed. Proven by e2e tests that build the real pre-change binary from the pinned merge-base commit. The push skip-proof is one Exists per chunk — three cheaper proxies (local basis, manifest existence, stored manifest content) each proved false or forgeable across four CTO review rounds and are recorded in the code comment. Hub-side, manifests are write-once and must name only chunks the store holds; reassembly is bounded at 256 MiB against amplified manifests. Also: per-file sync ceiling 32 -> 100 MiB; import refuses archives whose journals reference content they do not hold (--allow-incomplete overrides). Deploy hubs before clients: old hubs refuse chunk keys (push degrades to offline-retry), and old clients cap reads at 32 MiB so 32-100 MiB files report "blob corrupt on remote" until the client upgrades. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7 * fix(test): fetch the pinned pre-delta commit on shallow CI clones buildOldBinary archives the pinned merge-base sha, which a fetch-depth-1 actions/checkout does not have — all three old-binary e2e tests failed in CI with exit 128 while passing on any full local clone. On archive failure, fetch just that commit (--depth=1, one object; actions/checkout persists credentials so the in-job fetch works) and retry. Verified against a real GitHub shallow clone: archive fails, the single-sha fetch succeeds, archive then yields the pre-delta tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6nqxi5a9qcENmJvrgBJF7 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
167 lines
4.4 KiB
Go
167 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
)
|
|
|
|
func openFileBackend(t *testing.T) remote.Backend {
|
|
t.Helper()
|
|
be, err := remote.Open(context.Background(), "file://"+t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { be.Close() })
|
|
return be
|
|
}
|
|
|
|
func put(t *testing.T, be remote.Backend, key, content string) {
|
|
t.Helper()
|
|
if err := be.Put(context.Background(), key, strings.NewReader(content), int64(len(content))); err != nil {
|
|
t.Fatalf("put %s: %v", key, err)
|
|
}
|
|
}
|
|
|
|
func blobKey(content string) string {
|
|
sum := sha256.Sum256([]byte(content))
|
|
return "blobs/" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func TestExportImportRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
src := openFileBackend(t)
|
|
|
|
// A project as two devices left it: two journals, two blobs.
|
|
blobA, blobB := "hello from device one", "and from device two"
|
|
put(t, src, blobKey(blobA), blobA)
|
|
put(t, src, blobKey(blobB), blobB)
|
|
put(t, src, "journal/dev-1.jsonl", `{"path":"a.md"}`+"\n")
|
|
put(t, src, "journal/dev-2.jsonl", `{"path":"b.md"}`+"\n")
|
|
|
|
var buf bytes.Buffer
|
|
man := exportManifest{Project: "wiki", Remote: "https://old.example/p/p-00000000", ExportedAt: time.Now().UTC()}
|
|
blobs, journals, _, err := exportStore(ctx, src, &buf, man)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if blobs != 2 || journals != 2 {
|
|
t.Fatalf("export counted %d blobs, %d journals; want 2, 2", blobs, journals)
|
|
}
|
|
|
|
// The manifest comes first and names the project.
|
|
gz, err := gzip.NewReader(bytes.NewReader(buf.Bytes()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tr := tar.NewReader(gz)
|
|
gotMan, first, err := readManifest(tr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first != nil || gotMan.Project != "wiki" {
|
|
t.Fatalf("manifest = %+v (first=%v), want project wiki consumed first", gotMan, first)
|
|
}
|
|
|
|
dst := openFileBackend(t)
|
|
blobs, journals, _, err = importStore(ctx, dst, tr, first, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if blobs != 2 || journals != 2 {
|
|
t.Fatalf("import counted %d blobs, %d journals; want 2, 2", blobs, journals)
|
|
}
|
|
|
|
// Every key round-trips byte-identical.
|
|
for _, prefix := range []string{"journal/", "blobs/"} {
|
|
objs, err := src.List(ctx, prefix)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, o := range objs {
|
|
want := read(t, src, o.Key)
|
|
got := read(t, dst, o.Key)
|
|
if want != got {
|
|
t.Errorf("%s: imported %q, want %q", o.Key, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImportRejectsCorruptBlob(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
gz := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gz)
|
|
// Content that does not hash to its key.
|
|
writeTarFile(tw, blobKey("original"), []byte("tampered"))
|
|
writeTarFile(tw, "journal/dev-1.jsonl", []byte("{}\n"))
|
|
tw.Close()
|
|
gz.Close()
|
|
|
|
dst := openFileBackend(t)
|
|
if _, _, _, err := importStore(context.Background(), dst, openTar(t, buf.Bytes()), nil, false); err == nil || !strings.Contains(err.Error(), "corrupt") {
|
|
t.Fatalf("err = %v, want corrupt-archive error", err)
|
|
}
|
|
}
|
|
|
|
func TestImportRejectsForeignEntries(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
gz := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gz)
|
|
writeTarFile(tw, "../../etc/passwd", []byte("nope"))
|
|
tw.Close()
|
|
gz.Close()
|
|
|
|
dst := openFileBackend(t)
|
|
if _, _, _, err := importStore(context.Background(), dst, openTar(t, buf.Bytes()), nil, false); err == nil || !strings.Contains(err.Error(), "unexpected entry") {
|
|
t.Fatalf("err = %v, want unexpected-entry error", err)
|
|
}
|
|
}
|
|
|
|
func TestImportRequiresJournals(t *testing.T) {
|
|
content := "just a blob"
|
|
var buf bytes.Buffer
|
|
gz := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gz)
|
|
writeTarFile(tw, blobKey(content), []byte(content))
|
|
tw.Close()
|
|
gz.Close()
|
|
|
|
dst := openFileBackend(t)
|
|
if _, _, _, err := importStore(context.Background(), dst, openTar(t, buf.Bytes()), nil, false); err == nil || !strings.Contains(err.Error(), "no journals") {
|
|
t.Fatalf("err = %v, want no-journals error", err)
|
|
}
|
|
}
|
|
|
|
func openTar(t *testing.T, b []byte) *tar.Reader {
|
|
t.Helper()
|
|
gz, err := gzip.NewReader(bytes.NewReader(b))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tar.NewReader(gz)
|
|
}
|
|
|
|
func read(t *testing.T, be remote.Backend, key string) string {
|
|
t.Helper()
|
|
rc, err := be.Get(context.Background(), key)
|
|
if err != nil {
|
|
t.Fatalf("get %s: %v", key, err)
|
|
}
|
|
defer rc.Close()
|
|
b, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|