package api
import (
"bytes"
"compress/zlib"
"crypto/sha1"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/uv"
"craftdesign.group/quarry/internal/xfer"
)
// RFC 0020 rung 3, with a stock fossil binary as the client: quarry's
// sync front answers fossil's unversioned cards from the repository's
// bucket (the S3 stand-in the uv tests use), and hands a real zit
// child the rest.
// uvSyncAuth is the sync credential: fossil's shared secret, derived
// from the project code, user and password (sync RFC ยง6.3).
type uvSyncAuth struct {
code string
pw map[string]string
letters map[string]string
}
func (a uvSyncAuth) Gated(string) bool { return true }
func (a uvSyncAuth) MayWrite(_, user string) bool {
return oltp.CapSet(a.letters[user]).HasAny("i")
}
func (a uvSyncAuth) Secrets(_, user string) []xfer.Credential {
p, ok := a.pw[user]
if !ok {
return nil
}
return []xfer.Credential{{Secret: xfer.SharedSecret(a.code, user, p), CheckIn: true, Publish: true}}
}
type uvFossilRig struct {
t *testing.T
base string // the front's URL, no credentials
origin string // the zit store the child serves
code string
home string
db *oltp.DB
objects *uvBucket
front *xfer.Fronter
tracking bool
// afterPush runs between splicedPush's spliced push and its clean one.
afterPush func()
}
// newUVFossilRig stands up the front over a zit store with one check-in,
// accounts pub (y), dev (no y), and a bucket bound with limit bytes
// per file, or none when limit is 0.
func newUVFossilRig(t *testing.T, limit int64) *uvFossilRig {
t.Helper()
for _, bin := range []string{"zit", "fossil"} {
if _, err := exec.LookPath(bin); err != nil {
// CI provides both (the runner sets CI=true for every task),
// so a missing one there is a broken runner, not a reason to
// pass without running.
if os.Getenv("CI") == "true" {
t.Fatalf("FAIL: %s not on PATH under CI: the stock-fossil round trip needs both", bin)
}
t.Skip(bin + " not on PATH: the stock-fossil round trip needs both")
}
}
g := &uvFossilRig{t: t, origin: t.TempDir(), home: t.TempDir()}
g.zit("init")
if err := os.WriteFile(filepath.Join(g.origin, "README.md"), []byte("# uv\n"), 0o644); err != nil {
t.Fatal(err)
}
g.zit("add", "README.md")
g.zit("commit", "-m", "first")
code, err := xfer.ProjectCode(g.origin)
if err != nil {
t.Fatal(err)
}
g.code = code
g.db, err = oltp.Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { g.db.Close() })
srv, objects := newUVBucket(t)
g.objects = objects
buckets := newMemBuckets()
if limit > 0 {
buckets.rows["test"] = oltp.BucketBinding{Endpoint: srv.URL, Bucket: "files", Region: "auto", AccessKey: "AK", Secret: "SK", LimitBytes: limit}
}
letters := map[string]string{"pub": "oiy", "dev": "oi"}
d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {}}), Buckets: buckets, UV: g.db, Events: &uvEvents{},
CapsFor: func(account, _ string) CapSet { return oltp.CapSet(letters[account]) }}
f := xfer.New()
t.Cleanup(f.Close)
g.front = f
f.Register("test", g.origin)
f.SetAuth(uvSyncAuth{code: code, pw: map[string]string{"pub": "pubpw", "dev": "devpw"}, letters: letters})
f.SetUnversioned(SyncUV{d})
f.SetWriteGate(func(string) (bool, string) {
if g.tracking {
return false, "https://upstream.example/test"
}
return true, ""
})
mux := http.NewServeMux()
mux.Handle("POST /r/{repo}", f)
front := httptest.NewServer(mux)
t.Cleanup(front.Close)
g.base = front.URL + "/r/test"
return g
}
func (g *uvFossilRig) zit(args ...string) string {
g.t.Helper()
cmd := exec.Command("zit", args...)
cmd.Dir = g.origin
out, err := cmd.CombinedOutput()
if err != nil {
g.t.Fatalf("zit %v: %v\n%s", args, err, out)
}
return string(out)
}
// fossil runs the stock client with a home of its own, so nothing it
// remembers leaks between tests or into the developer's.
func (g *uvFossilRig) fossil(dir string, args ...string) (string, error) {
cmd := exec.Command("fossil", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "HOME="+g.home, "FOSSIL_HOME="+g.home, "FOSSIL_USER=tester")
out, err := cmd.CombinedOutput()
return string(out), err
}
func (g *uvFossilRig) must(dir string, args ...string) string {
g.t.Helper()
out, err := g.fossil(dir, args...)
if err != nil {
g.t.Fatalf("FAIL: fossil %v: %v\n%s", args, err, out)
}
return out
}
// as is the front's URL carrying an account's password, the way a
// fossil user names one.
func (g *uvFossilRig) as(user string) string {
return strings.Replace(g.base, "http://", "http://"+user+":"+user+"pw@", 1)
}
// clone makes a fossil clone in a directory of its own and returns the
// repository file.
func (g *uvFossilRig) clone(name string, args ...string) string {
g.t.Helper()
dir := g.t.TempDir()
repo := filepath.Join(dir, name+".fossil")
g.must(dir, append(append([]string{"clone"}, args...), g.base, repo)...)
return repo
}
// uvAdd writes content to a file beside repo and adds it as name.
func (g *uvFossilRig) uvAdd(repo, name, content, mtime string) {
g.t.Helper()
dir := filepath.Dir(repo)
if err := os.WriteFile(filepath.Join(dir, "in.tmp"), []byte(content), 0o644); err != nil {
g.t.Fatal(err)
}
g.must(dir, "uv", "add", "in.tmp", "--as", name, "--mtime", mtime, "-R", repo)
}
func (g *uvFossilRig) row(name string) (uv.File, bool) {
g.t.Helper()
f, ok, err := g.db.UVFile("test", name)
if err != nil {
g.t.Fatal(err)
}
return f, ok
}
// exchange sends one hand-built round to the front, signed as user
// when pw is set, and returns the decoded reply.
func (g *uvFossilRig) exchange(user, pw, cards string) string {
g.t.Helper()
body := cards
if user != "" {
nonce := sha1.Sum([]byte(cards))
n := hex.EncodeToString(nonce[:])
sig := sha1.Sum([]byte(n + xfer.SharedSecret(g.code, user, pw)))
body = "login " + user + " " + n + " " + hex.EncodeToString(sig[:]) + "\n" + cards
}
resp, err := http.Post(g.base, "application/x-fossil", bytes.NewReader(compress([]byte(body))))
if err != nil {
g.t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return string(decompress(raw))
}
func compress(body []byte) []byte {
var buf bytes.Buffer
var n [4]byte
binary.BigEndian.PutUint32(n[:], uint32(len(body)))
buf.Write(n[:])
zw := zlib.NewWriter(&buf)
zw.Write(body)
zw.Close()
return buf.Bytes()
}
func decompress(raw []byte) []byte {
if len(raw) < 4 {
return raw
}
zr, err := zlib.NewReader(bytes.NewReader(raw[4:]))
if err != nil {
return raw
}
out, err := io.ReadAll(zr)
if err != nil {
return raw
}
return out
}
const zeroHash = "0000000000000000000000000000000000000000"
func (g *uvFossilRig) pullRound() string {
return "pragma client-version 22800 20260101 000000\npull " + g.code + " " + g.code + "\npragma uv-hash " + zeroHash + "\n"
}
func check(t *testing.T, ok bool, pass, fail string, args ...any) {
t.Helper()
if ok {
t.Log("ok: " + pass)
} else {
t.Errorf("FAIL: "+fail, args...)
}
}
func TestUVFossilRoundTrip(t *testing.T) {
g := newUVFossilRig(t, 1<<20)
// Publish as pub, who holds y: the object is in the bucket and the
// catalog names the account.
a := g.clone("a")
g.uvAdd(a, "hello.txt", "hello\n", "2026-01-01 00:00:00")
out := g.must(filepath.Dir(a), "sync", "-u", g.as("pub"), "-R", a)
o, inBucket := g.objects.has(uv.Key("test", "hello.txt"))
row, _ := g.row("hello.txt")
check(t, inBucket && string(o.body) == "hello\n" && row.Who == "pub" && row.Mtime == 1767225600 && !row.Deleted,
"a uvfile from an account holding y lands in the bucket, recorded as that account",
"publish: object %v %q, row %+v\n%s", inBucket, o.body, row, out)
// An anonymous clone -u of the public repository gets the content,
// and the front tells an anonymous reader it may only pull.
b := g.clone("b", "-u")
got := g.must(filepath.Dir(b), "uv", "cat", "hello.txt", "-R", b)
check(t, got == "hello\n", "an anonymous clone -u carries the unversioned file", "clone -u cat = %q", got)
reply := g.exchange("", "", g.pullRound())
check(t, strings.Contains(reply, "pragma uv-pull-only\n") && strings.Contains(reply, "uvigot hello.txt 1767225600 "+row.Hash+" 6\n"),
"anonymous uv-hash draws uv-pull-only and the catalog", "anonymous reply:\n%s", reply)
reply = g.exchange("pub", "pubpw", g.pullRound())
check(t, strings.Contains(reply, "pragma uv-push-ok\n"), "a login holding y draws uv-push-ok", "pub reply:\n%s", reply)
reply = g.exchange("pub", "pubpw", g.pullRound()+"uvgimme hello.txt\n")
check(t, strings.Contains(reply, "uvfile hello.txt 1767225600 "+row.Hash+" 6 0\nhello\n"),
"uvgimme is answered with the content from the bucket", "uvgimme reply:\n%s", reply)
check(t, !strings.Contains(g.exchange("", "", "pragma uv-hash "+uv.CatalogHash([]uv.File{row})+"\n"), "uvigot"),
"a uv-hash that matches the catalog draws nothing", "matching uv-hash drew a catalog")
// uv touch sends the file with flag 0x0004 and no content; the
// front moves the mtime and leaves the bytes where they are.
g.must(filepath.Dir(a), "uv", "touch", "hello.txt", "--mtime", "2026-01-02 00:00:00", "-R", a)
out = g.must(filepath.Dir(a), "sync", "-u", g.as("pub"), "-R", a)
o, _ = g.objects.has(uv.Key("test", "hello.txt"))
row, _ = g.row("hello.txt")
check(t, row.Mtime == 1767312000 && o.meta["mtime"] == "1767312000" && string(o.body) == "hello\n",
"an mtime-only uvfile updates the mtime in the catalog and the object",
"touch: row %+v meta %v\n%s", row, o.meta, out)
// uv rm syncs a tombstone; a fresh clone and the earlier one agree.
g.must(filepath.Dir(a), "uv", "rm", "hello.txt", "--mtime", "2026-01-03 00:00:00", "-R", a)
out = g.must(filepath.Dir(a), "sync", "-u", g.as("pub"), "-R", a)
_, content := g.objects.has(uv.Key("test", "hello.txt"))
_, tomb := g.objects.has(uv.TombstoneKey("test", "hello.txt"))
row, _ = g.row("hello.txt")
check(t, !content && tomb && row.Deleted && row.Who == "pub",
"uv rm reaches the bucket as a tombstone and the catalog as a deleted row",
"rm: content %v tomb %v row %+v\n%s", content, tomb, row, out)
c := g.clone("c", "-u")
// uv sync moves only unversioned content, so the anonymous reader's
// round reaches the child with no DAG cards in it at all.
g.must(filepath.Dir(b), "uv", "sync", g.base, "-R", b)
for _, repo := range []string{b, c} {
l := g.must(filepath.Dir(repo), "uv", "list", "-R", repo)
check(t, !strings.Contains(l, "hello.txt"), "a clone sees the removal", "%s still lists hello.txt:\n%s", repo, l)
}
// Two writers, one name, different mtimes: the newer wins on every
// side, whichever syncs first.
g.uvAdd(a, "race.txt", "older\n", "2026-02-01 00:00:00")
g.uvAdd(b, "race.txt", "newer\n", "2026-02-02 00:00:00")
g.must(filepath.Dir(b), "sync", "-u", g.as("pub"), "-R", b)
g.must(filepath.Dir(a), "sync", "-u", g.as("pub"), "-R", a)
g.uvAdd(a, "late.txt", "a wins\n", "2026-02-04 00:00:00")
g.uvAdd(b, "late.txt", "b loses\n", "2026-02-03 00:00:00")
g.must(filepath.Dir(b), "sync", "-u", g.as("pub"), "-R", b)
g.must(filepath.Dir(a), "sync", "-u", g.as("pub"), "-R", a)
g.must(filepath.Dir(b), "sync", "-u", g.as("pub"), "-R", b)
d := g.clone("d", "-u")
for _, want := range []struct{ name, body string }{{"race.txt", "newer\n"}, {"late.txt", "a wins\n"}} {
o, _ := g.objects.has(uv.Key("test", want.name))
ok := string(o.body) == want.body
for _, repo := range []string{a, b, d} {
got, _ := g.fossil(filepath.Dir(repo), "uv", "cat", want.name, "-R", repo)
ok = ok && got == want.body
}
check(t, ok, "the newer "+want.name+" wins in the bucket and in every clone", "%s: bucket %q", want.name, o.body)
}
}
// A file over the binding's limit is refused by name and not written.
func TestUVFossilOverLimit(t *testing.T) {
g := newUVFossilRig(t, 64)
a := g.clone("a")
g.uvAdd(a, "big.bin", strings.Repeat("x", 100), "2026-01-01 00:00:00")
out, _ := g.fossil(filepath.Dir(a), "sync", "-u", g.as("pub"), "-R", a)
_, written := g.objects.has(uv.Key("test", "big.bin"))
check(t, !written && strings.Contains(out, "big.bin") && strings.Contains(out, "64"),
"a file over the bucket's limit is refused by name", "over limit: written %v\n%s", written, out)
}
// A repository with no bucket has no unversioned files: uv-hash draws
// nothing, and the DAG syncs as before.
func TestUVFossilNoBucket(t *testing.T) {
g := newUVFossilRig(t, 0)
b := g.clone("b", "-u")
tl := g.must(filepath.Dir(b), "timeline", "-R", b)
check(t, strings.Contains(tl, "first"), "clone -u of a repository with no bucket carries the DAG", "timeline:\n%s", tl)
reply := g.exchange("pub", "pubpw", g.pullRound())
check(t, !strings.Contains(reply, "pragma uv-") && !strings.Contains(reply, "uvigot"),
"uv-hash with no bucket draws nothing", "reply:\n%s", reply)
}
// splicedPush commits msg in a checkout of repo and pushes it as user
// through a proxy that adds splice to the request. With resign the card
// goes after the login card and the login is signed again over it, as
// the account itself could; without, it goes before the login card,
// where anyone holding a copy of the signed request could put it.
func (g *uvFossilRig) splicedPush(repo, user, msg, splice string, resign bool) string {
g.t.Helper()
splicer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
body := decompress(raw)
if i := bytes.Index(body, []byte("login ")); i >= 0 && bytes.Contains(body, []byte("\npush ")) {
end := i + bytes.IndexByte(body[i:], '\n') + 1
if resign {
covered := append([]byte(splice), body[end:]...)
nonce := sha1.Sum(covered)
n := hex.EncodeToString(nonce[:])
sig := sha1.Sum([]byte(n + xfer.SharedSecret(g.code, user, user+"pw")))
login := "login " + user + " " + n + " " + hex.EncodeToString(sig[:]) + "\n"
body = append(append(append([]byte{}, body[:i]...), login...), covered...)
} else {
body = append(append(append([]byte{}, body[:i]...), splice...), body[i:]...)
}
}
req, _ := http.NewRequest(http.MethodPost, g.base, bytes.NewReader(compress(body)))
req.Header.Set("Content-Type", r.Header.Get("Content-Type"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}))
defer splicer.Close()
co := g.t.TempDir()
g.must(co, "settings", "autosync", "off", "-R", repo)
g.must(co, "open", repo, "--nosync")
if err := os.WriteFile(filepath.Join(co, "NEW.md"), []byte(msg+"\n"), 0o644); err != nil {
g.t.Fatal(err)
}
g.must(co, "add", "NEW.md")
g.fossil(co, "commit", "-m", msg, "--no-warnings")
u, _ := url.Parse(splicer.URL)
u.User = url.UserPassword(user, user+"pw")
out, _ := g.fossil(co, "push", u.String()+"/r/test", "--once")
if g.afterPush != nil {
g.afterPush()
}
// The same push again, straight to the front: it lands, so a
// refusal above was the spliced card's and not the check-in's.
if again, err := g.fossil(co, "push", g.as(user), "--once"); err != nil {
g.t.Errorf("FAIL: the push without the splice: %v\n%s", err, again)
}
return out
}
// timeline is the store's recent check-ins.
func (g *uvFossilRig) timeline() string { return g.zit("timeline", "-n", "10") }
// Without y: a stock client is told uv-pull-only and never sends the
// file. A uvfile sent anyway, in a push its account signed, is refused
// with the letter named, and the check-in it rode with does not land.
func TestUVFossilWithoutY(t *testing.T) {
g := newUVFossilRig(t, 1<<20)
a := g.clone("a")
g.uvAdd(a, "nope.txt", "no\n", "2026-01-01 00:00:00")
out, _ := g.fossil(filepath.Dir(a), "sync", "-u", g.as("dev"), "-R", a)
_, written := g.objects.has(uv.Key("test", "nope.txt"))
reply := g.exchange("dev", "devpw", g.pullRound())
check(t, !written && strings.Contains(reply, "pragma uv-pull-only\n"),
"a stock client without y is told uv-pull-only and publishes nothing", "dev sync: written %v\n%s\n%s", written, out, reply)
splice := "uvfile spliced.txt 1767225600 " + uv.Hash([]byte("x")) + " 1 0\nx"
var landed bool
g.afterPush = func() { landed = strings.Contains(g.timeline(), "dev was here") }
out = g.splicedPush(a, "dev", "dev was here", splice, true)
_, spliced := g.objects.has(uv.Key("test", "spliced.txt"))
check(t, !spliced && strings.Contains(out, "y (Write-UV)") && !landed,
"a signed uvfile without y is refused with the letter named, and the DAG part with it",
"spliced: written %v, landed %v\n%s", spliced, landed, out)
check(t, strings.Contains(g.timeline(), "dev was here"), "the check-in alone lands", "the clean push did not land")
}
// A uvfile put before the login card of a push signed by an account
// that holds y is not that account's: the signature does not cover it,
// so anyone with a copy of the request could have put it there. It is
// refused, nothing is written, and the push does not land.
func TestUVFossilSpliceBeforeLoginIsRefused(t *testing.T) {
g := newUVFossilRig(t, 1<<20)
a := g.clone("a")
splice := "uvfile forged.txt 1767225600 " + uv.Hash([]byte("x")) + " 1 0\nx"
var landed bool
g.afterPush = func() { landed = strings.Contains(g.timeline(), "pub was here") }
out := g.splicedPush(a, "pub", "pub was here", splice, false)
_, forged := g.objects.has(uv.Key("test", "forged.txt"))
_, row := g.row("forged.txt")
check(t, !forged && !row && !landed && strings.Contains(out, "before the login card"),
"a uvfile before a y-holder's login card is refused, nothing written, the push not applied",
"forged: written %v, row %v, landed %v\n%s", forged, row, landed, out)
}
// A tracking mirror takes no local writes, unversioned ones included:
// even y is told uv-pull-only, and a uvfile is refused as a write.
func TestUVFossilTrackingMirrorRefuses(t *testing.T) {
g := newUVFossilRig(t, 1<<20)
g.tracking = true
reply := g.exchange("pub", "pubpw", g.pullRound())
check(t, strings.Contains(reply, "pragma uv-pull-only\n"), "a tracking mirror is uv-pull-only even for y", "reply:\n%s", reply)
reply = g.exchange("pub", "pubpw", "uvfile m.txt 1 "+uv.Hash([]byte("m"))+" 1 0\nm")
_, written := g.objects.has(uv.Key("test", "m.txt"))
check(t, !written && strings.HasPrefix(reply, "error ") && strings.Contains(reply, "mirrors"),
"a uvfile to a tracking mirror is refused as a write", "written %v reply:\n%s", written, reply)
}
// The hand-built rounds above are only meaningful if the front reads
// them; a request with a bad signature is anonymous, not pub.
func TestUVFossilBadLoginIsAnonymous(t *testing.T) {
g := newUVFossilRig(t, 1<<20)
reply := g.exchange("pub", "wrong", g.pullRound())
check(t, !strings.Contains(reply, "uv-push-ok"), "a login that does not verify never draws uv-push-ok", "reply:\n%s", reply)
reply = g.exchange("pub", "wrong", "uvfile w.txt 1 "+uv.Hash([]byte("w"))+" 1 0\nw")
_, written := g.objects.has(uv.Key("test", "w.txt"))
check(t, !written && strings.HasPrefix(reply, `error Write\spermissions\sfor\sunversioned\sfiles\smissing`),
"a uvfile under a login that does not verify is refused", "written %v reply:\n%s", written, reply)
}
// A transfer's timeout grows with its size: the floor for a tombstone
// or a small file, and room for the binding's largest files on a slow
// link, where one fixed timeout would be either too tight or too slack.
func TestUVTransferTimeoutScalesWithSize(t *testing.T) {
for _, c := range []struct {
size int64
want time.Duration
}{
{0, 30 * time.Second},
{1 << 20, 32 * time.Second},
{100 << 20, 230 * time.Second},
{(1 << 20) + 1, 34 * time.Second}, // a started MiB counts whole
} {
got := uvTransferTimeout(c.size)
check(t, got == c.want, fmt.Sprintf("%d bytes get %s", c.size, got), "%d bytes: %s, want %s", c.size, got, c.want)
}
}