Artifact c593ce20bd7943019231382a6409da448f52b8561f2eda4466031b58485da898:
- File
internal/sync/pullonly_converge_test.go
— part of check-in
[821cf115ce]
at
2026-10-12 22:48:22
on branch main
— fix(sync): refuse push and pull cards naming another project (#309) (#353)
fossil's page_xfer compares a push or pull card's PROJECTCODE with the repository's project-code before any capability check and, on a mismatch, resets the reply, answers "error wrong project" and breaks out of the card loop (section 8.5). The handler accepted either card whatever its project code.
The refusal goes through a new breakLoop, which the undecodable file card path now shares: the reply is reset to the one error, no later card is processed, and the post-loop work and timestamp still follow. Tests that synced repositories created with different project codes now create them with a shared one, and the DST simulator gives its leaves the upstream's. (user: [email protected], size: 5614) [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: testPC, }) 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: testPC, }) 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: testPC, }) 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) } }