Skip to content
quarry Artifact [f1bf1bf57d]
Login

Artifact [f1bf1bf57d]

Artifact f1bf1bf57d1413661cc3a7b17352f8b512b2930f381d6b4018d9a0ec67d69024:


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, repo string) CapSet {
			if l, ok := letters[account]; ok {
				return oltp.CapSet(l)
			}
			// Anyone else is the forge's to answer: a run token's
			// login (RFC 0021 §runs) resolves by its name.
			return g.db.EffectiveCaps(account, repo)
		}}
	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)
	}
}