Artifact 3875fbb85405071c0c02fd919c2d745f000202913e7f0f231224cfd5056ec9a2:
- File
internal/sync/private_test.go
— part of check-in
[c42a2b90c6]
at
2026-10-13 05:59:46
on branch main
— fix(manifest,hash): verify the R card at commit; reject an unrecognized hash-policy (#396)
* fix(manifest,hash): verify the R card at commit; reject an unrecognized hash-policy
A check-in reads its manifest back and requires the R card to equal the digest recomputed over the resolved tree, byte for byte (artifact-format-01 6.3.4, AF-6.3-5). A hash-policy setting outside the ordinals 0 through 4 is now an error instead of a fall back to the default (7.1, AF-7.1-2).
Tracked in #387.
* fix(manifest,repo): drive the commit R check from conformance; refuse a bad hash-policy on write (user: [email protected], size: 8976) [annotate] [blame] [check-ins using]
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. syncViaHandler(t, serverRepo, clientRepo, SyncOpts{ Pull: true, Push: true, Private: true, }) if content.IsPrivate(clientRepo.DB(), int64(clientRID)) { t.Error("blob should no longer be private on client after transition") } }