Files
beardrive/cmd/bdrive/sec_migrate_test.go
0dd474baab Delta sync: large files move as content-defined chunks (#161)
* 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>
2026-08-13 07:18:37 -07:00

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
}