package sync
import (
"context"
"fmt"
"strings"
"testing"
"github.com/danmestas/go-libfossil/db"
"github.com/danmestas/go-libfossil/internal/auth"
"github.com/danmestas/go-libfossil/internal/blob"
"github.com/danmestas/go-libfossil/internal/content"
"github.com/danmestas/go-libfossil/internal/delta"
libfossil "github.com/danmestas/go-libfossil/internal/fsltype"
"github.com/danmestas/go-libfossil/internal/hash"
"github.com/danmestas/go-libfossil/internal/manifest"
"github.com/danmestas/go-libfossil/internal/repo"
"github.com/danmestas/go-libfossil/internal/xfer"
)
// findCards returns all cards of type T from a message.
func findCards[T xfer.Card](msg *xfer.Message) []T {
var out []T
for _, c := range msg.Cards {
if tc, ok := c.(T); ok {
out = append(out, tc)
}
}
return out
}
// storeTestBlob stores a blob and returns its UUID.
func storeTestBlob(t *testing.T, r *repo.Repo, data []byte) string {
t.Helper()
uuid := hash.SHA1(data)
if err := storeReceivedFile(context.Background(), r, uuid, "", data, nil, visibilityPublic, nil); err != nil {
t.Fatalf("storeReceivedFile: %v", err)
}
return uuid
}
func testProjectCode(t *testing.T, d *db.DB) string {
t.Helper()
var code string
if err := d.QueryRow("SELECT value FROM config WHERE name='project-code'").Scan(&code); err != nil {
t.Fatalf("project-code: %v", err)
}
return code
}
func TestHandlePull(t *testing.T) {
r := setupSyncTestRepo(t)
uuid := storeTestBlob(t, r, []byte("pull me"))
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PullCard{ServerCode: "test", ProjectCode: testPC},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
igots := findCards[*xfer.IGotCard](resp)
found := false
for _, ig := range igots {
if ig.UUID == uuid {
found = true
}
}
if !found {
t.Fatalf("pull response missing igot for %s", uuid)
}
}
func TestHandleIGotGimme(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'")
unknownUUID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.IGotCard{UUID: unknownUUID},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
gimmes := findCards[*xfer.GimmeCard](resp)
found := false
for _, g := range gimmes {
if g.UUID == unknownUUID {
found = true
}
}
if !found {
t.Fatal("expected gimme for unknown UUID")
}
}
// TestHandleIGotWithoutPushOrPull verifies the server does not gimme when
// the client is neither pushing nor pulling. Without pushOK there is no
// mandate for the client to send files, so issuing a gimme is wasted.
func TestHandleIGotWithoutPushOrPull(t *testing.T) {
r := setupSyncTestRepo(t)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.IGotCard{UUID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
gimmes := findCards[*xfer.GimmeCard](resp)
if len(gimmes) > 0 {
t.Fatal("should not gimme without push or pull card")
}
}
// TestHandleIGotWithPushOnly verifies the server emits gimme cards when
// the client is push-only. This is the multi-round push-without-pull case
// — without it, push-only clients exit after one round even when the
// server is missing artifacts the client has just announced.
func TestHandleIGotWithPushOnly(t *testing.T) {
r := setupPushableTestRepo(t)
unknownUUID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.IGotCard{UUID: unknownUUID},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
gimmes := findCards[*xfer.GimmeCard](resp)
found := false
for _, g := range gimmes {
if g.UUID == unknownUUID {
found = true
}
}
if !found {
t.Fatal("expected gimme for unknown UUID under push-only")
}
}
// TestHandleIGotShunned verifies a shunned hash draws no gimme and no
// phantom (#349), while an unshunned one still does.
func TestHandleIGotShunned(t *testing.T) {
r := setupPushableTestRepo(t)
shunned := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
other := "cccccccccccccccccccccccccccccccccccccccc"
if _, err := r.DB().Exec("INSERT INTO shun(uuid, mtime) VALUES(?, 0)", shunned); err != nil {
t.Fatalf("shun: %v", err)
}
resp := handleReq(t, r,
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.IGotCard{UUID: shunned},
&xfer.IGotCard{UUID: other},
)
got := map[string]bool{}
for _, g := range findCards[*xfer.GimmeCard](resp) {
got[g.UUID] = true
}
if got[shunned] {
t.Error("gimme sent for a shunned hash")
}
if !got[other] {
t.Error("no gimme for an unshunned unknown hash")
}
}
func TestHandleGimme(t *testing.T) {
r := setupSyncTestRepo(t)
data := []byte("gimme this")
uuid := storeTestBlob(t, r, data)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PullCard{ServerCode: "test", ProjectCode: testPC},
&xfer.GimmeCard{UUID: uuid},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
files := findCards[*xfer.FileCard](resp)
found := false
for _, f := range files {
if f.UUID == uuid && string(f.Content) == string(data) {
found = true
}
}
if !found {
t.Fatal("expected file card with correct content")
}
}
func TestHandleGimmeMissing(t *testing.T) {
r := setupSyncTestRepo(t)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.GimmeCard{UUID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
files := findCards[*xfer.FileCard](resp)
if len(files) > 0 {
t.Fatal("should not return file for missing blob")
}
}
func TestHandlePushFile(t *testing.T) {
r := setupPushableTestRepo(t)
data := []byte("push this")
uuid := hash.SHA1(data)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.FileCard{UUID: uuid, Content: data},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) > 0 {
t.Fatalf("unexpected error: %s", errs[0].Message)
}
_, ok := blob.Exists(r.DB(), uuid)
if !ok {
t.Fatal("pushed blob not stored")
}
}
// TestHandleWrongProjectEndsClone pins that a wrong-project break ends a
// clone begun earlier in the request: no cfile, clone_seqno or push card
// follows the error.
func TestHandleWrongProjectEndsClone(t *testing.T) {
r := setupSyncTestRepo(t)
storeTestBlob(t, r, []byte("clone data"))
req := &xfer.Message{Cards: []xfer.Card{
&xfer.CloneCard{Version: 3, SeqNo: 1, SeqNoIsDecimal: true},
&xfer.PullCard{ServerCode: "test", ProjectCode: strings.Repeat("0", 40)},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
for _, c := range resp.Cards {
switch c.(type) {
case *xfer.CFileCard, *xfer.CloneSeqNoCard, *xfer.PushCard:
t.Fatalf("clone data %T follows the wrong-project error", c)
}
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) != 1 || errs[0].Message != "wrong project" {
t.Fatalf("error cards %v, want one \"wrong project\"", errs)
}
}
// TestHandleLoginWithoutProjectCode pins that a login against a repository
// with no project-code is refused as a failed login, not a panic: the shared
// secret cannot be derived without the code.
func TestHandleLoginWithoutProjectCode(t *testing.T) {
r := setupSyncTestRepo(t)
if _, err := r.DB().Exec("DELETE FROM config WHERE name='project-code'"); err != nil {
t.Fatalf("delete project-code: %v", err)
}
req := &xfer.Message{Cards: []xfer.Card{
&xfer.LoginCard{User: "testuser", Nonce: strings.Repeat("0", 40), Signature: "sig"},
&xfer.PullCard{ServerCode: "test", ProjectCode: testPC},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) != 1 || errs[0].Message != "login failed" {
t.Fatalf("error cards %v, want one \"login failed\"", errs)
}
}
// TestHandleWrongProject pins §8.5: a push or pull naming another project is
// refused with "wrong project" before any capability check, and the cards
// after it are not processed, though the reply still ends with its timestamp.
func TestHandleWrongProject(t *testing.T) {
r := setupPushableTestRepo(t)
data := []byte("wrong project")
uuid := hash.SHA1(data)
other := strings.Repeat("0", 40)
for _, first := range []xfer.Card{
&xfer.PushCard{ServerCode: "test", ProjectCode: other},
&xfer.PullCard{ServerCode: "test", ProjectCode: other},
} {
req := &xfer.Message{Cards: []xfer.Card{
first,
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.FileCard{UUID: uuid, Content: data},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) != 1 || errs[0].Message != "wrong project" {
t.Fatalf("%T: error cards %v, want one \"wrong project\"", first, errs)
}
if _, ok := blob.Exists(r.DB(), uuid); ok {
t.Fatalf("%T: file after a wrong-project card was stored", first)
}
if len(findCards[*xfer.CommentCard](resp)) != 1 {
t.Fatalf("%T: reply has no timestamp comment", first)
}
}
}
func TestHandleFileWithoutPush(t *testing.T) {
r := setupSyncTestRepo(t)
data := []byte("no push card")
uuid := hash.SHA1(data)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.FileCard{UUID: uuid, Content: data},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) == 0 {
t.Fatal("expected error for file without push")
}
}
func TestHandleClone(t *testing.T) {
r := setupSyncTestRepo(t)
stored := map[string]bool{}
for i := range 5 {
data := []byte(fmt.Sprintf("clone test %d", i))
uuid := storeTestBlob(t, r, data)
stored[uuid] = true
}
req := &xfer.Message{Cards: []xfer.Card{
&xfer.CloneCard{Version: 1},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
// §7.2: clone sends cfile, not file — no plain file cards should appear.
if plain := findCards[*xfer.FileCard](resp); len(plain) > 0 {
t.Fatalf("clone response carried %d 'file' cards; §7.2 requires 'cfile'", len(plain))
}
files := findCards[*xfer.CFileCard](resp)
for _, f := range files {
delete(stored, f.UUID)
}
if len(stored) > 0 {
t.Fatalf("clone missing blobs: %v", stored)
}
}
// TestHandleClonePushCardTrailsCloneSeqno pins the card order a clone response
// must use to keep a real fossil client from re-transferring the whole
// repository (issue #138). Canonical's client re-issues `clone 3 SEQNO` every
// time it sees a server `push` card while its cloneSeqno is still > 0
// (fossil-scm xfer.c:2706), and canonical's server emits `push` *after* the
// terminal `clone_seqno 0` (xfer.c:1571 then 1577). If go-libfossil emits
// `push` before `clone_seqno`, the client queues one more `clone 3 SEQNO`
// before it learns the cursor reached 0, and the server serves the entire
// content a second time -- the measured 2.06x end-to-end blow-up. So in a clone
// response the CloneSeqNoCard must precede the PushCard.
func TestHandleClonePushCardTrailsCloneSeqno(t *testing.T) {
r := setupSyncTestRepo(t)
for i := range 5 {
storeTestBlob(t, r, []byte(fmt.Sprintf("clone order test %d", i)))
}
req := &xfer.Message{Cards: []xfer.Card{
versionPragma("client-version"), &xfer.CloneCard{Version: 3, SeqNo: 1},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
seqnoIdx, pushIdx := -1, -1
for i, c := range resp.Cards {
switch c.(type) {
case *xfer.CloneSeqNoCard:
seqnoIdx = i
case *xfer.PushCard:
pushIdx = i
}
}
if seqnoIdx < 0 {
t.Fatal("clone response missing clone_seqno card")
}
if pushIdx < 0 {
t.Fatal("clone response missing push card")
}
if pushIdx < seqnoIdx {
t.Fatalf("push card at index %d precedes clone_seqno at index %d; "+
"a real fossil client re-clones the whole repo when push arrives "+
"before the terminal clone_seqno (issue #138)", pushIdx, seqnoIdx)
}
}
// similarCloneArtifacts returns two bodies sharing almost all their text, so
// a delta between them is well under content.Deltify's policy threshold and
// far smaller than either full body.
func similarCloneArtifacts() ([]byte, []byte) {
var a, b strings.Builder
for i := 0; i < 200; i++ {
fmt.Fprintf(&a, "line %04d: the quick brown fox jumps over the lazy dog\n", i)
if i == 100 {
fmt.Fprintf(&b, "line %04d: CHANGED FOR THE DELTA TEST\n", i)
} else {
fmt.Fprintf(&b, "line %04d: the quick brown fox jumps over the lazy dog\n", i)
}
}
return []byte(a.String()), []byte(b.String())
}
// cloneContentCard is an artifact-bearing card reduced to the fields the
// #141 ordering tests reason about.
type cloneContentCard struct {
uuid string
deltaSrc string
content []byte
}
// cloneContentCards flattens a clone response into the artifact-bearing cards
// in wire order. A clone sends every artifact as a cfile, a delta naming its
// source (#255); a file card would be a protocol error here, and is kept so
// the ordering tests still see it.
func cloneContentCards(resp *xfer.Message) []cloneContentCard {
var out []cloneContentCard
for _, c := range resp.Cards {
switch cf := c.(type) {
case *xfer.CFileCard:
out = append(out, cloneContentCard{cf.UUID, cf.DeltaSrc, cf.Content})
case *xfer.FileCard:
out = append(out, cloneContentCard{cf.UUID, cf.DeltaSrc, cf.Content})
}
}
return out
}
// TestEmitCloneBatchSendsDeltifiedRowsAsDelta pins the #141 send behavior: a
// deltified row goes on the wire as a delta card (reclaiming the bandwidth the
// old expand-before-send path gave up), but its source is emitted first so the
// delta never forward-references a card that has not gone out yet.
//
// content.Deltify deltifies the OLDER artifact against the NEWER one, so the
// predecessor's DeltaSource is the (higher-rid) successor. emitCloneBatch must
// therefore emit the successor's full card BEFORE the predecessor's delta card,
// even though that is out of ascending-rid order. That ordering is a real
// invariant of the send path -- a delta must never forward-reference a card the
// receiver has not yet seen -- and TestEmitCloneBatchSourcePrecedesDelta guards
// it across a deeper chain.
func TestEmitCloneBatchSendsDeltifiedRowsAsDelta(t *testing.T) {
r := setupSyncTestRepo(t)
predecessor, successor := similarCloneArtifacts()
var oldUUID, newUUID string
if err := r.WithTx(func(tx *db.Tx) error {
oldRid, u1, err := blob.Store(tx, predecessor)
if err != nil {
return err
}
newRid, u2, err := blob.Store(tx, successor)
if err != nil {
return err
}
oldUUID, newUUID = u1, u2
saved, err := content.Deltify(tx, oldRid, newRid)
if err != nil {
return err
}
if saved <= 0 {
t.Fatalf("test setup: Deltify saved %d bytes, want > 0", saved)
}
return nil
}); err != nil {
t.Fatalf("deltify setup: %v", err)
}
req := &xfer.Message{Cards: []xfer.Card{
versionPragma("client-version"), &xfer.CloneCard{Version: 1},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
// Locate the predecessor delta and the successor source, and the positions
// they occupy in wire order.
var delta, source *cloneContentCard
deltaPos, sourcePos := -1, -1
for i, cc := range cloneContentCards(resp) {
cc := cc
switch cc.uuid {
case oldUUID:
delta, deltaPos = &cc, i
case newUUID:
source, sourcePos = &cc, i
}
}
if delta == nil || source == nil {
t.Fatalf("expected content cards for both predecessor and successor; got delta=%v source=%v",
delta != nil, source != nil)
}
if delta.deltaSrc != newUUID {
t.Errorf("predecessor DeltaSrc = %q, want %q (the successor it deltas against)",
delta.deltaSrc, newUUID)
}
if source.deltaSrc != "" {
t.Errorf("successor DeltaSrc = %q, want empty (it is the full-content chain tip)", source.deltaSrc)
}
if sourcePos > deltaPos {
t.Errorf("source card at position %d must precede the delta card at position %d",
sourcePos, deltaPos)
}
if len(delta.content) >= len(predecessor) {
t.Errorf("delta payload = %d bytes, want < %d (the un-deltified original) -- a delta must be smaller",
len(delta.content), len(predecessor))
}
}
// TestEmitCloneBatchSourcePrecedesDelta asserts the airtight #141 invariant on
// a deeper corpus: every content card that carries a DeltaSrc is preceded, in
// wire order, by a card that supplies that source UUID. A delta whose source
// has not already been sent would create an unfillable phantom on a real fossil
// client, so this is a correctness guard, not a bandwidth one.
//
// Method: build six revisions of one file so content.Deltify produces a
// multi-link chain, clone in a single round, and walk the response cards
// tracking which UUIDs have been supplied so far.
func TestEmitCloneBatchSourcePrecedesDelta(t *testing.T) {
r := setupSyncTestRepo(t)
base := make([]byte, 4096)
for i := range base {
base[i] = byte(i % 251)
}
var parent libfossil.FslID
for c := 0; c < 6; c++ {
body := append([]byte(nil), base...)
body[c] = byte(200 + c)
body = append(body, []byte(fmt.Sprintf("\nrevision marker %d\n", c))...)
mid, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Comment: fmt.Sprintf("checkin %d", c),
User: "testuser",
Parent: parent,
Files: []manifest.File{{Name: "big.bin", Content: body}},
})
if err != nil {
t.Fatalf("Checkin %d: %v", c, err)
}
parent = mid
}
if _, err := manifest.Crosslink(r); err != nil {
t.Fatalf("Crosslink: %v", err)
}
var deltaCount int
if err := r.DB().QueryRow("SELECT count(*) FROM delta").Scan(&deltaCount); err != nil {
t.Fatalf("count deltas: %v", err)
}
if deltaCount == 0 {
t.Fatal("fixture bug: no deltas created -- this test needs a deltified chain to be meaningful")
}
req := &xfer.Message{Cards: []xfer.Card{
versionPragma("client-version"), &xfer.CloneCard{Version: 3, SeqNo: 1},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
sawDelta := false
supplied := map[string]bool{}
for _, cc := range cloneContentCards(resp) {
if cc.deltaSrc != "" {
sawDelta = true
if !supplied[cc.deltaSrc] {
t.Errorf("delta card %s names source %s that no earlier card supplied",
cc.uuid, cc.deltaSrc)
}
}
supplied[cc.uuid] = true
}
if !sawDelta {
t.Error("expected at least one delta card in the clone of a deltified chain, got none")
}
}
// cloneRound runs one clone round at the given cursor and returns the cfile
// cards it produced, their total payload size, and the cursor the server
// reported back. A zero cursor back means the snapshot is exhausted.
func cloneRound(t *testing.T, r *repo.Repo, seqno int) (files []*xfer.CFileCard, payload int, next int) {
t.Helper()
req := &xfer.Message{Cards: []xfer.Card{&xfer.CloneCard{Version: 3, SeqNo: seqno}}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("clone round at seqno %d: %v", seqno, err)
}
files = findCards[*xfer.CFileCard](resp)
for _, f := range files {
payload += len(f.Content)
}
seqnos := findCards[*xfer.CloneSeqNoCard](resp)
if len(seqnos) != 1 {
t.Fatalf("clone round at seqno %d: got %d clone_seqno cards, want 1", seqno, len(seqnos))
}
return files, payload, seqnos[0].SeqNo
}
// TestHandleClonePaginationBoundedByBytes proves the clone batch is bounded
// by output size and not by artifact count (issue #88). Method: store enough
// large artifacts that one round's payload must exceed the byte budget if the
// bound were a count, then check the first round stopped near the budget
// instead of at any fixed number of artifacts.
func TestHandleClonePaginationBoundedByBytes(t *testing.T) {
r := setupSyncTestRepo(t)
// Each artifact is a fixed 1/8 of the budget, so a byte bound admits
// 8 of them per round and a count bound would admit all 40.
const artifactSize = DefaultCloneBatchBytes / 8
const artifacts = 40
for i := range artifacts {
data := make([]byte, artifactSize)
copy(data, fmt.Sprintf("large blob %d", i))
storeTestBlob(t, r, data)
}
files, payload, next := cloneRound(t, r, 1)
if next == 0 {
t.Fatalf("round 1 drained the whole snapshot: %d files, %d payload bytes", len(files), payload)
}
// The artifact that crosses the budget is still sent whole, so one
// artifact of overshoot is the contract, not a tolerance.
if payload > DefaultCloneBatchBytes+artifactSize {
t.Errorf("round 1 payload = %d bytes, want <= %d",
payload, DefaultCloneBatchBytes+artifactSize)
}
if len(files) >= artifacts {
t.Errorf("round 1 sent %d of %d artifacts; batch was not size-bounded", len(files), artifacts)
}
}
// TestHandleClonePaginationNotCappedByCount proves a corpus of small
// artifacts is not chopped at a fixed count. 2,000 tiny artifacts are far
// below the byte budget, so the whole snapshot must drain in one round.
// Under the old 200-artifact bound this needed ten rounds, which is the
// arithmetic behind #88's ~20,000-artifact ceiling.
func TestHandleClonePaginationNotCappedByCount(t *testing.T) {
r := setupSyncTestRepo(t)
const artifacts = 2000
for i := range artifacts {
storeTestBlob(t, r, []byte(fmt.Sprintf("page blob %d", i)))
}
files, payload, next := cloneRound(t, r, 1)
if payload >= DefaultCloneBatchBytes {
t.Fatalf("test corpus is not below the budget: %d payload bytes", payload)
}
if next != 0 {
t.Errorf("round 1 returned cursor %d after %d files; want 0 (exhausted)", next, len(files))
}
if len(files) < artifacts {
t.Errorf("round 1 sent %d files, want all %d", len(files), artifacts)
}
}
// TestHandleClonePaginationOversizedArtifact pins the liveness edge: a single
// artifact larger than the entire round budget must still be sent, or the
// cursor never advances past it and the clone stalls to MaxRounds.
func TestHandleClonePaginationOversizedArtifact(t *testing.T) {
r := setupSyncTestRepo(t)
oversized := make([]byte, DefaultCloneBatchBytes+1024)
copy(oversized, "oversized blob")
storeTestBlob(t, r, oversized)
storeTestBlob(t, r, []byte("trailing blob"))
files, payload, next := cloneRound(t, r, 1)
if len(files) != 1 {
t.Fatalf("round 1 sent %d files, want exactly the one oversized artifact", len(files))
}
if payload != len(oversized) {
t.Errorf("round 1 payload = %d bytes, want %d", payload, len(oversized))
}
if next == 0 {
t.Fatal("round 1 reported exhaustion but the trailing artifact was never sent")
}
files2, _, next2 := cloneRound(t, r, next)
if len(files2) != 1 || next2 != 0 {
t.Fatalf("round 2: %d files, cursor %d; want 1 file and exhaustion", len(files2), next2)
}
}
// TestHandleClonePagination walks a two-round clone to completion, pinning
// the cursor round trip. The client echoes the server's cursor back on the
// clone card itself; clone_seqno is server-to-client only (issue #74).
func TestHandleClonePagination(t *testing.T) {
r := setupSyncTestRepo(t)
// Two artifacts, each the size of the whole budget: the first fills it
// and the second is owed to a following round.
const artifactSize = DefaultCloneBatchBytes
for i := range 2 {
data := make([]byte, artifactSize)
copy(data, fmt.Sprintf("paged blob %d", i))
storeTestBlob(t, r, data)
}
files1, _, next := cloneRound(t, r, 1)
if len(files1) != 1 {
t.Fatalf("page 1: got %d files, want 1", len(files1))
}
if next == 0 {
t.Fatal("page 1: expected a continuation cursor, got exhaustion")
}
files2, _, next2 := cloneRound(t, r, next)
if len(files2) != 1 {
t.Fatalf("page 2: got %d files, want 1", len(files2))
}
if next2 != 0 {
t.Fatalf("page 2: expected clone_seqno 0 (completion), got %d", next2)
}
}
func TestHandlePushFileStoreFails(t *testing.T) {
r := setupSyncTestRepo(t)
// File with valid push but bad hash → storeReceivedFile returns error → ErrorCard
badUUID := "cccccccccccccccccccccccccccccccccccccccc"
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.FileCard{UUID: badUUID, Content: []byte("wrong hash content")},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) == 0 {
t.Fatal("expected error card for bad hash")
}
}
// TestHandlePushDeltaBeforeBaseDoesNotErrorCard is the push-path acceptance
// test for issue #57 (folded into #53): a client pushing a delta whose base
// the server has never seen is normal transfer steady state, not a fault.
// The server must store it (see storeDeltaAgainstPhantomBase) and continue,
// rather than emitting an ErrorCard.
func TestHandlePushDeltaBeforeBaseDoesNotErrorCard(t *testing.T) {
r := setupPushableTestRepo(t)
base := []byte("push delta-before-base test, base content long enough to compress")
target := []byte("push delta-before-base test, base content long enough to compress, v2")
deltaBytes, err := delta.Create(base, target)
if err != nil {
t.Fatalf("delta.Create: %v", err)
}
baseUUID := hash.SHA1(base)
targetUUID := hash.SHA1(target)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.CFileCard{UUID: targetUUID, DeltaSrc: baseUUID, Content: deltaBytes},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
if errs := findCards[*xfer.ErrorCard](resp); len(errs) != 0 {
t.Fatalf("unexpected error card(s) for delta-before-base push: %v", errs)
}
targetRid, ok := blob.Exists(r.DB(), targetUUID)
if !ok {
t.Fatal("target blob missing after push")
}
var size int64
r.DB().QueryRow("SELECT size FROM blob WHERE rid=?", targetRid).Scan(&size)
if size < 0 {
t.Fatalf("target size = %d, want >= 0 (target must not be phantomized)", size)
}
if _, available := content.AvailableByUUID(r.DB(), targetUUID); available {
t.Fatal("target reported available before its base ever arrived")
}
}
// alwaysAtSite is a BuggifyChecker that fires only for one named site.
type alwaysAtSite string
func (s alwaysAtSite) Check(site string, _ float64) bool { return string(s) == site }
// TestHandleFileMissingAfterStore exercises the regression path from
// issue #14: blob.Exists returning ok=false after a successful
// storeReceivedFile must not reach content.MakePublic / MakePrivate
// (which panic on rid <= 0). The handler should emit an ErrorCard
// and continue processing.
func TestHandleFileMissingAfterStore(t *testing.T) {
r := setupPushableTestRepo(t)
data := []byte("payload that stores cleanly")
uuid := hash.SHA1(data)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.FileCard{UUID: uuid, Content: data},
}}
opts := HandleOpts{Buggify: alwaysAtSite("handler.handleFile.missingAfterStore")}
resp, err := HandleSyncWithOpts(context.Background(), r, req, opts)
if err != nil {
t.Fatalf("HandleSyncWithOpts: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) != 1 {
t.Fatalf("error cards = %d, want 1", len(errs))
}
if want := "missing after store"; !strings.Contains(errs[0].Message, want) {
t.Fatalf("error card message = %q, want containing %q", errs[0].Message, want)
}
}
func TestHandleCFileCard(t *testing.T) {
r := setupPushableTestRepo(t)
data := []byte("cfile content")
uuid := hash.SHA1(data)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.CFileCard{UUID: uuid, Content: data, USize: len(data)},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) > 0 {
t.Fatalf("unexpected error: %s", errs[0].Message)
}
_, ok := blob.Exists(r.DB(), uuid)
if !ok {
t.Fatal("cfile blob not stored")
}
}
func TestHandleLoginAndPragma(t *testing.T) {
r := setupSyncTestRepo(t)
storeTestBlob(t, r, []byte("login pragma test"))
// Pragma cards should be accepted (no login needed for pragma processing)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PragmaCard{Name: "client-version", Values: []string{"22800"}},
&xfer.PragmaCard{Name: "unknown-pragma", Values: []string{"ignored"}},
&xfer.PullCard{ServerCode: "test", ProjectCode: testPC},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) > 0 {
t.Fatalf("unexpected error: %s", errs[0].Message)
}
igots := findCards[*xfer.IGotCard](resp)
if len(igots) == 0 {
t.Fatal("expected igot cards after pragma+pull")
}
}
// TestHandleCloneCursorPastEnd checks that a cursor beyond the last rid
// yields no files. It no longer sends a clone_seqno card — the cursor rides
// on the clone card — so the old name no longer described it.
func TestHandleCloneCursorPastEnd(t *testing.T) {
r := setupSyncTestRepo(t)
// Store blobs, then clone with a seqno that skips them
for i := range 3 {
data := []byte(fmt.Sprintf("seqno test %d", i))
storeTestBlob(t, r, data)
}
// Get all blobs to find the max rid
req1 := &xfer.Message{Cards: []xfer.Card{&xfer.CloneCard{Version: 1}}}
resp1, _ := HandleSync(context.Background(), r, req1)
files1 := findCards[*xfer.CFileCard](resp1)
// Now clone with seqno past all blobs — should get nothing
req2 := &xfer.Message{Cards: []xfer.Card{
&xfer.CloneCard{Version: 1, SeqNo: 9999},
}}
resp2, err := HandleSync(context.Background(), r, req2)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
files2 := findCards[*xfer.CFileCard](resp2)
if len(files2) > 0 {
t.Fatalf("expected no files with high seqno, got %d", len(files2))
}
_ = files1 // used for context
}
func TestHandleEmptyRequest(t *testing.T) {
r := setupSyncTestRepo(t)
req := &xfer.Message{Cards: nil}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
// A completed reply carries nothing but the timestamp comment (draft §3).
if len(resp.Cards) != 1 {
t.Fatalf("empty request: got %d reply cards, want only the timestamp comment",
len(resp.Cards))
}
c, ok := resp.Cards[0].(*xfer.CommentCard)
if !ok || !strings.HasPrefix(c.Text, "timestamp ") || !strings.HasSuffix(c.Text, " errors 0") {
t.Fatalf("empty request: reply card = %#v, want the timestamp comment", resp.Cards[0])
}
}
func TestHandleReqConfigMissing(t *testing.T) {
r := setupSyncTestRepo(t)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.ReqConfigCard{Name: "nonexistent-config"},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
configs := findCards[*xfer.ConfigCard](resp)
if len(configs) > 0 {
t.Fatal("should not return config for nonexistent key")
}
}
// TestHandleSyncNoSpuriousGimmeForReceivedFile verifies that HandleSync
// does NOT emit a GimmeCard for a blob that was delivered as a FileCard
// in the same request. Regression test for the igot-before-file bug:
// if IGotCard is processed before FileCard, blob.Exists returns false
// and a spurious GimmeCard is emitted, causing infinite sync loops.
func TestHandleSyncNoSpuriousGimmeForReceivedFile(t *testing.T) {
r := setupPushableTestRepo(t)
defer r.Close()
// Create a blob to push to the handler.
content := []byte("test content for spurious gimme check")
uuid := hash.SHA1(content)
// Build a request with BOTH IGotCard and FileCard for the same blob.
// This mimics what a sync client sends when pushing a new blob:
// it announces via igot AND delivers the file in the same round.
req := &xfer.Message{
Cards: []xfer.Card{
&xfer.PushCard{
ServerCode: "test-server",
ProjectCode: testPC,
},
&xfer.PullCard{
ServerCode: "test-server",
ProjectCode: testPC,
},
&xfer.IGotCard{UUID: uuid},
&xfer.FileCard{UUID: uuid, Content: content},
},
}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
// Check response for spurious gimme.
for _, card := range resp.Cards {
if g, ok := card.(*xfer.GimmeCard); ok && g.UUID == uuid {
t.Errorf("HandleSync emitted GimmeCard for %s which was delivered as FileCard in the same request — this causes infinite sync loops", uuid[:12])
}
}
// Verify the blob was actually stored.
if _, exists := blob.Exists(r.DB(), uuid); !exists {
t.Errorf("blob %s was not stored by HandleSync", uuid[:12])
}
// Server should NOT emit igot for this blob because the client
// announced it via igot — remoteHas filtering suppresses it.
for _, card := range resp.Cards {
if ig, ok := card.(*xfer.IGotCard); ok && ig.UUID == uuid {
t.Errorf("HandleSync should not emit IGotCard for %s — client already announced it via igot", uuid[:12])
}
}
}
func TestHandlerEmitsPushCardOnClone(t *testing.T) {
r := setupSyncTestRepo(t)
var projectCode, serverCode string
r.DB().QueryRow("SELECT value FROM config WHERE name='project-code'").Scan(&projectCode)
r.DB().QueryRow("SELECT value FROM config WHERE name='server-code'").Scan(&serverCode)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.CloneCard{Version: 1},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
pushCards := findCards[*xfer.PushCard](resp)
if len(pushCards) != 1 {
t.Fatalf("PushCard count = %d, want 1", len(pushCards))
}
if pushCards[0].ProjectCode != projectCode {
t.Errorf("ProjectCode = %q, want %q", pushCards[0].ProjectCode, projectCode)
}
if pushCards[0].ServerCode != serverCode {
t.Errorf("ServerCode = %q, want %q", pushCards[0].ServerCode, serverCode)
}
}
func TestHandlerNoPushCardOnPull(t *testing.T) {
r := setupSyncTestRepo(t)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PullCard{ServerCode: "test", ProjectCode: testPC},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
// Server must NOT emit PushCard on sync/pull — real Fossil treats
// server-sent "push" as unknown command during sync.
pushCards := findCards[*xfer.PushCard](resp)
if len(pushCards) != 0 {
t.Fatalf("PushCard count = %d, want 0 (push is clone-only)", len(pushCards))
}
}
// TestEmitIGots_OnlyUnclustered verifies that after clustering, emitIGots
// returns only unclustered entries (not all blobs in the repo).
func TestEmitIGots_AllBlobs(t *testing.T) {
r := setupSyncTestRepo(t)
// Store 200 blobs — above ClusterThreshold (100).
for i := 0; i < 200; i++ {
data := []byte(fmt.Sprintf("blob-%04d", i))
if _, _, err := blob.Store(r.DB(), data); err != nil {
t.Fatalf("Store blob %d: %v", i, err)
}
}
// Pre-cluster so we have known state before the handler runs.
n, err := content.GenerateClusters(r.DB())
if err != nil {
t.Fatalf("GenerateClusters: %v", err)
}
if n == 0 {
t.Fatal("expected at least 1 cluster to be created")
}
// Send a pull request — handler emits igots for ALL non-phantom blobs.
resp := handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
)
igots := cardsByType(resp, xfer.CardIGot)
var totalBlobs int
r.DB().QueryRow("SELECT count(*) FROM blob WHERE size >= 0").Scan(&totalBlobs)
if len(igots) != totalBlobs {
t.Fatalf("igots = %d, total blobs = %d; emitIGots should send all blobs",
len(igots), totalBlobs)
}
}
// TestPragmaReqClusters verifies that pragma req-clusters causes the handler
// to emit igot cards for cluster artifacts via sendAllClusters.
// TestBreakDropsClusterIGots pins that a break discards the cluster igots
// a req-clusters asked for: fossil answers the pragma in place, and the
// break's reset removes the answer.
func TestBreakDropsClusterIGots(t *testing.T) {
r := setupSyncTestRepo(t)
// A clustered artifact tagged cluster (tag 7) is what req-clusters names.
rid, _, err := blob.Store(r.DB(), []byte("stands in for a cluster"))
if err != nil {
t.Fatalf("blob.Store: %v", err)
}
if _, err := r.DB().Exec("DELETE FROM unclustered WHERE rid=?", rid); err != nil {
t.Fatalf("uncluster: %v", err)
}
_, err = r.DB().Exec("INSERT INTO tagxref(tagid, tagtype, srcid, origid, mtime, rid)"+
" VALUES(7, 1, 0, ?, 0, ?)", rid, rid)
if err != nil {
t.Fatalf("tag cluster: %v", err)
}
bracket := func(cards ...xfer.Card) bool {
resp, err := HandleSync(context.Background(), r, &xfer.Message{Cards: cards})
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
for _, c := range findCards[*xfer.CommentCard](resp) {
if c.Text == "sending-clusters" {
return true
}
}
return false
}
reqClusters := &xfer.PragmaCard{Name: "req-clusters"}
if !bracket(reqClusters) {
t.Fatal("req-clusters sent no cluster igots; the test has no cluster")
}
wrong := &xfer.PullCard{ServerCode: "s", ProjectCode: strings.Repeat("0", 40)}
if bracket(reqClusters, wrong) {
t.Fatal("cluster igots follow a wrong-project break")
}
}
func TestPragmaReqClusters(t *testing.T) {
r := setupSyncTestRepo(t)
// Store 200 blobs — above ClusterThreshold (100).
for i := 0; i < 200; i++ {
data := []byte(fmt.Sprintf("cluster-blob-%04d", i))
if _, _, err := blob.Store(r.DB(), data); err != nil {
t.Fatalf("Store blob %d: %v", i, err)
}
}
// Pre-cluster so we have known state.
n, err := content.GenerateClusters(r.DB())
if err != nil {
t.Fatalf("GenerateClusters: %v", err)
}
if n != 1 {
t.Fatalf("clusters = %d, want 1", n)
}
resp, err := HandleSync(context.Background(), r, &xfer.Message{
Cards: []xfer.Card{
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PragmaCard{Name: "req-clusters"},
},
})
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
igots := cardsByType(resp, xfer.CardIGot)
// emitIGots sends all blobs; sendAllClusters may add cluster igots
// (deduplication happens client-side). Total should include all blobs.
var totalBlobs int
r.DB().QueryRow("SELECT count(*) FROM blob WHERE size >= 0").Scan(&totalBlobs)
if len(igots) < totalBlobs {
t.Fatalf("igots = %d, total blobs = %d; should include at least all blobs",
len(igots), totalBlobs)
}
}
// TestPragmaReqClusters_OldClusters verifies that all blobs are advertised
// even when the unclustered table is empty (all blobs have been clustered).
func TestPragmaReqClusters_OldClusters(t *testing.T) {
r := setupSyncTestRepo(t)
// Store 200 blobs and cluster them.
for i := 0; i < 200; i++ {
data := []byte(fmt.Sprintf("old-cluster-blob-%04d", i))
if _, _, err := blob.Store(r.DB(), data); err != nil {
t.Fatalf("Store blob %d: %v", i, err)
}
}
n, err := content.GenerateClusters(r.DB())
if err != nil {
t.Fatalf("GenerateClusters: %v", err)
}
if n != 1 {
t.Fatalf("first pass clusters = %d, want 1", n)
}
// Manually remove everything from unclustered to simulate old clusters
// that have been clustered in a future pass.
if _, err := r.DB().Exec("DELETE FROM unclustered"); err != nil {
t.Fatalf("clearing unclustered: %v", err)
}
resp, err := HandleSync(context.Background(), r, &xfer.Message{
Cards: []xfer.Card{
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PragmaCard{Name: "req-clusters"},
},
})
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
igots := cardsByType(resp, xfer.CardIGot)
// emitIGots sends ALL blobs regardless of unclustered status.
// sendAllClusters adds cluster igots (may duplicate).
var totalBlobs int
r.DB().QueryRow("SELECT count(*) FROM blob WHERE size >= 0").Scan(&totalBlobs)
if len(igots) < totalBlobs {
t.Fatalf("igots = %d, total blobs = %d; should include all blobs",
len(igots), totalBlobs)
}
}
func TestHandlePushRequiresAuth(t *testing.T) {
r := setupSyncTestRepo(t)
// Delete nobody so anonymous push is rejected
r.DB().Exec("DELETE FROM user WHERE login='nobody'")
data := []byte("auth test")
uuid := hash.SHA1(data)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.FileCard{UUID: uuid, Content: data},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) == 0 {
t.Fatal("expected error for unauthorized push")
}
if _, ok := blob.Exists(r.DB(), uuid); ok {
t.Fatal("blob should not be stored without push capability")
}
}
func TestHandlePullRequiresAuth(t *testing.T) {
r := setupSyncTestRepo(t)
storeTestBlob(t, r, []byte("pull auth test"))
// Delete nobody so anonymous pull is rejected
r.DB().Exec("DELETE FROM user WHERE login='nobody'")
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PullCard{ServerCode: "test", ProjectCode: testPC},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) == 0 {
t.Fatal("expected error for unauthorized pull")
}
igots := findCards[*xfer.IGotCard](resp)
if len(igots) > 0 {
t.Fatal("should not emit igots without pull capability")
}
}
func TestHandleAuthenticatedPush(t *testing.T) {
r := setupSyncTestRepo(t)
pc := testProjectCode(t, r.DB())
// Delete nobody, create a user with push caps
r.DB().Exec("DELETE FROM user WHERE login='nobody'")
auth.CreateUser(r.DB(), pc, "pusher", "secret", "oi")
data := []byte("authed push")
uuid := hash.SHA1(data)
req := signedRequest(t, "pusher", "secret", pc,
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.FileCard{UUID: uuid, Content: data},
)
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) > 0 {
t.Fatalf("unexpected error: %s", errs[0].Message)
}
if _, ok := blob.Exists(r.DB(), uuid); !ok {
t.Fatal("authenticated push should store blob")
}
}
func TestHandleNobodyPullOnly(t *testing.T) {
r := setupSyncTestRepo(t)
storeTestBlob(t, r, []byte("nobody test"))
// Set nobody to pull-only
r.DB().Exec("UPDATE user SET cap='o' WHERE login='nobody'")
// Pull should work
pullReq := &xfer.Message{Cards: []xfer.Card{
&xfer.PullCard{ServerCode: "test", ProjectCode: testPC},
}}
pullResp, _ := HandleSync(context.Background(), r, pullReq)
igots := findCards[*xfer.IGotCard](pullResp)
if len(igots) == 0 {
t.Fatal("nobody with 'o' cap should allow pull")
}
// Push should fail
data := []byte("nobody push attempt")
uuid := hash.SHA1(data)
pushReq := &xfer.Message{Cards: []xfer.Card{
&xfer.PushCard{ServerCode: "test", ProjectCode: testPC},
&xfer.FileCard{UUID: uuid, Content: data},
}}
pushResp, _ := HandleSync(context.Background(), r, pushReq)
errs := findCards[*xfer.ErrorCard](pushResp)
if len(errs) == 0 {
t.Fatal("nobody with 'o' cap should reject push")
}
}
func TestEmitIGots_ExcludesShunAndPrivate(t *testing.T) {
r := setupSyncTestRepo(t)
_, normalUUID, err := blob.Store(r.DB(), []byte("normal-blob-content"))
if err != nil {
t.Fatalf("Store normal: %v", err)
}
_, shunnedUUID, err := blob.Store(r.DB(), []byte("shunned-blob-content"))
if err != nil {
t.Fatalf("Store shunned: %v", err)
}
privRid, _, err := blob.Store(r.DB(), []byte("private-blob-content"))
if err != nil {
t.Fatalf("Store private: %v", err)
}
if _, err := r.DB().Exec("INSERT INTO shun(uuid, mtime) VALUES(?, 0)", shunnedUUID); err != nil {
t.Fatalf("shun: %v", err)
}
if _, err := r.DB().Exec("INSERT INTO private(rid) VALUES(?)", privRid); err != nil {
t.Fatalf("private: %v", err)
}
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PullCard{ServerCode: "test", ProjectCode: testPC},
}}
resp, err := HandleSync(context.Background(), r, req)
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
igotUUIDs := make(map[string]bool)
for _, c := range resp.Cards {
if ig, ok := c.(*xfer.IGotCard); ok {
igotUUIDs[ig.UUID] = true
}
}
if !igotUUIDs[normalUUID] {
t.Error("normal blob missing from igots")
}
if igotUUIDs[shunnedUUID] {
t.Error("shunned blob appeared in igots")
}
var privUUID string
if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", privRid).Scan(&privUUID); err != nil {
t.Fatalf("query privUUID: %v", err)
}
if igotUUIDs[privUUID] {
t.Error("private blob appeared in igots")
}
}
func TestHandlerPragmaSendPrivate_Accepted(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'")
resp := handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PragmaCard{Name: "send-private"},
)
for _, c := range resp.Cards {
if e, ok := c.(*xfer.ErrorCard); ok {
if e.Message == "not authorized to sync private content" {
t.Error("should not get auth error with 'x' capability")
}
}
}
}
func TestHandlerPragmaSendPrivate_Rejected(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='oi' WHERE login='nobody'")
resp := handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PragmaCard{Name: "send-private"},
)
errors := findCards[*xfer.ErrorCard](resp)
found := false
for _, e := range errors {
if e.Message == "not authorized to sync private content" {
found = true
}
}
if !found {
t.Error("expected 'not authorized to sync private content' error")
}
}
func TestHandlerPrivateCardAccepted(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'")
data := []byte("private blob data")
uuid := hash.SHA1(data)
resp := handleReq(t, r,
&xfer.PushCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PrivateCard{},
&xfer.FileCard{UUID: uuid, Content: data},
)
for _, c := range resp.Cards {
if e, ok := c.(*xfer.ErrorCard); ok {
t.Errorf("unexpected error: %s", e.Message)
}
}
rid, ok := blob.Exists(r.DB(), uuid)
if !ok {
t.Fatal("blob not stored")
}
if !content.IsPrivate(r.DB(), int64(rid)) {
t.Error("blob should be marked private")
}
}
func TestHandlerPrivateCardRejected(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='oi' WHERE login='nobody'")
data := []byte("private blob rejected")
uuid := hash.SHA1(data)
resp := handleReq(t, r,
&xfer.PushCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PrivateCard{},
&xfer.FileCard{UUID: uuid, Content: data},
)
errors := findCards[*xfer.ErrorCard](resp)
found := false
for _, e := range errors {
if e.Message == "not authorized to sync private content" {
found = true
}
}
if !found {
t.Error("expected 'not authorized to sync private content' error")
}
}
func TestHandlerPublicFileClearsPrivate(t *testing.T) {
r := setupPushableTestRepo(t)
data := []byte("was private now public")
uuid := hash.SHA1(data)
// Pre-store as private.
storeReceivedFile(context.Background(), r, uuid, "", data, nil, visibilityPublic, nil)
rid, _ := blob.Exists(r.DB(), uuid)
content.MakePrivate(r.DB(), int64(rid))
if !content.IsPrivate(r.DB(), int64(rid)) {
t.Fatal("precondition: blob should be private")
}
// Push same blob WITHOUT private card — should clear private.
resp := handleReq(t, r,
&xfer.PushCard{ServerCode: "s", ProjectCode: testPC},
&xfer.FileCard{UUID: uuid, Content: data},
)
for _, c := range resp.Cards {
if e, ok := c.(*xfer.ErrorCard); ok {
t.Errorf("unexpected error: %s", e.Message)
}
}
if content.IsPrivate(r.DB(), int64(rid)) {
t.Error("blob should no longer be private after public file push")
}
}
func TestEmitIGotsExcludesPrivate(t *testing.T) {
r := setupSyncTestRepo(t)
pubUUID := storeTestBlob(t, r, []byte("public blob"))
privData := []byte("private blob for exclusion")
privUUID := storeTestBlob(t, r, privData)
privRid, _ := blob.Exists(r.DB(), privUUID)
content.MakePrivate(r.DB(), int64(privRid))
// Pull without send-private pragma — private blob should be excluded.
resp := handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
)
igotUUIDs := make(map[string]bool)
for _, c := range resp.Cards {
if ig, ok := c.(*xfer.IGotCard); ok {
igotUUIDs[ig.UUID] = true
}
}
if !igotUUIDs[pubUUID] {
t.Error("public blob missing from igots")
}
if igotUUIDs[privUUID] {
t.Error("private blob should be excluded from igots without send-private")
}
}
func TestEmitIGotsIncludesPrivateWhenAuthorized(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'")
pubUUID := storeTestBlob(t, r, []byte("public blob auth"))
privData := []byte("private blob auth")
privUUID := storeTestBlob(t, r, privData)
privRid, _ := blob.Exists(r.DB(), privUUID)
content.MakePrivate(r.DB(), int64(privRid))
// Pull WITH send-private pragma and x capability.
resp := handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PragmaCard{Name: "send-private"},
)
pubFound := false
privFound := false
for _, c := range resp.Cards {
if ig, ok := c.(*xfer.IGotCard); ok {
if ig.UUID == pubUUID {
pubFound = true
}
if ig.UUID == privUUID {
if !ig.IsPrivate {
t.Error("private blob igot should have IsPrivate=true")
}
privFound = true
}
}
}
if !pubFound {
t.Error("public blob missing from igots")
}
if !privFound {
t.Error("private blob should be included when send-private is authorized")
}
}
func TestHandlerIGotPrivate_Authorized(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'")
unknownUUID := "dddddddddddddddddddddddddddddddddddddd"
resp := handleReq(t, r,
&xfer.PushCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PragmaCard{Name: "send-private"},
&xfer.IGotCard{UUID: unknownUUID, IsPrivate: true},
)
gimmes := findCards[*xfer.GimmeCard](resp)
found := false
for _, g := range gimmes {
if g.UUID == unknownUUID {
found = true
}
}
if !found {
t.Error("authorized private igot should produce gimme")
}
}
func TestHandlerIGotPrivate_Unauthorized(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='oi' WHERE login='nobody'")
unknownUUID := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
resp := handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.IGotCard{UUID: unknownUUID, IsPrivate: true},
)
gimmes := findCards[*xfer.GimmeCard](resp)
for _, g := range gimmes {
if g.UUID == unknownUUID {
t.Error("unauthorized private igot should NOT produce gimme")
}
}
}
func TestHandlerIGotDoesNotChangeServerPrivateStatus(t *testing.T) {
r := setupSyncTestRepo(t)
// Store a blob and mark it private.
data := []byte("igot does not change server private status")
uuid := storeTestBlob(t, r, data)
rid, _ := blob.Exists(r.DB(), uuid)
content.MakePrivate(r.DB(), int64(rid))
if !content.IsPrivate(r.DB(), int64(rid)) {
t.Fatal("precondition: blob should be private")
}
// Client sends a public igot for the existing blob.
// Server is authoritative — this should NOT change the server's private status.
handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.IGotCard{UUID: uuid, IsPrivate: false},
)
if !content.IsPrivate(r.DB(), int64(rid)) {
t.Error("server private status should not be changed by client igot")
}
}
func TestHandleGimmePrivate_Authorized(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'")
data := []byte("gimme private blob")
uuid := storeTestBlob(t, r, data)
rid, _ := blob.Exists(r.DB(), uuid)
content.MakePrivate(r.DB(), int64(rid))
resp := handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PragmaCard{Name: "send-private"},
&xfer.GimmeCard{UUID: uuid},
)
// Should get PrivateCard followed by FileCard.
files := findCards[*xfer.FileCard](resp)
found := false
for _, f := range files {
if f.UUID == uuid {
found = true
}
}
if !found {
t.Error("authorized gimme for private blob should return file")
}
privCards := findCards[*xfer.PrivateCard](resp)
if len(privCards) == 0 {
t.Error("expected PrivateCard prefix before private file")
}
}
func TestHandleGimmePrivate_Unauthorized(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='oi' WHERE login='nobody'")
data := []byte("gimme private unauthorized")
uuid := storeTestBlob(t, r, data)
rid, _ := blob.Exists(r.DB(), uuid)
content.MakePrivate(r.DB(), int64(rid))
resp := handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.GimmeCard{UUID: uuid},
)
files := findCards[*xfer.FileCard](resp)
for _, f := range files {
if f.UUID == uuid {
t.Error("unauthorized gimme for private blob should NOT return file")
}
}
}
func TestEmitCloneBatchSkipsPrivate(t *testing.T) {
r := setupSyncTestRepo(t)
pubData := []byte("clone public blob")
pubUUID := storeTestBlob(t, r, pubData)
privData := []byte("clone private blob")
privUUID := storeTestBlob(t, r, privData)
privRid, _ := blob.Exists(r.DB(), privUUID)
content.MakePrivate(r.DB(), int64(privRid))
resp := handleReq(t, r,
&xfer.CloneCard{Version: 1},
)
files := findCards[*xfer.CFileCard](resp)
fileUUIDs := make(map[string]bool)
for _, f := range files {
fileUUIDs[f.UUID] = true
}
if !fileUUIDs[pubUUID] {
t.Error("public blob missing from clone batch")
}
if fileUUIDs[privUUID] {
t.Error("private blob should be excluded from clone batch without send-private")
}
}
func TestEmitCloneBatchIncludesPrivateWhenAuthorized(t *testing.T) {
r := setupSyncTestRepo(t)
r.DB().Exec("UPDATE user SET cap='gx' WHERE login='nobody'")
pubData := []byte("clone pub auth")
pubUUID := storeTestBlob(t, r, pubData)
privData := []byte("clone priv auth")
privUUID := storeTestBlob(t, r, privData)
privRid, _ := blob.Exists(r.DB(), privUUID)
content.MakePrivate(r.DB(), int64(privRid))
resp := handleReq(t, r,
&xfer.CloneCard{Version: 1},
&xfer.PragmaCard{Name: "send-private"},
)
files := findCards[*xfer.CFileCard](resp)
fileUUIDs := make(map[string]bool)
for _, f := range files {
fileUUIDs[f.UUID] = true
}
if !fileUUIDs[pubUUID] {
t.Error("public blob missing from clone batch")
}
if !fileUUIDs[privUUID] {
t.Error("private blob should be included when send-private authorized")
}
privCards := findCards[*xfer.PrivateCard](resp)
if len(privCards) == 0 {
t.Error("expected PrivateCard prefix for private blob in clone batch")
}
}
func TestHandlerIGotFiltersEmit(t *testing.T) {
r := setupSyncTestRepo(t)
// Store 3 blobs with known UUIDs.
data1 := []byte("igot-filter-blob-one")
data2 := []byte("igot-filter-blob-two")
data3 := []byte("igot-filter-blob-three")
uuid1 := storeTestBlob(t, r, data1)
uuid2 := storeTestBlob(t, r, data2)
uuid3 := storeTestBlob(t, r, data3)
// Client announces it already has blob 1 and blob 2.
resp := handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.IGotCard{UUID: uuid1},
&xfer.IGotCard{UUID: uuid2},
)
igots := findCards[*xfer.IGotCard](resp)
igotUUIDs := make(map[string]bool)
for _, ig := range igots {
igotUUIDs[ig.UUID] = true
}
// Server should NOT echo back blobs the client already has.
if igotUUIDs[uuid1] {
t.Errorf("server should not emit igot for uuid1 (%s) — client already has it", uuid1)
}
if igotUUIDs[uuid2] {
t.Errorf("server should not emit igot for uuid2 (%s) — client already has it", uuid2)
}
// Server SHOULD emit igot for blob 3, which the client didn't announce.
if !igotUUIDs[uuid3] {
t.Errorf("server should emit igot for uuid3 (%s) — client doesn't have it", uuid3)
}
}
func TestSendAllClustersExcludesPrivate(t *testing.T) {
r := setupSyncTestRepo(t)
// Store 200 blobs so clustering triggers.
for i := 0; i < 200; i++ {
data := []byte(fmt.Sprintf("cluster-priv-blob-%04d", i))
if _, _, err := blob.Store(r.DB(), data); err != nil {
t.Fatalf("Store blob %d: %v", i, err)
}
}
n, err := content.GenerateClusters(r.DB())
if err != nil {
t.Fatalf("GenerateClusters: %v", err)
}
if n == 0 {
t.Fatal("expected at least 1 cluster")
}
// Mark the cluster artifact itself as private.
var clusterRid int
err = r.DB().QueryRow(`
SELECT tx.rid FROM tagxref tx
WHERE tx.tagid = 7
LIMIT 1`,
).Scan(&clusterRid)
if err != nil {
t.Fatalf("find cluster rid: %v", err)
}
content.MakePrivate(r.DB(), int64(clusterRid))
resp, err := HandleSync(context.Background(), r, &xfer.Message{
Cards: []xfer.Card{
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PragmaCard{Name: "req-clusters"},
},
})
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
// The private cluster should not appear in igots.
var clusterUUID string
r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", clusterRid).Scan(&clusterUUID)
for _, c := range resp.Cards {
if ig, ok := c.(*xfer.IGotCard); ok && ig.UUID == clusterUUID {
t.Error("private cluster blob should be excluded from sendAllClusters")
}
}
}
func TestHandleGimmeWithContentCache(t *testing.T) {
r := setupSyncTestRepo(t)
data := []byte("cached gimme blob")
uuid := storeTestBlob(t, r, data)
cache := content.NewCache(1 << 20)
req := &xfer.Message{Cards: []xfer.Card{
&xfer.PullCard{ServerCode: "test", ProjectCode: testPC},
&xfer.GimmeCard{UUID: uuid},
}}
resp, err := HandleSyncWithOpts(context.Background(), r, req, HandleOpts{
ContentCache: cache,
})
if err != nil {
t.Fatalf("HandleSyncWithOpts: %v", err)
}
files := findCards[*xfer.FileCard](resp)
found := false
for _, f := range files {
if f.UUID == uuid && string(f.Content) == string(data) {
found = true
}
}
if !found {
t.Fatal("expected file card with correct content via cached handler")
}
// Cache should have been populated.
stats := cache.Stats()
if stats.Misses == 0 {
t.Fatal("expected at least 1 cache miss (the initial expand)")
}
// Second request for the same blob should hit cache.
resp2, err := HandleSyncWithOpts(context.Background(), r, req, HandleOpts{
ContentCache: cache,
})
if err != nil {
t.Fatal(err)
}
files2 := findCards[*xfer.FileCard](resp2)
found2 := false
for _, f := range files2 {
if f.UUID == uuid && string(f.Content) == string(data) {
found2 = true
}
}
if !found2 {
t.Fatal("expected file card on second request (cache hit path)")
}
stats2 := cache.Stats()
if stats2.Hits == 0 {
t.Fatal("expected cache hit on second handler call")
}
}
func TestSyncRoundTripWithContentCache(t *testing.T) {
server := setupSyncTestRepo(t)
client := setupSyncTestRepo(t)
// Store a blob on the server.
data := []byte("sync round trip cached data")
serverUUID := storeTestBlob(t, server, data)
cache := content.NewCache(1 << 20)
transport := &MockTransport{
Handler: func(req *xfer.Message) *xfer.Message {
resp, err := HandleSyncWithOpts(context.Background(), server, req, HandleOpts{
ContentCache: cache,
})
if err != nil {
t.Fatalf("HandleSyncWithOpts: %v", err)
}
return resp
},
}
result, err := Sync(context.Background(), client, transport, SyncOpts{
Pull: true,
ContentCache: cache,
})
if err != nil {
t.Fatalf("Sync: %v", err)
}
if result.FilesRecvd == 0 {
t.Fatal("expected to receive files")
}
// Verify client has the blob.
rid, ok := blob.Exists(client.DB(), serverUUID)
if !ok {
t.Fatal("blob not found on client after sync")
}
got, err := content.Expand(client.DB(), rid)
if err != nil {
t.Fatal(err)
}
if string(got) != string(data) {
t.Fatalf("got %q, want %q", got, data)
}
// Cache should have recorded activity.
stats := cache.Stats()
if stats.Hits+stats.Misses == 0 {
t.Fatal("cache was never used during sync")
}
}
func TestHandlerIGotFiltersPrivateEmit(t *testing.T) {
r := setupSyncTestRepo(t)
// Grant private sync capability.
r.DB().Exec("UPDATE user SET cap='oix' WHERE login='nobody'")
// Store a blob and mark it private.
data := []byte("igot-filter-private-blob")
uuid := storeTestBlob(t, r, data)
rid, _ := blob.Exists(r.DB(), uuid)
content.MakePrivate(r.DB(), int64(rid))
// Store a second private blob the client does NOT have.
data2 := []byte("igot-filter-private-blob-two")
uuid2 := storeTestBlob(t, r, data2)
rid2, _ := blob.Exists(r.DB(), uuid2)
content.MakePrivate(r.DB(), int64(rid2))
// Client announces it has the first private blob.
resp := handleReq(t, r,
&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
&xfer.PragmaCard{Name: "send-private"},
&xfer.IGotCard{UUID: uuid, IsPrivate: true},
)
igots := findCards[*xfer.IGotCard](resp)
for _, ig := range igots {
if ig.UUID == uuid {
t.Errorf("server should not emit private igot for %s — client already has it", uuid)
}
}
// The second private blob should still appear.
found := false
for _, ig := range igots {
if ig.UUID == uuid2 && ig.IsPrivate {
found = true
}
}
if !found {
t.Errorf("server should emit private igot for %s — client doesn't have it", uuid2)
}
}
// TestHandleCloneZeroSeqNoIsFatal pins §8.1: an otherwise authorized clone
// request with exactly three tokens, VERSION >= 2, and a digit-only SEQNO of
// zero or less clears accumulated output, emits `invalid clone sequence
// number`, and parses no later request card. Before the presence flag existed
// the server could not see this case at all — `clone` and `clone 3 0` both
// decoded to SeqNo 0 — and it silently clamped the cursor to 1 instead.
func TestHandleCloneZeroSeqNoIsFatal(t *testing.T) {
r := setupSyncTestRepo(t)
for i := range 3 {
storeTestBlob(t, r, []byte(fmt.Sprintf("fatal seqno %d", i)))
}
resp, err := HandleSync(context.Background(), r, &xfer.Message{Cards: []xfer.Card{
&xfer.CloneCard{Version: 3, SeqNo: 0, SeqNoIsDecimal: true},
&xfer.ReqConfigCard{Name: "/project"}, // must not be parsed
}})
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
errs := findCards[*xfer.ErrorCard](resp)
if len(errs) != 1 || errs[0].Message != "invalid clone sequence number" {
t.Fatalf("error cards = %v, want exactly [invalid clone sequence number]", errs)
}
// Accumulated output is cleared: nothing but the error survives.
if len(resp.Cards) != 1 {
t.Errorf("resp has %d cards, want 1 (output must be cleared)", len(resp.Cards))
}
if n := len(findCards[*xfer.CFileCard](resp)); n != 0 {
t.Errorf("sent %d cfile cards on a fatal clone request, want 0", n)
}
if n := len(findCards[*xfer.ConfigCard](resp)); n != 0 {
t.Errorf("later reqconfig card was parsed; §8.1 forbids it")
}
}
// TestHandleCloneNonFatalSeqNoForms pins the cases §8.1 explicitly withholds
// the fatal from: wrong arity, a VERSION below 2, and a SEQNO that fails
// digit-only recognition. Each must clone normally from the first rid.
func TestHandleCloneNonFatalSeqNoForms(t *testing.T) {
// A negative SEQNO leaves page_xfer with no cursor: no content goes out,
// and the reply says the clone is done (sync.8.1.negative-seqno).
tests := []struct {
name string
card *xfer.CloneCard
wantFiles bool
}{
{"bare clone", &xfer.CloneCard{}, true},
{"version below 2",
&xfer.CloneCard{Version: 1, SeqNo: 0, SeqNoIsDecimal: true}, true},
{"non-digit seqno", &xfer.CloneCard{Version: 3, SeqNo: -1}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := setupSyncTestRepo(t)
storeTestBlob(t, r, []byte("non-fatal clone form "+tt.name))
resp, err := HandleSync(context.Background(), r, &xfer.Message{
Cards: []xfer.Card{tt.card},
})
if err != nil {
t.Fatalf("HandleSync: %v", err)
}
for _, e := range findCards[*xfer.ErrorCard](resp) {
if e.Message == "invalid clone sequence number" {
t.Fatalf("§8.1 fatal applied to a case it excludes")
}
}
n := len(findCards[*xfer.CFileCard](resp))
if tt.wantFiles && n == 0 {
t.Errorf("no files sent; request should clone from the first rid")
}
if !tt.wantFiles && n != 0 {
t.Errorf("%d files sent for a clone with no cursor", n)
}
seqnos := findCards[*xfer.CloneSeqNoCard](resp)
if !tt.wantFiles && (len(seqnos) != 1 || seqnos[0].SeqNo != 0) {
t.Errorf("clone_seqno cards %v, want one clone_seqno 0", seqnos)
}
})
}
}
// alwaysAtSites is a BuggifyChecker that fires for any of several named sites.
type alwaysAtSites []string
func (s alwaysAtSites) Check(site string, _ float64) bool {
for _, want := range s {
if want == site {
return true
}
}
return false
}
// TestEmitCloneBatchCursorAlwaysAdvances pins the liveness invariant behind
// emitCloneBatch's `count > 1` truncate guard. The cursor is inclusive now
// (`rid >= cursor`), so dropping the only card in a batch would report
// nextRID == lastSentRID == the cursor we entered with, and the client would
// re-request the same batch until MaxRounds. Under the old exclusive
// semantics `count > 0` was safe, which is why this is a constraint rather
// than a chaos-injection detail.
//
// smallBatch shrinks the round budget to one byte, which yields exactly
// one artifact per round, so every batch is the count == 1 case, and
// truncate is forced alongside it so the guard is the only thing preventing
// a non-advancing cursor. Each round must strictly advance or report 0.
func TestEmitCloneBatchCursorAlwaysAdvances(t *testing.T) {
r := setupSyncTestRepo(t)
for i := range 5 {
storeTestBlob(t, r, []byte(fmt.Sprintf("advance %d", i)))
}
bug := alwaysAtSites{
"handler.emitCloneBatch.smallBatch",
"clone.emitCloneBatch.truncate",
}
cursor := 1
for round := 0; round < 50; round++ {
resp, err := HandleSyncWithOpts(context.Background(), r, &xfer.Message{
Cards: []xfer.Card{&xfer.CloneCard{Version: 3, SeqNo: cursor, SeqNoIsDecimal: true}},
}, HandleOpts{Buggify: bug})
if err != nil {
t.Fatalf("round %d (cursor %d): %v", round, cursor, err)
}
seqnos := findCards[*xfer.CloneSeqNoCard](resp)
if len(seqnos) != 1 {
t.Fatalf("round %d: got %d clone_seqno cards, want 1", round, len(seqnos))
}
next := seqnos[0].SeqNo
if next == 0 {
return // Exhausted, as expected.
}
if next <= cursor {
t.Fatalf("round %d: cursor did not advance (%d -> %d); clone would stall to MaxRounds",
round, cursor, next)
}
cursor = next
}
t.Fatalf("cursor never reached exhaustion in 50 rounds (last %d)", cursor)
}