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>
251 lines
8.5 KiB
Go
251 lines
8.5 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
)
|
|
|
|
// Security tests for `bdrive export` / `bdrive import`. Import's input is a
|
|
// tar.gz the user obtained from somewhere — a colleague, a download, a hub
|
|
// that is not theirs — so every archive member is attacker-controlled. Export's
|
|
// input is a hub's object listing, which is attacker-controlled the moment the
|
|
// hub is not yours ("works against any hub in either direction").
|
|
|
|
// secpkgTar builds a tar.gz from the given headers+bodies, manifest first.
|
|
func secpkgTar(t *testing.T, withManifest bool, entries ...func(*tar.Writer)) io.Reader {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
gz := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gz)
|
|
if withManifest {
|
|
body := []byte(`{"project":"wiki"}`)
|
|
if err := tw.WriteHeader(&tar.Header{Name: manifestName, Mode: 0o644, Size: int64(len(body))}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tw.Write(body)
|
|
}
|
|
for _, e := range entries {
|
|
e(tw)
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &buf
|
|
}
|
|
|
|
// secpkgEntry writes one member with a literal body.
|
|
func secpkgEntry(t *testing.T, h *tar.Header, body string) func(*tar.Writer) {
|
|
return func(tw *tar.Writer) {
|
|
if h.Size == 0 && body != "" {
|
|
h.Size = int64(len(body))
|
|
}
|
|
if err := tw.WriteHeader(h); err != nil {
|
|
t.Logf("writer refused header %q: %v", h.Name, err)
|
|
return
|
|
}
|
|
tw.Write([]byte(body))
|
|
}
|
|
}
|
|
|
|
// secpkgReadArchive returns a tar.Reader positioned past the manifest, the
|
|
// way importCmd does it.
|
|
func secpkgReadArchive(t *testing.T, r io.Reader) (*tar.Reader, *tar.Header) {
|
|
t.Helper()
|
|
gz, err := gzip.NewReader(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tr := tar.NewReader(gz)
|
|
_, first, err := readManifest(tr)
|
|
if err != nil {
|
|
t.Fatalf("readManifest: %v", err)
|
|
}
|
|
return tr, first
|
|
}
|
|
|
|
// TestSec_Migrate_ArchiveEntryCannotEscapeTheStorePrefix asserts the refused
|
|
// case: every classic tar-extraction trick is rejected by importStore's key
|
|
// allowlist, and nothing lands outside journal/ + blobs/ in the target store
|
|
// or anywhere on the local filesystem.
|
|
func TestSec_Migrate_ArchiveEntryCannotEscapeTheStorePrefix(t *testing.T) {
|
|
ctx := context.Background()
|
|
hostile := []*tar.Header{
|
|
{Name: "../../../../etc/cron.d/pwn", Mode: 0o644},
|
|
{Name: "/etc/cron.d/pwn", Mode: 0o644},
|
|
{Name: "journal/../../../../etc/pwn.jsonl", Mode: 0o644},
|
|
{Name: "blobs/../../../../etc/pwn", Mode: 0o644},
|
|
{Name: "./../.ssh/authorized_keys", Mode: 0o644},
|
|
{Name: "C:\\Windows\\System32\\pwn.jsonl", Mode: 0o644},
|
|
{Name: "journal/evil.jsonl\x00/../../pwn", Mode: 0o644},
|
|
{Name: "journal/pwn.jsonl", Mode: 0o4755}, // setuid bits on a store key
|
|
{Name: "blobs/" + strings.Repeat("A", 64), Mode: 0o644},
|
|
{Name: "journal/dev.JSONL", Mode: 0o644},
|
|
{Name: "journal/dev.jsonl", Typeflag: tar.TypeSymlink, Linkname: "../../../../etc/passwd"},
|
|
{Name: "journal/hard.jsonl", Typeflag: tar.TypeLink, Linkname: "/etc/passwd"},
|
|
{Name: "journal/fifo.jsonl", Typeflag: tar.TypeFifo},
|
|
{Name: "journal/dev0.jsonl", Typeflag: tar.TypeChar, Devmajor: 1, Devminor: 3},
|
|
}
|
|
for _, h := range hostile {
|
|
t.Run(strings.ReplaceAll(h.Name, "/", "_"), func(t *testing.T) {
|
|
root := t.TempDir()
|
|
be, err := remote.Open(ctx, "file://"+filepath.Join(root, "store"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer be.Close()
|
|
hdr := *h
|
|
r := secpkgTar(t, true, secpkgEntry(t, &hdr, "planted"))
|
|
tr, first := secpkgReadArchive(t, r)
|
|
_, _, _, _ = importStore(ctx, be, tr, first, false)
|
|
|
|
for _, p := range secpkgFilesUnder(t, root) {
|
|
rel, err := filepath.Rel(filepath.Join(root, "store"), p)
|
|
if err != nil || strings.HasPrefix(rel, "..") {
|
|
t.Errorf("member %q created %q, outside the store", h.Name, p)
|
|
continue
|
|
}
|
|
slash := filepath.ToSlash(rel)
|
|
if !strings.HasPrefix(slash, "journal/") && !strings.HasPrefix(slash, "blobs/") {
|
|
t.Errorf("member %q created store key %q, outside journal/ and blobs/", h.Name, slash)
|
|
}
|
|
if fi, err := os.Stat(p); err == nil && fi.Mode()&(os.ModeSetuid|os.ModeSetgid) != 0 {
|
|
t.Errorf("member %q produced %q with mode %v", h.Name, p, fi.Mode())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSec_Migrate_CorruptBlobNeverLandsInTheTargetStore: importStore streams
|
|
// each blob straight into be.Put and only then compares the hash it teed off.
|
|
// By the time the mismatch is noticed the object is already stored under a key
|
|
// that promises different content — in a project the user has just created on
|
|
// their hub, alongside the journals that reference it (journals are written
|
|
// first). Every device that later connects fails its pull with "blob corrupt
|
|
// on remote" and never recovers.
|
|
func TestSec_Migrate_CorruptBlobNeverLandsInTheTargetStore(t *testing.T) {
|
|
ctx := context.Background()
|
|
root := t.TempDir()
|
|
be, err := remote.Open(ctx, "file://"+root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer be.Close()
|
|
|
|
honest := "the content this key promises"
|
|
sum := sha256.Sum256([]byte(honest))
|
|
key := "blobs/" + hex.EncodeToString(sum[:])
|
|
|
|
r := secpkgTar(t, true,
|
|
secpkgEntry(t, &tar.Header{Name: "journal/dev-1.jsonl", Mode: 0o644}, `{"path":"a.md"}`+"\n"),
|
|
secpkgEntry(t, &tar.Header{Name: key, Mode: 0o644}, "SUBSTITUTED CONTENT"),
|
|
)
|
|
tr, first := secpkgReadArchive(t, r)
|
|
if _, _, _, err := importStore(ctx, be, tr, first, false); err == nil {
|
|
t.Fatal("importStore accepted a blob whose content does not match its key")
|
|
}
|
|
|
|
rc, err := be.Get(ctx, key)
|
|
if err != nil {
|
|
return // not stored: the secure outcome
|
|
}
|
|
b, _ := io.ReadAll(rc)
|
|
rc.Close()
|
|
if string(b) != honest {
|
|
t.Errorf("after the refused import, %s still holds %q — a content-addressed key serving content that does not hash to it", key, b)
|
|
}
|
|
}
|
|
|
|
// secpkgHostileBackend is a hub that answers List with keys of its own
|
|
// choosing. A hub is not trusted to name its own objects: export copies each
|
|
// key into the archive verbatim as a tar member name.
|
|
type secpkgHostileBackend struct{ keys []string }
|
|
|
|
func (b *secpkgHostileBackend) Put(context.Context, string, io.Reader, int64) error { return nil }
|
|
func (b *secpkgHostileBackend) Get(_ context.Context, key string) (io.ReadCloser, error) {
|
|
return io.NopCloser(strings.NewReader("x")), nil
|
|
}
|
|
func (b *secpkgHostileBackend) List(_ context.Context, prefix string) ([]remote.Object, error) {
|
|
var out []remote.Object
|
|
for _, k := range b.keys {
|
|
if strings.HasPrefix(k, prefix) || prefix == "journal/" && strings.HasPrefix(k, "journal") {
|
|
out = append(out, remote.Object{Key: k, Size: 1})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (b *secpkgHostileBackend) Exists(context.Context, string) (bool, error) { return true, nil }
|
|
func (b *secpkgHostileBackend) Close() error { return nil }
|
|
|
|
// TestSec_Migrate_ExportOnlyEmitsStoreKeys: exportStore trusts the hub's
|
|
// listing and writes o.Key as the tar member name with no check that it is a
|
|
// store key. A hostile (or compromised) hub therefore hands the user an
|
|
// archive whose members are ../../.ssh/authorized_keys — and the export's own
|
|
// printed advice is to pass that file around. bdrive import rejects it, but
|
|
// `tar xzf` does not.
|
|
func TestSec_Migrate_ExportOnlyEmitsStoreKeys(t *testing.T) {
|
|
ctx := context.Background()
|
|
be := &secpkgHostileBackend{keys: []string{
|
|
"journal/../../../../.ssh/authorized_keys",
|
|
"journal/dev-1.jsonl",
|
|
"blobs/../../../../etc/cron.d/pwn",
|
|
}}
|
|
var buf bytes.Buffer
|
|
if _, _, _, err := exportStore(ctx, be, &buf, exportManifest{Project: "wiki", ExportedAt: time.Now().UTC()}); err != nil {
|
|
return // refusing the hub's key is the secure outcome
|
|
}
|
|
gz, err := gzip.NewReader(&buf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tr := tar.NewReader(gz)
|
|
for {
|
|
h, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if h.Name == manifestName {
|
|
continue
|
|
}
|
|
clean := filepath.ToSlash(filepath.Clean(h.Name))
|
|
if strings.HasPrefix(clean, "..") || strings.HasPrefix(h.Name, "/") ||
|
|
(!strings.HasPrefix(clean, "journal/") && !strings.HasPrefix(clean, "blobs/")) {
|
|
t.Errorf("export wrote archive member %q, which extracts outside the store layout", h.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// secpkgFilesUnder lists every regular file under root.
|
|
func secpkgFilesUnder(t *testing.T, root string) []string {
|
|
t.Helper()
|
|
var out []string
|
|
filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
out = append(out, p)
|
|
return nil
|
|
})
|
|
sort.Strings(out)
|
|
return out
|
|
}
|