Artifact ae95cd6176409912fcd1c13d29eeff6549f9f119ed4adbcc152875b1529b29ed:
- File
internal/sync/private_test.go
— part of check-in
[e245232360]
at
2026-10-13 07:12:29
on branch fix/391-igot-reclassify
— fix(sync): reclassify held artifacts and phantoms from igot
sync-protocol-01 Section 7.5 has a pushed igot reclassify what the server holds: public igot makes it public, igot HASH 1 private, the latter from a requester without x too, where it still makes no phantom. This matches page_xfer's content_make_public and content_make_private.
Tracked in #391. (user: [email protected], size: 9159) [annotate] [blame] [check-ins using] [more...]
package sync import ( "context" "fmt" "path/filepath" "strings" "testing" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/repo" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/internal/xfer" "github.com/danmestas/go-libfossil/simio" ) // newPrivateTestPair creates a server and client repo with matching project code. // The server's nobody user is granted the given capabilities. func newPrivateTestPair(t *testing.T, nobodyCaps string) (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) } cPath := filepath.Join(dir, "client.fossil") c, err := repo.Create(cPath, "test", simio.CryptoRand{}, testPC) if err != nil { t.Fatalf("client repo: %v", err) } // Match project codes so sync works. var projCode string s.DB().QueryRow("SELECT value FROM config WHERE name='project-code'").Scan(&projCode) c.DB().Exec("UPDATE config SET value=? WHERE name='project-code'", projCode) // Set nobody capabilities on server. s.DB().Exec("UPDATE user SET cap=? WHERE login='nobody'", nobodyCaps) t.Cleanup(func() { s.Close() c.Close() }) return s, c } // syncViaHandler runs sync.Sync using a MockTransport backed by HandleSync. func syncViaHandler(t *testing.T, serverRepo, clientRepo *repo.Repo, opts SyncOpts) *SyncResult { t.Helper() transport := &MockTransport{Handler: func(req *xfer.Message) *xfer.Message { resp, err := HandleSync(context.Background(), serverRepo, req) if err != nil { t.Fatalf("HandleSync: %v", err) } return resp }} var srvCode string serverRepo.DB().QueryRow("SELECT value FROM config WHERE name='server-code'").Scan(&srvCode) opts.ServerCode = srvCode var projCode string serverRepo.DB().QueryRow("SELECT value FROM config WHERE name='project-code'").Scan(&projCode) opts.ProjectCode = projCode result, err := Sync(context.Background(), clientRepo, transport, opts) if err != nil { t.Fatalf("Sync: %v", err) } return result } func TestSyncPrivateEndToEnd(t *testing.T) { // Server has 3 public blobs and 2 private blobs. // Client with Private=false gets only public. // Client with Private=true gets all. t.Run("without_private_flag", func(t *testing.T) { serverRepo, clientRepo := newPrivateTestPair(t, "oix") // Store 3 public blobs. publicUUIDs := make([]string, 3) for i := range 3 { data := []byte(fmt.Sprintf("public-blob-%d", i)) _, uuid, err := blob.Store(serverRepo.DB(), data) if err != nil { t.Fatalf("blob.Store public: %v", err) } publicUUIDs[i] = uuid } // Store 2 private blobs. privateUUIDs := make([]string, 2) for i := range 2 { data := []byte(fmt.Sprintf("private-blob-%d", i)) rid, uuid, err := blob.Store(serverRepo.DB(), data) if err != nil { t.Fatalf("blob.Store private: %v", err) } if err := content.MakePrivate(serverRepo.DB(), int64(rid)); err != nil { t.Fatalf("MakePrivate: %v", err) } privateUUIDs[i] = uuid } // Sync WITHOUT Private flag. syncViaHandler(t, serverRepo, clientRepo, SyncOpts{ Pull: true, Push: true, Private: false, }) // Public blobs should arrive. for _, uuid := range publicUUIDs { if _, ok := blob.Exists(clientRepo.DB(), uuid); !ok { t.Errorf("client missing public blob %s", uuid) } } // Private blobs should NOT arrive. for _, uuid := range privateUUIDs { if _, ok := blob.Exists(clientRepo.DB(), uuid); ok { t.Errorf("client has private blob %s without Private flag", uuid) } } }) t.Run("with_private_flag", func(t *testing.T) { serverRepo, clientRepo := newPrivateTestPair(t, "oix") // Store 3 public blobs. publicUUIDs := make([]string, 3) for i := range 3 { data := []byte(fmt.Sprintf("public-blob-priv-%d", i)) _, uuid, err := blob.Store(serverRepo.DB(), data) if err != nil { t.Fatalf("blob.Store public: %v", err) } publicUUIDs[i] = uuid } // Store 2 private blobs. privateUUIDs := make([]string, 2) privateRIDs := make([]int64, 2) for i := range 2 { data := []byte(fmt.Sprintf("private-blob-priv-%d", i)) rid, uuid, err := blob.Store(serverRepo.DB(), data) if err != nil { t.Fatalf("blob.Store private: %v", err) } if err := content.MakePrivate(serverRepo.DB(), int64(rid)); err != nil { t.Fatalf("MakePrivate: %v", err) } privateUUIDs[i] = uuid privateRIDs[i] = int64(rid) } // Sync WITH Private flag. syncViaHandler(t, serverRepo, clientRepo, SyncOpts{ Pull: true, Push: true, Private: true, }) // All public blobs should arrive. for _, uuid := range publicUUIDs { if _, ok := blob.Exists(clientRepo.DB(), uuid); !ok { t.Errorf("client missing public blob %s", uuid) } } // All private blobs should arrive AND be marked private on client. for _, uuid := range privateUUIDs { rid, ok := blob.Exists(clientRepo.DB(), uuid) if !ok { t.Errorf("client missing private blob %s", uuid) continue } if !content.IsPrivate(clientRepo.DB(), int64(rid)) { t.Errorf("blob %s should be in client's private table", uuid) } } // Public blobs should NOT be in private table. for _, uuid := range publicUUIDs { rid, ok := blob.Exists(clientRepo.DB(), uuid) if !ok { continue } if content.IsPrivate(clientRepo.DB(), int64(rid)) { t.Errorf("public blob %s should not be in private table", uuid) } } }) t.Run("no_x_capability", func(t *testing.T) { // Server's nobody lacks 'x' capability — pragma send-private rejected. serverRepo, clientRepo := newPrivateTestPair(t, "oi") // no 'x' // Store a private blob on server. data := []byte("secret-no-cap") rid, uuid, err := blob.Store(serverRepo.DB(), data) if err != nil { t.Fatalf("blob.Store: %v", err) } content.MakePrivate(serverRepo.DB(), int64(rid)) // Also store a public blob. pubData := []byte("public-no-cap") _, pubUUID, err := blob.Store(serverRepo.DB(), pubData) if err != nil { t.Fatalf("blob.Store public: %v", err) } // Sync with Private=true, but server denies it. res := syncViaHandler(t, serverRepo, clientRepo, SyncOpts{ Pull: true, Push: true, Private: true, }) // The server denies send-private with an error card. §10.1 (and // fossil's client_sync) end the reply-card loop at an error card, and // the server emits the denial before its inventory igots, so a first // --private pull against a server that denies private receives no // content this session. What matters for security is unchanged: the // private blob never reaches a client lacking 'x'. if _, ok := blob.Exists(clientRepo.DB(), uuid); ok { t.Error("client has private blob despite lacking 'x' capability") } if _, ok := blob.Exists(clientRepo.DB(), pubUUID); ok { t.Error("client has public blob; the denial error should end the " + "reply loop before the inventory, as fossil's client does") } reported := false for _, e := range res.Errors { if strings.Contains(e, "not authorized to sync private content") { reported = true } } if !reported { t.Errorf("sync did not surface the send-private denial: %v", res.Errors) } }) } func TestSyncPrivateArtifactTransition(t *testing.T) { // Server has a private blob. Client syncs with Private=true. // Server makes it public. Client syncs again — private status clears. serverRepo, clientRepo := newPrivateTestPair(t, "oix") // Store a blob and mark private on server. data := []byte("transitioning-artifact") uuid := hash.MustNamingFor(serverRepo.DB()).New.Hash(data) rid, _, err := blob.Store(serverRepo.DB(), data) if err != nil { t.Fatalf("blob.Store: %v", err) } content.MakePrivate(serverRepo.DB(), int64(rid)) // Phase 1: sync with Private=true — client gets the private blob. syncViaHandler(t, serverRepo, clientRepo, SyncOpts{ Pull: true, Push: true, Private: true, }) clientRID, ok := blob.Exists(clientRepo.DB(), uuid) if !ok { t.Fatal("client missing blob after first sync") } if !content.IsPrivate(clientRepo.DB(), int64(clientRID)) { t.Fatal("blob should be private on client after first sync") } // Phase 2: server makes blob public. if err := content.MakePublic(serverRepo.DB(), int64(rid)); err != nil { t.Fatalf("MakePublic: %v", err) } // Sync again — the server will emit igot without IsPrivate=true, // and the client should clear the private status. The sync only // pulls: a push would carry the client's igot HASH 1, which makes the // server's copy private again before its inventory goes out, as // fossil's content_make_private does (§7.5). syncViaHandler(t, serverRepo, clientRepo, SyncOpts{ Pull: true, Private: true, }) if content.IsPrivate(clientRepo.DB(), int64(clientRID)) { t.Error("blob should no longer be private on client after transition") } }