Artifact 4341576a8eb71ea0f9a203d75e2b745c3c3c81bad15db8b589fd8919e7669564:
- File
internal/sync/pullonly_converge_test.go
— part of check-in
[c9436f82cc]
at
2026-10-12 18:07:04
on branch main
— fix(sync): handle unknown, error and pull-only cards per the draft (#300) (#341)
The server ignored reply-only cards a client should never send and the client read on past cards that end a reply.
On the server, a client-sent error or message card -- reply-only cards fossil's page_xfer has no branch for -- now answers "bad command" and resets the reply, not silence. A push card from a requester who may read but not write answers "message pull only - not authorized to push", as fossil does, rather than a refusal, so the pull proceeds. To reach that branch the client now sends its pull card before its push card, fossil's order: the server's read-vs-pull-only decision turns on having seen the pull card first.
On the client, an error card ends the reply-card loop (§10.1): the cards before it stand, those after it are not read. A "message pull only ..." (§10.2) stops the client pushing -- it keeps pulling but answers no further gimme and drops its push card one request later, matching fossil clearing SYNC_PUSH after it has already emitted the next request's header.
Behavior change worth a look: a first --private pull against a server that denies private content (no 'x') now receives no content that session, because the denial error ends the reply-card loop before the server's inventory, as fossil's client does. The private blob still never reaches an unauthorized client. TestSyncPrivateEndToEnd/no_x_capability is updated to this. (user: [email protected], size: 5608) [annotate] [blame] [check-ins using]
package sync // A pull-only session converges even when the client holds artifacts the // server lacks (#277): the server does not gimme what a non-pushing client // announces, and the client ignores gimmes it cannot satisfy. import ( "context" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/xfer" "github.com/danmestas/go-libfossil/testutil" ) const pullOnlyMaxRounds = 5 func storeBlob(t *testing.T, r *repo.Repo, body string) string { t.Helper() _, uuid, err := blob.Store(r.DB(), []byte(body)) if err != nil { t.Fatalf("blob.Store: %v", err) } return uuid } func TestPullOnlyConvergesWhenServerLacksClientArtifacts(t *testing.T) { server := setupSyncTestRepo(t) client := setupSyncTestRepo(t) shared := "artifact held by both" storeBlob(t, server, shared) storeBlob(t, client, shared) serverOnly := storeBlob(t, server, "artifact only the server holds") clientOnly := []string{ storeBlob(t, client, "client-only artifact one"), storeBlob(t, client, "client-only artifact two"), } rt := &recordingTransport{src: server} ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() res, err := Sync(ctx, client, rt, SyncOpts{ Pull: true, ProjectCode: "pc", }) if err != nil { t.Fatalf("Sync: %v", err) } if res.Rounds > pullOnlyMaxRounds { t.Errorf("Rounds = %d, want <= %d", res.Rounds, pullOnlyMaxRounds) } if res.FilesRecvd != 1 { t.Errorf("FilesRecvd = %d, want 1 (only the server-only artifact)", res.FilesRecvd) } if res.FilesSent != 0 { t.Errorf("FilesSent = %d, want 0", res.FilesSent) } if _, ok := blob.Exists(client.DB(), serverOnly); !ok { t.Error("server-only artifact was not pulled") } for _, u := range clientOnly { if _, ok := blob.Exists(server.DB(), u); ok { t.Errorf("pull-only sync pushed %s to the server", u) } } for i, req := range rt.requests { if n := len(cardsByType(req, xfer.CardFile)) + len(cardsByType(req, xfer.CardCFile)); n != 0 { t.Errorf("request %d carries %d file cards", i, n) } if n := len(cardsByType(rt.replies[i], xfer.CardGimme)); n != 0 { t.Errorf("reply %d carries %d gimme cards to a pull-only client", i, n) } } } // gimmeForeverTransport answers every request with a gimme for an artifact // the client does not hold, as an older libfossil server would. type gimmeForeverTransport struct{ uuid string } func (g *gimmeForeverTransport) Exchange( _ context.Context, _ *xfer.Message, ) (*xfer.Message, error) { return &xfer.Message{Cards: []xfer.Card{&xfer.GimmeCard{UUID: g.uuid}}}, nil } func TestPullOnlyIgnoresServerGimmes(t *testing.T) { client := setupSyncTestRepo(t) storeBlob(t, client, "client-held artifact") tr := &gimmeForeverTransport{uuid: "dddddddddddddddddddddddddddddddddddddddd"} res, err := Sync(context.Background(), client, tr, SyncOpts{ Pull: true, ProjectCode: "pc", }) if err != nil { t.Fatalf("Sync: %v", err) } if res.Rounds > pullOnlyMaxRounds { t.Errorf("Rounds = %d, want <= %d", res.Rounds, pullOnlyMaxRounds) } if res.FilesSent != 0 { t.Errorf("FilesSent = %d, want 0", res.FilesSent) } } func TestPullOnlyFromRealFossilWhenServerLacksClientArtifacts(t *testing.T) { bin := testutil.RequireFossilBin(t) dir := t.TempDir() remote := filepath.Join(dir, "remote.fossil") if out, err := exec.Command(bin, "new", remote).CombinedOutput(); err != nil { t.Fatalf("fossil new: %v\n%s", err, out) } url := startFossilServer(t, remote) local := filepath.Join(dir, "local.fossil") if out, err := exec.Command(bin, "clone", url, local).CombinedOutput(); err != nil { t.Fatalf("fossil clone: %v\n%s", err, out) } r, err := repo.Open(local) if err != nil { t.Fatalf("repo.Open: %v", err) } defer r.Close() storeBlob(t, r, "artifact the fossil server lacks") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() res, err := Sync(ctx, r, &HTTPTransport{URL: url}, SyncOpts{ Pull: true, ProjectCode: getProjectCode(t, local), ServerCode: getServerCode(t, local), }) if err != nil { t.Fatalf("Sync: %v", err) } if res.FilesSent != 0 { t.Errorf("FilesSent = %d, want 0", res.FilesSent) } } // pullOnlyMessageTransport answers every request with "message pull only", // the reply a server sends a client it won't accept a push from. type pullOnlyMessageTransport struct{} func (pullOnlyMessageTransport) Exchange( _ context.Context, _ *xfer.Message, ) (*xfer.Message, error) { return &xfer.Message{Cards: []xfer.Card{ &xfer.MessageCard{Message: "pull only - not authorized to push"}, }}, nil } // A push the server refuses with "message pull only" must show up in the // result: fossil's own client drops push work silently, but a library caller // that asked to push has no other way to learn it didn't happen. func TestPushRefusedByPullOnlyIsReported(t *testing.T) { client := setupSyncTestRepo(t) storeBlob(t, client, "content the server will not accept") res, err := Sync(context.Background(), client, pullOnlyMessageTransport{}, SyncOpts{ Push: true, Pull: true, ProjectCode: "pc", }) if err != nil { t.Fatalf("Sync: %v", err) } found := false for _, e := range res.Errors { found = found || strings.Contains(e, "pull only") } if !found { t.Fatalf("Errors = %q, want the server's pull-only refusal recorded", res.Errors) } if res.FilesSent != 0 { t.Errorf("FilesSent = %d, want 0 after the server refused the push", res.FilesSent) } }