Artifact 3d0203f90c5efc770a665aa4a39a6d20cc71e8061ee6b19a3780ea0dcab030c2:
- File
internal/sync/handler_test.go
— part of check-in
[37271d3f68]
at
2026-07-19 23:59:09
on branch trunk
— fix: bound clone batches by output size, not artifact count (#100)
* fix: bound clone batches by output size, not artifact count (#88, #92)
The xfer server paginated a clone by a fixed 200-artifact count. A count cannot adapt to artifact size: 200 control artifacts and 200 large blobs produce wildly different messages, and neither corresponds to what the transport can carry. It capped a libfossil-served clone near 20,000 artifacts against our own MaxRounds=100 client, and left the response size unbounded in the other direction -- serving libfossil's own repository produced a single 44.9 MB round.
emitCloneBatch now accumulates the wire bytes it emits and stops once they reach DefaultCloneBatchBytes, mirroring canonical's `while( mxSend > blob_size(pOut) )`: the budget is tested before each artifact and the artifact that crosses it is still sent whole, which is what keeps the cursor advancing when one artifact exceeds the entire budget. The card header is charged alongside the payload so a repository of zero-length artifacts still paginates. Section 8.2 leaves the bound to the server ("clients MUST NOT infer count or size from range").
The budget is 16 MB rather than canonical's 5,000,000 max-download because this clone path emits fully expanded artifacts where canonical retransmits stored compressed form (Section 7.2) -- 80x to 220x the bytes on real repositories. Measured on libfossil's own repository (17,218 artifacts): 87 rounds and a 44.9 MB worst-case response before, 65 rounds and 24.7 MB after. Canonical's constant applied to expanded bytes would have needed 184 rounds, breaking clones that work today.
Adds the interop half: a canonical fossil 2.28 client cloning a generated corpus from `repo serve`, sized past the pagination boundary, checked for round-trip count, artifact parity with the source, and `fossil test-integrity`. The many-small-artifacts case is what discriminates the two bounds -- 2,056 artifacts clone in 3 round-trips under the size bound and 23 under the count bound. Corpus size is a parameter since the interesting behaviour is at the boundary. A missing fossil binary skips, or fails under REQUIRE_FOSSIL_BIN=1.
* review: state the ceiling honestly, fix the overhead comment
The size bound replaces an artifact-count ceiling with an expanded-bytes ceiling. It is a better ceiling and it beats the count bound on every corpus measured, but fossil- and sqlite-sized repositories still cannot be cloned from a libfossil server within MaxRounds. Record the full three-repository round table on DefaultCloneBatchBytes and say outright that only Section 7.2 removes the ceiling rather than moving it.
State the memory property as budget plus the largest artifact, not as a 24.7 MB ceiling. The crossing artifact is sent whole, so a repository holding one 2 GB artifact still emits a 2 GB round, and concurrent clones multiply it. 45 MB -> 25 MB is a large reduction of a pre-existing unbounded allocation, not a cap.
cloneCardOverheadBytes claimed to cover the hash and then len(uuid) was added on top, double-charging it. Drop the addend; 96 over-approximates the ~58-byte framing on its own and the slack absorbs the uncharged PrivateCard.
Also: smallBatch sets a one-byte budget rather than a batch size of one, so correct the comments that still describe the old knob; drop the post-loop multi-batch > single-batch check, which passes under both bounds and so discriminates nothing; and make serveRepo report a server that exited early instead of signalling into an uninstalled handler, where SIGINT's default action would kill the test binary outright.
* test: count clone batches on the server, not round-trips on the client
CI (fossil 2.23) and this machine (2.28) disagreed on an identical server: the multi-batch clone reported 1 round-trip against CI and 9 locally. The clone was correct in both cases.
fossil prints "Round-trips: N" carriage-return separated, once per round. 2.28 guards those prints behind bOutIsTty and adds a single final line for non-TTY output, so a captured clone yields exactly one occurrence holding the total. 2.23 has no such guard and emits every round into the capture, so the regex's first match was always 1 no matter how many rounds ran. Verified by cloning one repository from a real fossil server with both binaries: 2.28 prints "Round-trips:" once, 2.23 four times, first line 1 and last line 2.
So the assertion silently meant different things on different clients, and many-small-artifacts -- the only case that discriminates a size bound from a count bound -- was passing under 2.23 for the wrong reason, inert rather than green.
Count the clone batches the server emits instead. That is the property under test, it cannot be distorted by client-side reporting, and it is sharper: many-small-artifacts is 2 batches under the size bound against 22 under a reinstated count bound, where the round-trip numbers were 3 against 23.
This serves via sync.ServeHTTP directly so the handler can be wrapped. RepoServeCmd.Run is a five-line wrapper around the same call and TestRepoServeCmdCanonicalFossilClone still drives the command end to end. It also drops these subtests' use of the process-wide SIGINT. (user: [email protected], size: 53104) [annotate] [blame] [check-ins using]
package sync import ( "context" "crypto/sha1" "encoding/hex" "fmt" "strings" "testing" "github.com/danmestas/libfossil/internal/auth" "github.com/danmestas/libfossil/internal/blob" "github.com/danmestas/libfossil/internal/content" "github.com/danmestas/libfossil/db" "github.com/danmestas/libfossil/internal/delta" "github.com/danmestas/libfossil/internal/hash" "github.com/danmestas/libfossil/internal/repo" "github.com/danmestas/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(r, uuid, "", data); 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 buildTestLoginCard(user, password, projectCode string, payload []byte) *xfer.LoginCard { nonce := testSHA1Hex(payload) shared := testSHA1Hex([]byte(projectCode + "/" + user + "/" + password)) sig := testSHA1Hex([]byte(nonce + shared)) return &xfer.LoginCard{User: user, Nonce: nonce, Signature: sig} } func testSHA1Hex(data []byte) string { h := sha1.Sum(data) return hex.EncodeToString(h[:]) } 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: "test"}, }} 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) unknownUUID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" req := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "test", ProjectCode: "test"}, &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 := setupSyncTestRepo(t) unknownUUID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &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") } } 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: "test"}, &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 := setupSyncTestRepo(t) data := []byte("push this") uuid := hash.SHA1(data) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &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") } } 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) } files := findCards[*xfer.FileCard](resp) for _, f := range files { delete(stored, f.UUID) } if len(stored) > 0 { t.Fatalf("clone missing blobs: %v", stored) } } // cloneRound runs one clone round at the given cursor and returns the file // 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.FileCard, 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.FileCard](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 TestHandleReqConfig(t *testing.T) { r := setupSyncTestRepo(t) req := &xfer.Message{Cards: []xfer.Card{ &xfer.ReqConfigCard{Name: "project-code"}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } configs := findCards[*xfer.ConfigCard](resp) found := false for _, c := range configs { if c.Name == "project-code" && len(c.Content) > 0 { found = true } } if !found { t.Fatal("expected config card for project-code") } } 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: "test"}, &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 := setupSyncTestRepo(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 := delta.Create(base, target) baseUUID := hash.SHA1(base) targetUUID := hash.SHA1(target) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &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 := setupSyncTestRepo(t) data := []byte("payload that stores cleanly") uuid := hash.SHA1(data) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &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 := setupSyncTestRepo(t) data := []byte("cfile content") uuid := hash.SHA1(data) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &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: "test"}, }} 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.FileCard](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.FileCard](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) } if len(resp.Cards) != 0 { t.Fatalf("expected empty response for empty request, got %d cards", len(resp.Cards)) } } 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 := setupSyncTestRepo(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: "test-project", }, &xfer.PullCard{ ServerCode: "test-server", ProjectCode: "test-project", }, &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: "test"}, }} 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: "p"}, ) 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. 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: "p"}, &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: "p"}, &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: "test"}, &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: "test"}, }} 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) // Build a valid login card — nonce is SHA1 of the non-login card payload loginCard := buildTestLoginCard("pusher", "secret", pc, []byte("dummy")) req := &xfer.Message{Cards: []xfer.Card{ loginCard, &xfer.PushCard{ServerCode: "test", ProjectCode: "test"}, &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: "test"}, }} 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: "test"}, &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: "test"}, }} 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: "p"}, &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: "p"}, &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: "p"}, &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: "p"}, &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 := setupSyncTestRepo(t) data := []byte("was private now public") uuid := hash.SHA1(data) // Pre-store as private. storeReceivedFile(r, uuid, "", 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") } // Push same blob WITHOUT private card — should clear private. resp := handleReq(t, r, &xfer.PushCard{ServerCode: "s", ProjectCode: "p"}, &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: "p"}, ) 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: "p"}, &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.PullCard{ServerCode: "s", ProjectCode: "p"}, &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: "p"}, &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: "p"}, &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: "p"}, &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: "p"}, &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.FileCard](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.FileCard](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: "p"}, &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: "p"}, &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: "test"}, &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: "p"}, &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-code"}, // 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.FileCard](resp)); n != 0 { t.Errorf("sent %d file 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) { tests := []struct { name string card *xfer.CloneCard }{ {"bare clone", &xfer.CloneCard{}}, {"version below 2", &xfer.CloneCard{Version: 1, SeqNo: 0, SeqNoIsDecimal: true}}, {"non-digit seqno", &xfer.CloneCard{Version: 3, SeqNo: -1}}, } 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") } } if n := len(findCards[*xfer.FileCard](resp)); n == 0 { t.Errorf("no files sent; request should clone from the first rid") } }) } } // 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) }