Skip to content
go-libfossil Artifact [8470ffcb35]
Login

Artifact [8470ffcb35]

Artifact 8470ffcb3566144d25d3daebaa091769369566ca44b2a487b065f48db32d32c4:


package sync

import (
	"bytes"
	"context"
	"testing"

	"github.com/danmestas/go-libfossil/internal/auth"
	"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"
)

// The handler sends repository content only to a requester entitled to read
// it, as fossil's page_xfer does: gimme is answered only once a pull (or a
// clone) was accepted, and the unversioned catalog and files only to a
// requester with Read. These tests set nobody's capabilities directly, so
// every request is anonymous.

func pullCard() *xfer.PullCard { return &xfer.PullCard{ServerCode: "s", ProjectCode: testPC} }

func sentFile(resp *xfer.Message, uuid string) bool {
	for _, f := range findCards[*xfer.FileCard](resp) {
		if f.UUID == uuid {
			return true
		}
	}
	return false
}

func writeUV(t *testing.T, r *repo.Repo, name, data string, mtime int64) {
	t.Helper()
	if err := uv.EnsureSchema(r.DB()); err != nil {
		t.Fatalf("EnsureSchema: %v", err)
	}
	if err := uv.Write(r.DB(), name, []byte(data), mtime); err != nil {
		t.Fatalf("uv.Write: %v", err)
	}
}

func hasPragma(resp *xfer.Message, name string) bool {
	for _, p := range findCards[*xfer.PragmaCard](resp) {
		if p.Name == name {
			return true
		}
	}
	return false
}

func TestGimmeWithoutPullSendsNothing(t *testing.T) {
	r := setupSyncTestRepo(t)
	uuid := storeTestBlob(t, r, []byte("secret artifact"))
	for _, caps := range []string{"", "o", "y"} {
		setNobodyCaps(t, r, caps)
		resp := handleReq(t, r, &xfer.GimmeCard{UUID: uuid})
		if sentFile(resp, uuid) {
			t.Errorf("caps %q: gimme without a pull card sent the artifact", caps)
		}
	}
}

func TestGimmeWithoutReadSendsNothing(t *testing.T) {
	r := setupSyncTestRepo(t)
	uuid := storeTestBlob(t, r, []byte("secret artifact"))
	setNobodyCaps(t, r, "")
	resp := handleReq(t, r, pullCard(), &xfer.GimmeCard{UUID: uuid})
	if sentFile(resp, uuid) {
		t.Fatal("gimme from a requester without Read sent the artifact")
	}
}

func TestGimmeInCloneSessionIsServed(t *testing.T) {
	r := setupSyncTestRepo(t)
	uuid := storeTestBlob(t, r, []byte("cloned artifact"))
	setNobodyCaps(t, r, "g")
	resp := handleReq(t, r, &xfer.CloneCard{}, &xfer.GimmeCard{UUID: uuid})
	if !sentFile(resp, uuid) {
		t.Fatal("a clone session counts as pulling; gimme should be answered")
	}
}

func TestUVHashWithoutReadSendsNoCatalog(t *testing.T) {
	r := setupSyncTestRepo(t)
	writeUV(t, r, "secret.txt", "unversioned secret", 100)
	setNobodyCaps(t, r, "")
	resp := handleReq(t, r, &xfer.PragmaCard{Name: "uv-hash", Values: []string{"wrong"}})
	if n := len(findCards[*xfer.UVIGotCard](resp)); n != 0 {
		t.Errorf("uv-hash without Read sent %d uvigot cards", n)
	}
	if hasPragma(resp, "uv-push-ok") || hasPragma(resp, "uv-pull-only") {
		t.Error("uv-hash without Read answered with a uv pragma")
	}
}

func TestUVGimmeWithoutReadSendsNothing(t *testing.T) {
	r := setupSyncTestRepo(t)
	writeUV(t, r, "secret.txt", "unversioned secret", 100)
	setNobodyCaps(t, r, "")
	resp := handleReq(t, r, &xfer.UVGimmeCard{Name: "secret.txt"})
	if n := len(findCards[*xfer.UVFileCard](resp)); n != 0 {
		t.Fatalf("uvgimme without Read sent %d uvfile cards", n)
	}
}

func TestUVIGotWithoutReadSendsNothing(t *testing.T) {
	r := setupSyncTestRepo(t)
	writeUV(t, r, "secret.txt", "unversioned secret", 500)
	setNobodyCaps(t, r, "")
	// An older client copy makes the server's copy newer: it would send it.
	resp := handleReq(t, r, &xfer.UVIGotCard{Name: "secret.txt", MTime: 100, Hash: "-", Size: 0})
	if n := len(findCards[*xfer.UVFileCard](resp)); n != 0 {
		t.Fatalf("uvigot without Read sent %d uvfile cards", n)
	}
}

func TestUVIGotMtimeOnlyNeedsUVWrite(t *testing.T) {
	r := setupSyncTestRepo(t)
	writeUV(t, r, "f.txt", "same content", 100)
	_, _, h, err := uv.Read(r.DB(), "f.txt")
	if err != nil {
		t.Fatalf("uv.Read: %v", err)
	}
	setNobodyCaps(t, r, "o")
	// Same hash, newer client mtime: status 2 rewrites the server's mtime.
	handleReq(t, r, &xfer.UVIGotCard{Name: "f.txt", MTime: 900, Hash: h, Size: 12})
	_, mtime, _, err := uv.Read(r.DB(), "f.txt")
	if err != nil {
		t.Fatalf("uv.Read: %v", err)
	}
	if mtime != 100 {
		t.Fatalf("uvigot without y rewrote mtime to %d", mtime)
	}
}

func TestUVPushOKOnlyWithUVWrite(t *testing.T) {
	r := setupSyncTestRepo(t)
	writeUV(t, r, "a.txt", "aaa", 100)
	uvHash := &xfer.PragmaCard{Name: "uv-hash", Values: []string{"wrong"}}

	setNobodyCaps(t, r, "o")
	resp := handleReq(t, r, uvHash)
	if hasPragma(resp, "uv-push-ok") {
		t.Error("uv-push-ok sent to a requester without y")
	}
	if !hasPragma(resp, "uv-pull-only") {
		t.Error("expected uv-pull-only for a reader without y")
	}
	if n := len(findCards[*xfer.UVIGotCard](resp)); n != 1 {
		t.Errorf("reader got %d uvigot cards, want 1", n)
	}

	setNobodyCaps(t, r, "oy")
	resp = handleReq(t, r, uvHash)
	if !hasPragma(resp, "uv-push-ok") {
		t.Error("expected uv-push-ok for a requester with y")
	}
}

func setupKVTable(t *testing.T, r *repo.Repo) string {
	t.Helper()
	def := repo.TableDef{
		Columns:  []repo.ColumnDef{{Name: "id", Type: "text", PK: true}, {Name: "val", Type: "text"}},
		Conflict: "mtime-wins",
	}
	if err := repo.EnsureSyncSchema(r.DB()); err != nil {
		t.Fatalf("EnsureSyncSchema: %v", err)
	}
	if err := repo.RegisterSyncedTable(r.DB(), "kv", def, 100); err != nil {
		t.Fatalf("RegisterSyncedTable: %v", err)
	}
	row := map[string]any{"id": "k", "val": "secret row"}
	if err := repo.UpsertXRow(r.DB(), "kv", row, 200); err != nil {
		t.Fatalf("UpsertXRow: %v", err)
	}
	return repo.PKHash([]repo.ColumnDef{{Name: "id", Type: "text", PK: true}}, row)
}

func TestXGimmeWithoutPullSendsNothing(t *testing.T) {
	r := setupSyncTestRepo(t)
	pk := setupKVTable(t, r)
	setNobodyCaps(t, r, "")
	resp := handleReq(t, r, &xfer.XGimmeCard{Table: "kv", PKHash: pk})
	if n := len(findCards[*xfer.XRowCard](resp)); n != 0 {
		t.Fatalf("xgimme without a pull card sent %d rows", n)
	}
}

func TestXTableHashWithoutReadSendsNothing(t *testing.T) {
	r := setupSyncTestRepo(t)
	setupKVTable(t, r)
	setNobodyCaps(t, r, "")
	resp := handleReq(t, r, &xfer.PragmaCard{Name: "xtable-hash", Values: []string{"kv", "wrong"}})
	if n := len(findCards[*xfer.XIGotCard](resp)); n != 0 {
		t.Fatalf("xtable-hash without Read sent %d xigot cards", n)
	}
}

// A Clone-only requester has Read by derivation, but it asked for no pull:
// xtable-hash lists keys and mtimes that xgimme, pull-gated, would refuse to
// fetch. Both cards use the pull gate (#351).
func TestXTableHashNeedsPull(t *testing.T) {
	r := setupSyncTestRepo(t)
	setupKVTable(t, r)
	setNobodyCaps(t, r, "g")
	resp := handleReq(t, r,
		&xfer.CloneCard{Version: 3, SeqNo: 1, SeqNoIsDecimal: true},
		&xfer.PragmaCard{Name: "xtable-hash", Values: []string{"kv", "wrong"}})
	if n := len(findCards[*xfer.XIGotCard](resp)); n != 0 {
		t.Fatalf("xtable-hash without a pull card sent %d xigot cards", n)
	}

	// A requester whose pull card is accepted gets the inventory.
	setNobodyCaps(t, r, "go")
	resp = handleReq(t, r,
		&xfer.PullCard{ServerCode: "s", ProjectCode: testPC},
		&xfer.PragmaCard{Name: "xtable-hash", Values: []string{"kv", "wrong"}})
	if n := len(findCards[*xfer.XIGotCard](resp)); n == 0 {
		t.Fatal("xtable-hash with a pull card sent no xigot cards")
	}

	// A push-only client with Read gets it too: it queues newer rows from it.
	setNobodyCaps(t, r, "io")
	resp = handleReq(t, r,
		&xfer.PushCard{ServerCode: "s", ProjectCode: testPC},
		&xfer.PragmaCard{Name: "xtable-hash", Values: []string{"kv", "wrong"}})
	if n := len(findCards[*xfer.XIGotCard](resp)); n == 0 {
		t.Fatal("xtable-hash with a push card sent no xigot cards")
	}
}

func TestCkinLockNeedsWrite(t *testing.T) {
	r := setupSyncTestRepo(t)
	uuid := "lock000000000000000000000000000000000000"
	insertLeafBlob(t, r, uuid)
	setNobodyCaps(t, r, "o")
	handleReq(t, r, &xfer.PragmaCard{Name: "ci-lock", Values: []string{uuid, "client-A"}})
	var n int
	err := r.DB().QueryRow(
		"SELECT count(*) FROM config WHERE name GLOB 'libfossil-ci-lock-*'").Scan(&n)
	if err != nil {
		t.Fatalf("count locks: %v", err)
	}
	if n != 0 {
		t.Fatal("ci-lock from a requester without Write took a lock")
	}
}

// TestReaderGetsEverything is the positive control: a requester with Read
// who pulls gets artifacts, the unversioned catalog and unversioned files.
func TestReaderGetsEverything(t *testing.T) {
	r := setupSyncTestRepo(t)
	uuid := storeTestBlob(t, r, []byte("public artifact"))
	writeUV(t, r, "pub.txt", "unversioned public", 100)
	setNobodyCaps(t, r, "o")
	// The unversioned file is named by SHA3, which a client that names no
	// release would be refused (ยง7.6).
	resp := handleReq(t, r,
		versionPragma("client-version"),
		pullCard(),
		&xfer.PragmaCard{Name: "uv-hash", Values: []string{"wrong"}},
		&xfer.GimmeCard{UUID: uuid},
		&xfer.UVGimmeCard{Name: "pub.txt"},
	)
	if !sentFile(resp, uuid) {
		t.Error("reader did not get the artifact")
	}
	if n := len(findCards[*xfer.UVIGotCard](resp)); n != 1 {
		t.Errorf("reader got %d uvigot cards, want 1", n)
	}
	if n := len(findCards[*xfer.UVFileCard](resp)); n != 1 {
		t.Errorf("reader got %d uvfile cards, want 1", n)
	}
}

// TestCloneImpliesRead pins that Clone counts as Read at every read gate, as
// fossil's page_xfer grants it: a requester with only g gets the unversioned
// catalog, a uvgimme'd file and read-gated
// configuration. Pull still needs o, so its pull card is refused.
func TestCloneImpliesRead(t *testing.T) {
	r := setupSyncTestRepo(t)
	writeUV(t, r, "pub.txt", "unversioned public", 100)
	setupKVTable(t, r)
	if _, err := r.DB().Exec(
		"REPLACE INTO config(name, value, mtime) VALUES('project-name', 'p', 0)",
	); err != nil {
		t.Fatalf("set project-name: %v", err)
	}
	setNobodyCaps(t, r, "g")

	resp := handleReq(t, r,
		versionPragma("client-version"),
		&xfer.PragmaCard{Name: "uv-hash", Values: []string{"wrong"}},
		&xfer.UVGimmeCard{Name: "pub.txt"},
		&xfer.PragmaCard{Name: "xtable-hash", Values: []string{"kv", "wrong"}},
		&xfer.ReqConfigCard{Name: "/project"},
	)
	if n := len(findCards[*xfer.UVIGotCard](resp)); n != 1 {
		t.Errorf("clone-only requester got %d uvigot cards, want 1", n)
	}
	if n := len(findCards[*xfer.UVFileCard](resp)); n != 1 {
		t.Errorf("clone-only requester got %d uvfile cards, want 1", n)
	}
	// xtable-hash takes xgimme's pull gate, which Clone alone does not
	// meet (#351), so no inventory of rows the requester cannot fetch.
	if n := len(findCards[*xfer.XIGotCard](resp)); n != 0 {
		t.Errorf("clone-only requester got %d xigot cards, want 0", n)
	}
	if n := len(findCards[*xfer.ConfigCard](resp)); n == 0 {
		t.Error("clone-only requester got no configuration")
	}

	pull := handleReq(t, r, pullCard())
	if errs := findCards[*xfer.ErrorCard](pull); len(errs) != 1 {
		t.Errorf("clone-only pull: error cards %v, want the pull refused", errs)
	}
}

// TestLoginCloneDoesNotImplyRead pins that a Clone granted by a login card
// does not become Read, as fossil derives Read from Clone only for nobody's
// capabilities, ahead of the login.
func TestLoginCloneDoesNotImplyRead(t *testing.T) {
	r := setupSyncTestRepo(t)
	writeUV(t, r, "pub.txt", "unversioned public", 100)
	setNobodyCaps(t, r, "")
	if err := auth.CreateUser(r.DB(), testPC, "bob", "pw", "g"); err != nil {
		t.Fatalf("CreateUser: %v", err)
	}
	rest := []xfer.Card{
		versionPragma("client-version"),
		&xfer.UVGimmeCard{Name: "pub.txt"},
		randomComment(simio.CryptoRand{}),
	}
	payload, err := encodeCards(rest)
	if err != nil {
		t.Fatalf("encode: %v", err)
	}
	login := computeLogin("bob", "pw", testPC, payload)
	req, err := xfer.DecodeUncompressed(append(encodeLine(t, login), payload...))
	if err != nil {
		t.Fatalf("decode: %v", err)
	}
	resp, err := HandleSync(context.Background(), r, req)
	if err != nil {
		t.Fatalf("HandleSync: %v", err)
	}
	if errs := findCards[*xfer.ErrorCard](resp); len(errs) != 0 {
		t.Fatalf("login refused: %v", errs)
	}
	if n := len(findCards[*xfer.UVFileCard](resp)); n != 0 {
		t.Fatalf("a login-granted Clone got %d uvfile cards, want 0", n)
	}
}

// encodeLine encodes one card as its wire line.
func encodeLine(t *testing.T, c xfer.Card) []byte {
	t.Helper()
	var buf bytes.Buffer
	if err := xfer.EncodeCard(&buf, c); err != nil {
		t.Fatalf("encode %T: %v", c, err)
	}
	return buf.Bytes()
}