Artifact dd7ddf6368d117cc7f9e8c55f535d4cd47a623d72e01759ae1e2914a8afcff4f:
- File
internal/sync/client_uv_test.go
— part of check-in
[6bde4f3729]
at
2026-10-13 04:13:22
on branch main
— fix(sync): withhold SHA3 unversioned files from a pre-SHA3 server (#303) (#384)
sync-protocol-01 Section 7.6 (SP-7.6-1): a sender replaces a uvfile whose HASH is a SHA3 name with the error "Fossil version 2.0 or later required." when the peer's recorded VERSION is below 20000. The client gated artifacts this way already but sent unversioned files to such a server regardless.
buildUVFileCards now sends the refusal in place of the card, with or without content, and drops the file from the send list, as fossil's send_unversioned_file does. A deletion tombstone names no hash and is still sent. (user: [email protected], size: 6660) [annotate] [blame] [check-ins using]
package sync import ( "context" "path/filepath" "testing" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/uv" "github.com/danmestas/go-libfossil/internal/xfer" "github.com/danmestas/go-libfossil/simio" ) // newTestRepoPair creates a server and client repo, both with UV schema. func newTestRepoPair(t *testing.T) (server, client *repo.Repo) { t.Helper() dir := t.TempDir() sPath := filepath.Join(dir, "server.fossil") s, err := repo.Create(sPath, "test", simio.CryptoRand{}, testPC) if err != nil { t.Fatalf("server repo: %v", err) } uv.EnsureSchema(s.DB()) cPath := filepath.Join(dir, "client.fossil") c, err := repo.Create(cPath, "test", simio.CryptoRand{}, testPC) if err != nil { t.Fatalf("client repo: %v", err) } uv.EnsureSchema(c.DB()) t.Cleanup(func() { s.Close() c.Close() }) return s, c } func TestSyncUV_PullFromServer(t *testing.T) { serverRepo, clientRepo := newTestRepoPair(t) // Server has a UV file. uv.Write(serverRepo.DB(), "wiki/page.txt", []byte("hello wiki"), 100) transport := &MockTransport{Handler: func(req *xfer.Message) *xfer.Message { resp, _ := HandleSync(context.Background(), serverRepo, req) return resp }} _, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, Push: true, UV: true, ProjectCode: testPC, ServerCode: "s", }) if err != nil { t.Fatalf("Sync: %v", err) } // Client should now have the file. content, mtime, hash, err := uv.Read(clientRepo.DB(), "wiki/page.txt") if err != nil { t.Fatalf("Read: %v", err) } if string(content) != "hello wiki" || mtime != 100 || hash == "" { t.Errorf("got content=%q mtime=%d hash=%q", content, mtime, hash) } } func TestSyncUV_PushToServer(t *testing.T) { serverRepo, clientRepo := newTestRepoPair(t) grantNobodyPush(t, serverRepo) // Client has a UV file. uv.Write(clientRepo.DB(), "data/config.json", []byte(`{"key":"val"}`), 200) transport := &MockTransport{Handler: func(req *xfer.Message) *xfer.Message { resp, _ := HandleSync(context.Background(), serverRepo, req) return resp }} _, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, Push: true, UV: true, ProjectCode: testPC, ServerCode: "s", }) if err != nil { t.Fatalf("Sync: %v", err) } // Server should now have the file. content, _, _, err := uv.Read(serverRepo.DB(), "data/config.json") if err != nil { t.Fatalf("Read: %v", err) } if string(content) != `{"key":"val"}` { t.Errorf("server content = %q", content) } } func TestSyncUV_Bidirectional(t *testing.T) { serverRepo, clientRepo := newTestRepoPair(t) grantNobodyPush(t, serverRepo) uv.Write(serverRepo.DB(), "server-file.txt", []byte("from server"), 100) uv.Write(clientRepo.DB(), "client-file.txt", []byte("from client"), 200) transport := &MockTransport{Handler: func(req *xfer.Message) *xfer.Message { resp, _ := HandleSync(context.Background(), serverRepo, req) return resp }} _, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, Push: true, UV: true, ProjectCode: testPC, ServerCode: "s", }) if err != nil { t.Fatalf("Sync: %v", err) } // Both should have both files. sc, _, _, _ := uv.Read(serverRepo.DB(), "client-file.txt") cc, _, _, _ := uv.Read(clientRepo.DB(), "server-file.txt") if string(sc) != "from client" { t.Errorf("server missing client file: %q", sc) } if string(cc) != "from server" { t.Errorf("client missing server file: %q", cc) } } func TestSyncUV_Deletion(t *testing.T) { serverRepo, clientRepo := newTestRepoPair(t) // Both have the file initially. uv.Write(serverRepo.DB(), "old.txt", []byte("data"), 100) uv.Write(clientRepo.DB(), "old.txt", []byte("data"), 100) // Server deletes it (newer mtime). uv.Delete(serverRepo.DB(), "old.txt", 200) transport := &MockTransport{Handler: func(req *xfer.Message) *xfer.Message { resp, _ := HandleSync(context.Background(), serverRepo, req) return resp }} _, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, Push: true, UV: true, ProjectCode: testPC, ServerCode: "s", }) if err != nil { t.Fatalf("Sync: %v", err) } // Client should have tombstone. _, mtime, hash, _ := uv.Read(clientRepo.DB(), "old.txt") if hash != "" { t.Errorf("expected tombstone, got hash=%q", hash) } if mtime != 200 { t.Errorf("mtime = %d, want 200", mtime) } } func TestSyncUV_NoUVFlag_SkipsUV(t *testing.T) { serverRepo, clientRepo := newTestRepoPair(t) uv.Write(serverRepo.DB(), "test.txt", []byte("data"), 100) transport := &MockTransport{Handler: func(req *xfer.Message) *xfer.Message { resp, _ := HandleSync(context.Background(), serverRepo, req) return resp }} _, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, Push: true, UV: false, // UV disabled ProjectCode: testPC, ServerCode: "s", }) if err != nil { t.Fatalf("Sync: %v", err) } // Client should NOT have the UV file. content, _, _, _ := uv.Read(clientRepo.DB(), "test.txt") if content != nil { t.Error("UV file should not sync when UV=false") } } // TestSyncUV_SHA3GateToOldServer pins ยง7.6 on the client: an unversioned file // named by SHA3 is not sent to a server whose server-version predates SHA3 // support; the refusal fossil's send_unversioned_file sends goes in its place. func TestSyncUV_SHA3GateToOldServer(t *testing.T) { _, clientRepo := newTestRepoPair(t) uv.Write(clientRepo.DB(), "u.txt", []byte("unversioned"), 300) if _, _, h, err := uv.Read(clientRepo.DB(), "u.txt"); err != nil || len(h) <= 40 { t.Fatalf("uv.Read: hash %q, err %v; want a SHA3 name", h, err) } var requests []*xfer.Message transport := &MockTransport{Handler: func(req *xfer.Message) *xfer.Message { requests = append(requests, req) if len(requests) > 1 { return &xfer.Message{} } return &xfer.Message{Cards: []xfer.Card{ &xfer.PragmaCard{Name: "server-version", Values: []string{"19999"}}, &xfer.PragmaCard{Name: "uv-push-ok"}, }} }} _, _ = Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, Push: true, UV: true, ProjectCode: testPC, ServerCode: "s", }) if len(requests) < 2 { t.Fatalf("client sent %d requests, want a second one", len(requests)) } refused := false for _, c := range requests[1].Cards { switch c := c.(type) { case *xfer.UVFileCard: t.Fatalf("uvfile %s sent to a server older than SHA3 support", c.Name) case *xfer.ErrorCard: refused = refused || c.Message == sha3Refusal().Message } } if !refused { t.Fatalf("second request %v carries no SHA3 refusal", requests[1].Cards) } }