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>
542 lines
20 KiB
Go
542 lines
20 KiB
Go
package syncer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
)
|
|
|
|
// Row 19, driven end to end: a real syncing device pointed at an HTTP server
|
|
// that speaks /api/p/<id>/store/* and answers however it likes.
|
|
//
|
|
// The hub here PROXIES a real file:// store that an honest peer device syncs
|
|
// into, so every test has a control: the same cycle against the same data with
|
|
// the hostile lever off must converge. The delta is the finding.
|
|
|
|
// sechostProxy is the hostile hub. Zero value forwards everything honestly.
|
|
type sechostProxy struct {
|
|
*httptest.Server
|
|
be remote.Backend
|
|
|
|
onList func([]remote.Object) []remote.Object // rewrite the listing
|
|
onBody func(key string, body []byte, w http.ResponseWriter) bool // serve a key ourselves
|
|
claimed bool // sign: "direct, already stored"
|
|
|
|
putBytes atomic.Int64 // bytes this hub actually received on PUT
|
|
served atomic.Int64 // bytes this hub wrote for /store/object
|
|
}
|
|
|
|
func sechostNewProxy(t *testing.T, be remote.Backend) *sechostProxy {
|
|
t.Helper()
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
h := &sechostProxy{be: be}
|
|
h.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
key := r.URL.Query().Get("key")
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/store/list"):
|
|
objs, err := h.be.List(ctx, r.URL.Query().Get("prefix"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
if h.onList != nil {
|
|
objs = h.onList(objs)
|
|
}
|
|
out := make([]map[string]any, 0, len(objs))
|
|
for _, o := range objs {
|
|
out = append(out, map[string]any{"key": o.Key, "size": o.Size})
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"objects": out})
|
|
case strings.HasSuffix(r.URL.Path, "/store/object") && r.Method == http.MethodGet:
|
|
var body []byte
|
|
if rc, err := h.be.Get(ctx, key); err == nil {
|
|
body, _ = io.ReadAll(rc)
|
|
rc.Close()
|
|
}
|
|
if h.onBody != nil && h.onBody(key, body, w) {
|
|
return
|
|
}
|
|
if body == nil {
|
|
http.Error(w, "no such object", http.StatusNotFound)
|
|
return
|
|
}
|
|
n, _ := w.Write(body)
|
|
h.served.Add(int64(n))
|
|
case strings.HasSuffix(r.URL.Path, "/store/object") && r.Method == http.MethodPut:
|
|
var buf bytes.Buffer
|
|
n, _ := io.Copy(&buf, r.Body)
|
|
h.putBytes.Add(n)
|
|
if err := h.be.Put(ctx, key, &buf, n); err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
case strings.HasSuffix(r.URL.Path, "/store/sign"):
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if h.claimed {
|
|
// The one-field lie an honest hub tells truthfully when a
|
|
// content-addressed blob is already in storage.
|
|
w.Write([]byte(`{"mode":"direct","exists":true}`))
|
|
return
|
|
}
|
|
w.Write([]byte(`{"mode":"server","exists":false}`))
|
|
case strings.HasSuffix(r.URL.Path, "/store/exists"):
|
|
ok, _ := h.be.Exists(ctx, key)
|
|
json.NewEncoder(w).Encode(map[string]any{"exists": ok})
|
|
default:
|
|
http.Error(w, "not served", http.StatusNotFound)
|
|
}
|
|
}))
|
|
t.Cleanup(h.Server.Close)
|
|
return h
|
|
}
|
|
|
|
func (h *sechostProxy) backend(t *testing.T) remote.Backend {
|
|
t.Helper()
|
|
be, err := remote.Open(context.Background(), h.Server.URL+"/p/p-0123abcd")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { be.Close() })
|
|
return be
|
|
}
|
|
|
|
// sechostPeer seeds a real project through a file:// store and returns that
|
|
// store plus the hostile hub fronting it.
|
|
func sechostPeer(t *testing.T, files map[string]string) (remote.Backend, *sechostProxy) {
|
|
t.Helper()
|
|
storage := sharedRemote(t)
|
|
peer := newDevice(t, "peer", storage)
|
|
for rel, content := range files {
|
|
write(t, peer.Folder, rel, content)
|
|
}
|
|
cycle(t, peer)
|
|
return storage, sechostNewProxy(t, storage)
|
|
}
|
|
|
|
// sechostJSONL re-encodes ops as a journal body.
|
|
func sechostJSONL(ops []journal.Op) []byte {
|
|
var b bytes.Buffer
|
|
for _, op := range ops {
|
|
line, _ := json.Marshal(op)
|
|
b.Write(line)
|
|
b.WriteByte('\n')
|
|
}
|
|
return b.Bytes()
|
|
}
|
|
|
|
// sechostFill writes filler until stop bytes are out or the client stops
|
|
// reading, and reports the count.
|
|
func sechostFill(w http.ResponseWriter, head []byte, stop int64) int64 {
|
|
var n int64
|
|
if k, err := w.Write(head); err != nil {
|
|
return int64(k)
|
|
} else {
|
|
n = int64(k)
|
|
}
|
|
w.(http.Flusher).Flush()
|
|
chunk := bytes.Repeat([]byte("x"), 512<<10)
|
|
for n < stop {
|
|
k, err := w.Write(chunk)
|
|
n += int64(k)
|
|
if err != nil {
|
|
break
|
|
}
|
|
w.(http.Flusher).Flush()
|
|
}
|
|
return n
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// A hub names the objects, and syncer.pull turns each name into a local file
|
|
// path through store.JournalPath, which validates nothing. A name the OS cannot
|
|
// open (too long, a NUL, any control byte) makes os.ReadFile fail with something
|
|
// that is not IsNotExist — and pull RETURNS on that error, abandoning every
|
|
// journal it had not reached yet.
|
|
//
|
|
// So one extra entry at the front of one listing decides which peers a device
|
|
// ever sees, forever, and the only thing the user is told is "offline". This is
|
|
// the round-7 finding's shape (a peer choosing which ops each device sees) with
|
|
// the hub, not a peer, holding the lever — and it needs no write access to
|
|
// anything.
|
|
func TestSec_HostileHub_OneUnusableListedKeyCannotHideEveryPeer(t *testing.T) {
|
|
poison := "journal/" + strings.Repeat("d", 300) + ".jsonl"
|
|
|
|
// Control: the same hub, same data, no poison key.
|
|
_, ctl := sechostPeer(t, map[string]string{"notes/real.md": "the peer's work"})
|
|
good := newDevice(t, "deva", ctl.backend(t))
|
|
if res := cycle(t, good); res.PulledOps == 0 {
|
|
t.Fatalf("harness: the control device pulled nothing: %+v", res)
|
|
}
|
|
if read(t, good.Folder, "notes/real.md") != "the peer's work" {
|
|
t.Fatal("harness: the control device did not converge")
|
|
}
|
|
|
|
// Attack: identical hub, one extra listed key in front.
|
|
_, hostile := sechostPeer(t, map[string]string{"notes/real.md": "the peer's work"})
|
|
hostile.onList = func(objs []remote.Object) []remote.Object {
|
|
return append([]remote.Object{{Key: poison, Size: 10}}, objs...)
|
|
}
|
|
// Served, not 404'd: a 404 is an honest race between LIST and GET and heals
|
|
// on the next cycle. This body arrives fine — it is store.JournalPath(dev)
|
|
// that the OS refuses, on a name the hub will keep listing forever.
|
|
hostile.onBody = func(key string, body []byte, w http.ResponseWriter) bool {
|
|
if key == poison {
|
|
w.Write([]byte("{}\n"))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
res, err := victim.Cycle(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(victim.Folder, "notes", "real.md")); err != nil {
|
|
t.Fatalf("one unusable key in the listing hid every peer's journal from this device "+
|
|
"(pulled %d ops, offline=%v: %v); pull abandons the whole listing on a JournalPath "+
|
|
"the OS refuses, and the hub picks the order", res.PulledOps, res.Offline, res.OfflineErr)
|
|
}
|
|
}
|
|
|
|
// The bound on a journal body is sizeBound(o.Size) — and o.Size is a number the
|
|
// same party that serves the bytes wrote in the same response. Round 8's stated
|
|
// property ("the party serving the bytes must not also choose how many of them
|
|
// the daemon buffers") is not met when the hub is the peer: there is no absolute
|
|
// ceiling anywhere on the path, so one listing entry sizes the allocation.
|
|
func TestSec_HostileHub_ADeclaredJournalSizeCannotChooseTheDeviceAllocation(t *testing.T) {
|
|
const declared = 512 << 20 // what the hub says the journal is
|
|
const willing = 2*maxPullBytes + (40 << 20) // more than the generous ceiling: proves truncation
|
|
const ceiling = 2 * maxPullBytes // twice the cap is generous: transport buffering, not the bound, owns the slack
|
|
|
|
_, hostile := sechostPeer(t, map[string]string{"notes/real.md": "hi"})
|
|
hostile.onList = func(objs []remote.Object) []remote.Object {
|
|
for i := range objs {
|
|
if strings.HasPrefix(objs[i].Key, "journal/") {
|
|
objs[i].Size = declared
|
|
}
|
|
}
|
|
return objs
|
|
}
|
|
hostile.onBody = func(key string, body []byte, w http.ResponseWriter) bool {
|
|
if !strings.HasPrefix(key, "journal/") {
|
|
return false
|
|
}
|
|
hostile.served.Add(sechostFill(w, body, willing))
|
|
return true
|
|
}
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
victim.Cycle(context.Background())
|
|
if n := hostile.served.Load(); n > ceiling {
|
|
t.Fatalf("the hub declared a %d-byte journal and this device read %d bytes of it into memory "+
|
|
"(io.ReadAll under a bound the hub itself chose); ceiling %d", int64(declared), n, int64(ceiling))
|
|
}
|
|
}
|
|
|
|
// Same shape one layer down, and this one lands on disk rather than in memory:
|
|
// a pulled blob is read under sizeBound(op.Size), where op.Size is a field in
|
|
// the journal line the hub just served. PutBlobReader spools it into the
|
|
// volume's tmp dir before anything checks the hash, so the hub writes as many
|
|
// bytes to the user's disk as it names — on every cycle, for every op.
|
|
func TestSec_HostileHub_ADeclaredBlobSizeCannotFillTheDisk(t *testing.T) {
|
|
const declared = 512 << 20
|
|
const willing = 2*maxPullBytes + (40 << 20)
|
|
const ceiling = 2 * maxPullBytes
|
|
|
|
_, hostile := sechostPeer(t, map[string]string{"notes/real.md": "small file, honestly"})
|
|
hostile.onBody = func(key string, body []byte, w http.ResponseWriter) bool {
|
|
if strings.HasPrefix(key, "journal/") {
|
|
ops, err := journal.Parse(body)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for i := range ops {
|
|
if ops[i].Kind == journal.KindPut {
|
|
ops[i].Size = declared // the only field that bounds the read
|
|
}
|
|
}
|
|
w.Write(sechostJSONL(ops))
|
|
return true
|
|
}
|
|
if strings.HasPrefix(key, "blobs/") {
|
|
hostile.served.Add(sechostFill(w, nil, willing))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
victim.Cycle(context.Background())
|
|
if n := hostile.served.Load(); n > ceiling {
|
|
t.Fatalf("one Size field in one journal line made this device spool %d bytes into its volume "+
|
|
"tmp dir for a 20-byte file (ceiling %d), and the cycle retries it every tick", n, int64(ceiling))
|
|
}
|
|
}
|
|
|
|
// "Blobs are pushed before the journal, so a peer never sees an op whose
|
|
// content is missing" is a stated invariant. The hub can break it from the
|
|
// outside with one boolean: sign answers {"mode":"direct","exists":true}, Put
|
|
// returns nil without sending anything, push advances st.PushedOps, and the
|
|
// journal goes up naming content that was never uploaded. The device never
|
|
// retries — the ops are behind the cursor forever — so the user's file is
|
|
// silently not backed up and every teammate gets an op with no content.
|
|
func TestSec_HostileHub_ClaimingItAlreadyHasABlobCannotSwallowAPush(t *testing.T) {
|
|
storage, hostile := sechostPeer(t, map[string]string{"seed.md": "seed"})
|
|
hostile.claimed = true
|
|
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
write(t, victim.Folder, "notes/mine.md", "content the hub never received")
|
|
cycle(t, victim)
|
|
cycle(t, victim) // a second chance to notice
|
|
|
|
// An honest peer reading the same storage must be able to materialize it.
|
|
peer := newDevice(t, "peer2", storage)
|
|
cycle(t, peer)
|
|
if got, err := os.ReadFile(filepath.Join(peer.Folder, "notes", "mine.md")); err != nil ||
|
|
string(got) != "content the hub never received" {
|
|
t.Fatalf("the hub said \"already stored\" and this device published the op anyway: "+
|
|
"storage received %d bytes of blob body, the peer cannot materialize the file (%v), "+
|
|
"and the pushed-ops cursor has moved past it so it is never retried",
|
|
hostile.putBytes.Load(), err)
|
|
}
|
|
}
|
|
|
|
// pull skips a listed journal only when its key equals this device's own id.
|
|
// On a case-insensitive filesystem (APFS and NTFS by default) a differently
|
|
// cased spelling of the same id is a different key and the SAME FILE, so the
|
|
// hub gets to overwrite the one journal this device is the sole writer of —
|
|
// which push then uploads as its own. That is the one-writer invariant, broken
|
|
// from the receiving end.
|
|
func TestSec_HostileHub_CannotOverwriteThisDevicesOwnJournal(t *testing.T) {
|
|
storage, hostile := sechostPeer(t, map[string]string{"seed.md": "seed"})
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
write(t, victim.Folder, "mine.md", "authored here")
|
|
cycle(t, victim)
|
|
|
|
own := victim.Store.JournalPath(victim.Device.ID)
|
|
before, err := os.ReadFile(own)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !sechostCaseInsensitive(t, filepath.Dir(own)) {
|
|
t.Skip("filesystem is case-sensitive; this collision is only expressible on APFS/NTFS")
|
|
}
|
|
|
|
// Bigger than what is there, so pull's size gate lets it through.
|
|
forged := sechostJSONL([]journal.Op{{
|
|
Seq: 1, Lamport: 99, Kind: journal.KindDelete, Path: "mine.md",
|
|
Device: "DEVA", DeviceName: "DEVA", Note: strings.Repeat("p", len(before)+64),
|
|
}})
|
|
hostile.onList = func(objs []remote.Object) []remote.Object {
|
|
return append(objs, remote.Object{Key: "journal/DEVA.jsonl", Size: int64(len(forged))})
|
|
}
|
|
hostile.onBody = func(key string, body []byte, w http.ResponseWriter) bool {
|
|
if key == "journal/DEVA.jsonl" {
|
|
w.Write(forged)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
victim.Cycle(context.Background())
|
|
|
|
after, err := os.ReadFile(own)
|
|
_, mineErr := os.Stat(filepath.Join(victim.Folder, "mine.md"))
|
|
if err != nil || !bytes.Equal(before, after) {
|
|
t.Fatalf("the hub listed this device's own journal under a differently cased key and pull "+
|
|
"wrote over it (%d bytes → %d, err %v); each device writes only its own journal. "+
|
|
"The forged op replays in the same cycle: mine.md stat = %v",
|
|
len(before), len(after), err, mineErr)
|
|
}
|
|
if mineErr != nil {
|
|
t.Fatalf("the hub deleted a locally authored file: %v", mineErr)
|
|
}
|
|
_ = storage
|
|
}
|
|
|
|
func sechostCaseInsensitive(t *testing.T, dir string) bool {
|
|
t.Helper()
|
|
p := filepath.Join(dir, ".sechost-case-probe")
|
|
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Remove(p)
|
|
_, err := os.Stat(filepath.Join(dir, ".SECHOST-CASE-PROBE"))
|
|
return err == nil
|
|
}
|
|
|
|
// Round 7 capped the listing BODY at 8 MiB. It did not cap the object COUNT,
|
|
// and a journal entry costs about 40 bytes of JSON — so one in-bounds listing
|
|
// names ~200k journals, each of which pull fetches and writes as a file in the
|
|
// volume's journal dir. Every later cycle then re-reads all of them (AllOps
|
|
// walks the directory), and the next listing can name 200k different ones. No
|
|
// write access to the project is needed: this is the answer to /store/list.
|
|
func TestSec_HostileHub_AListingCannotMintUnboundedLocalJournals(t *testing.T) {
|
|
const named = 8000 // well under the 8 MiB body cap
|
|
const ceiling = 1024
|
|
|
|
_, hostile := sechostPeer(t, map[string]string{"notes/real.md": "x"})
|
|
hostile.onList = func(objs []remote.Object) []remote.Object {
|
|
for i := 0; i < named; i++ {
|
|
objs = append(objs, remote.Object{Key: fmt.Sprintf("journal/g%06d.jsonl", i), Size: 96})
|
|
}
|
|
return objs
|
|
}
|
|
hostile.onBody = func(key string, body []byte, w http.ResponseWriter) bool {
|
|
if strings.HasPrefix(key, "journal/g") {
|
|
w.Write([]byte(`{"seq":1,"lamport":1,"kind":"delete","path":"nope.md","device":"g"}` + "\n"))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
victim.Cycle(context.Background())
|
|
|
|
ents, err := os.ReadDir(filepath.Join(victim.Store.Dir(), "journal"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ents) > ceiling {
|
|
t.Fatalf("one listing made this device create %d journal files (ceiling %d); "+
|
|
"the 8 MiB body cap bounds the bytes, nothing bounds the object count, and every "+
|
|
"later cycle re-reads all of them", len(ents), ceiling)
|
|
}
|
|
}
|
|
|
|
// --- attacks the device already refuses (regression cover) ---
|
|
|
|
// A listing may name the same journal twice, or name one under a prefix that
|
|
// belongs to a different project. Neither may double-apply or reach outside
|
|
// this project's key space.
|
|
func TestSec_HostileHub_DuplicateAndForeignKeysInAListingAreInert(t *testing.T) {
|
|
_, hostile := sechostPeer(t, map[string]string{"notes/real.md": "the peer's work"})
|
|
hostile.onList = func(objs []remote.Object) []remote.Object {
|
|
dup := append([]remote.Object{}, objs...)
|
|
for _, o := range objs {
|
|
dup = append(dup, o, o) // same key three times
|
|
}
|
|
return append(dup,
|
|
remote.Object{Key: "../other-project/journal/x.jsonl", Size: 9},
|
|
remote.Object{Key: "/journal/x.jsonl", Size: 9})
|
|
}
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
res := cycle(t, victim)
|
|
if read(t, victim.Folder, "notes/real.md") != "the peer's work" {
|
|
t.Fatal("the device did not converge through a listing with duplicates")
|
|
}
|
|
if res.PulledOps != 1 {
|
|
t.Fatalf("a key listed three times produced %d ops, want 1", res.PulledOps)
|
|
}
|
|
ents, _ := os.ReadDir(filepath.Join(victim.Store.Dir(), "journal"))
|
|
if len(ents) != 1 { // the one peer journal, once
|
|
t.Fatalf("journal dir holds %d files after a listing with duplicate and foreign keys", len(ents))
|
|
}
|
|
}
|
|
|
|
// Content addressing is what makes a blob body unforgeable: bytes that do not
|
|
// hash to the key are filed under their real hash, so the path stays unwritten
|
|
// and the next cycle retries rather than materializing the hub's choice.
|
|
func TestSec_HostileHub_ABlobBodyThatIsNotItsKeyIsNeverMaterialized(t *testing.T) {
|
|
_, hostile := sechostPeer(t, map[string]string{"notes/real.md": "the peer's work"})
|
|
hostile.onBody = func(key string, body []byte, w http.ResponseWriter) bool {
|
|
if strings.HasPrefix(key, "blobs/") {
|
|
w.Write([]byte("attacker-chosen content"))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
victim.Cycle(context.Background())
|
|
got, err := os.ReadFile(filepath.Join(victim.Folder, "notes", "real.md"))
|
|
if err == nil && string(got) == "attacker-chosen content" {
|
|
t.Fatal("the hub served bytes that are not the blob's content address and the device wrote them")
|
|
}
|
|
if err == nil && string(got) != "the peer's work" {
|
|
t.Fatalf("unexpected content %q", got)
|
|
}
|
|
}
|
|
|
|
// A 200 with an empty (or truncated) journal body must not undo ops this device
|
|
// already applied — the shrink guard in pull.
|
|
func TestSec_HostileHub_AnEmptiedJournalCannotUndoAppliedOps(t *testing.T) {
|
|
_, hostile := sechostPeer(t, map[string]string{"notes/real.md": "the peer's work"})
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
cycle(t, victim)
|
|
if read(t, victim.Folder, "notes/real.md") != "the peer's work" {
|
|
t.Fatal("harness: first cycle did not converge")
|
|
}
|
|
hostile.onBody = func(key string, body []byte, w http.ResponseWriter) bool {
|
|
if strings.HasPrefix(key, "journal/") {
|
|
w.WriteHeader(http.StatusOK) // 200, zero bytes
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
victim.Cycle(context.Background())
|
|
if _, err := os.Stat(filepath.Join(victim.Folder, "notes", "real.md")); err != nil {
|
|
t.Fatalf("an empty journal body removed a file this device had already applied: %v", err)
|
|
}
|
|
}
|
|
|
|
// The hub cannot make a device delete a file it holds by simply not serving the
|
|
// op any more: a withdrawn put is re-asserted under this device's own identity.
|
|
func TestSec_HostileHub_WithdrawingAJournalCannotDeleteLocalFiles(t *testing.T) {
|
|
_, hostile := sechostPeer(t, map[string]string{"notes/real.md": "the peer's work"})
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
cycle(t, victim)
|
|
hostile.onList = func(objs []remote.Object) []remote.Object {
|
|
var out []remote.Object
|
|
for _, o := range objs {
|
|
if !strings.HasPrefix(o.Key, "journal/peer") {
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
victim.Cycle(context.Background())
|
|
if got, err := os.ReadFile(filepath.Join(victim.Folder, "notes", "real.md")); err != nil || string(got) != "the peer's work" {
|
|
t.Fatalf("dropping a journal from the listing removed the file it published: %q %v", got, err)
|
|
}
|
|
}
|
|
|
|
// A path a peer names is still checked against journal.SafePath on the way to
|
|
// disk, whoever serves it.
|
|
func TestSec_HostileHub_CannotWriteOutsideTheMount(t *testing.T) {
|
|
_, hostile := sechostPeer(t, map[string]string{"notes/real.md": "x"})
|
|
victim := newDevice(t, "deva", hostile.backend(t))
|
|
outside := filepath.Join(t.TempDir(), "escaped")
|
|
blob := fmt.Sprintf("%x", sechostSha("pwned"))
|
|
hostile.onBody = func(key string, body []byte, w http.ResponseWriter) bool {
|
|
if strings.HasPrefix(key, "journal/") {
|
|
w.Write(sechostJSONL([]journal.Op{{
|
|
Seq: 1, Lamport: 1, Kind: journal.KindPut, Device: "peer",
|
|
Path: "../../../../../../../../.." + outside, Blob: blob, Size: 5, Mode: 0o644,
|
|
}}))
|
|
return true
|
|
}
|
|
w.Write([]byte("pwned"))
|
|
return true
|
|
}
|
|
victim.Cycle(context.Background())
|
|
if _, err := os.Stat(outside); err == nil {
|
|
t.Fatalf("the hub wrote %s outside the mount", outside)
|
|
}
|
|
}
|
|
|
|
func sechostSha(s string) []byte {
|
|
sum := sha256.Sum256([]byte(s))
|
|
return sum[:]
|
|
}
|