Skip to content
quarry Artifact [8884b0a947]
Login

Artifact [8884b0a947]

Artifact 8884b0a947279903d30c2fb1f891b199c0daa6d96ee78e7b970a77ebc568f305:


package api

import (
	"bytes"
	"crypto/sha1"
	"crypto/sha3"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"craftdesign.group/quarry/internal/uv"
	"craftdesign.group/quarry/internal/xfer"
)

// Stock fossil answers an anonymous `sync -u` against a repository it
// will not take pushes on with "pull only - not authorized to push",
// pulls what is new, and exits 0. The front used to refuse the whole
// request on the push card fossil sends with every sync (quarry #324).

const pullOnlyNote = "pull only - not authorized to push"

// serverCheckIn adds a check-in to the repository the front serves.
func (g *uvFossilRig) serverCheckIn(name, content, msg string) {
	g.t.Helper()
	if err := os.WriteFile(filepath.Join(g.origin, name), []byte(content), 0o644); err != nil {
		g.t.Fatal(err)
	}
	g.zit("add", name)
	g.zit("commit", "-m", msg)
}

func TestAnonymousSyncPullsAndPushesNothing(t *testing.T) {
	g := newUVFossilRig(t, 1<<20)
	a := g.clone("a")
	g.uvAdd(a, "old.txt", "old\n", "2026-01-01 00:00:00")
	g.must(filepath.Dir(a), "sync", "-u", g.as("pub"), "-R", a)
	b := g.clone("b", "-u")

	// The server moves on: a new check-in and a new unversioned file.
	g.serverCheckIn("NEWS.md", "news\n", "server moved on")
	g.uvAdd(a, "new.txt", "new\n", "2026-01-02 00:00:00")
	g.must(filepath.Dir(a), "sync", "-u", g.as("pub"), "-R", a)

	// The reader holds an unversioned file of its own, which it must
	// not be able to publish.
	g.uvAdd(b, "mine.txt", "mine\n", "2026-01-03 00:00:00")

	out, err := g.fossil(filepath.Dir(b), "sync", "-u", g.base, "-R", b)
	check(t, err == nil, "an anonymous sync -u exits 0", "anonymous sync -u: %v\n%s", err, out)
	check(t, strings.Contains(out, pullOnlyNote), "the reader is told it may only pull", "no pull-only note:\n%s", out)
	tl := g.must(filepath.Dir(b), "timeline", "-R", b)
	check(t, strings.Contains(tl, "server moved on"), "the new check-in was pulled", "timeline of the reader:\n%s", tl)
	got, _ := g.fossil(filepath.Dir(b), "uv", "cat", "new.txt", "-R", b)
	check(t, got == "new\n", "the new unversioned file was pulled", "uv cat new.txt = %q", got)
	_, pushed := g.objects.has(uv.Key("test", "mine.txt"))
	_, row := g.row("mine.txt")
	check(t, !pushed && !row, "the reader's own unversioned file was not published", "bucket %v, catalog %v", pushed, row)
}

// stored says whether the store behind the front holds an artifact.
func (g *uvFossilRig) stored(hash string) bool {
	g.t.Helper()
	cmd := exec.Command("zit", "artifact", hash)
	cmd.Dir = g.origin
	return cmd.Run() == nil
}

// A write-bearing card in an anonymous request refuses the whole
// request, the pull in it too, with or without the push card that
// fossil sends in front of it, on a gated store and on a mirror.
func TestAnonymousRequestWithAFileCardIsRefusedWhole(t *testing.T) {
	for _, mirror := range []bool{false, true} {
		g := newUVFossilRig(t, 1<<20)
		g.tracking = mirror
		sha1h := fmt.Sprintf("%x", sha1.Sum([]byte("payload one")))
		sha3h := fmt.Sprintf("%x", sha3.Sum256([]byte("payload two")))
		cards := []string{
			"file " + sha1h + " 11\npayload one",
			"file " + sha3h + " 11\npayload two",
			"config /project 4\nabcd\n",
			"cfile " + strings.Repeat("a", 40) + " 4\nabcd\n",
			"uvfile x.txt 1767225600 " + uv.Hash([]byte("x")) + " 1 0\nx",
		}
		for _, card := range cards {
			for _, push := range []string{"push x " + g.code + "\n", ""} {
				reply := g.exchange("", "", push+"pull x "+g.code+"\n"+card)
				kind := strings.Fields(card)[0]
				check(t, strings.Contains(reply, "error ") && !strings.Contains(reply, "message ") && !strings.Contains(reply, "igot"),
					fmt.Sprintf("an anonymous request with %s (push card %v, mirror %v) is refused whole", kind, push != "", mirror),
					"%s request (push card %v, mirror %v):\n%s", kind, push != "", mirror, reply)
			}
		}
		for _, h := range []string{sha1h, sha3h} {
			if g.stored(h) {
				t.Errorf("FAIL: an anonymous file card stored %s (mirror %v)", h, mirror)
			}
		}
		if _, ok := g.row("x.txt"); ok {
			t.Errorf("FAIL: an anonymous uvfile reached the catalog (mirror %v)", mirror)
		}
	}
	g := newUVFossilRig(t, 1<<20)
	// And with nothing to write it is a pull, told so.
	reply := g.exchange("", "", "push x "+g.code+"\npull x "+g.code+"\n")
	check(t, !strings.Contains(reply, "error ") && strings.Contains(reply, "message pull\\sonly"),
		"an anonymous push card alone is answered as a pull", "reply:\n%s", reply)
}

// A signed-in reader whose account holds no check-in letter is
// answered as stock fossil answers one without push capability.
func TestSignedInReaderWithoutCheckInPulls(t *testing.T) {
	g := newUVFossilRigWith(t, 1<<20, forgeFront)
	if err := g.db.AddAccount("rdr", "rdrpw", false); err != nil {
		t.Fatal(err)
	}
	if err := g.db.SetCaps("rdr", "test", "o"); err != nil {
		t.Fatal(err)
	}
	b := g.clone("b")
	g.serverCheckIn("NEWS.md", "news\n", "server moved on")
	url := strings.Replace(g.base, "http://", "http://rdr:rdrpw@", 1)
	out, err := g.fossil(filepath.Dir(b), "sync", url, "-R", b)
	check(t, err == nil && strings.Contains(out, pullOnlyNote), "a signed-in reader without i syncs pull only", "reader sync: %v\n%s", err, out)
	check(t, strings.Contains(g.must(filepath.Dir(b), "timeline", "-R", b), "server moved on"), "and pulls the new check-in", "not pulled")
}

// post is exchange with the status kept, for comparing refusals.
func (g *uvFossilRig) post(user, pw, cards string) (int, 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 resp.StatusCode, string(decompress(raw))
}

// A store that mirrors an upstream answers an anonymous sync as a
// pull, and still refuses a write whoever signed it.
func TestMirrorAnswersAnonymousSyncAsAPull(t *testing.T) {
	g := newUVFossilRig(t, 1<<20)
	b := g.clone("b")
	g.serverCheckIn("NEWS.md", "news\n", "server moved on")
	g.tracking = true
	out, err := g.fossil(filepath.Dir(b), "sync", g.base, "-R", b)
	check(t, err == nil && strings.Contains(out, pullOnlyNote), "an anonymous sync of a mirror is told pull only", "sync: %v\n%s", err, out)
	check(t, strings.Contains(g.must(filepath.Dir(b), "timeline", "-R", b), "server moved on"), "and pulls the new check-in", "not pulled")
	reply := g.exchange("pub", "pubpw", "push x "+g.code+"\nconfig /project 4\nabcd\n")
	check(t, strings.Contains(reply, "mirrors"), "a signed write to a mirror is still refused", "reply:\n%s", reply)
}

// A login card that does not verify reads as nobody: on a private
// repository it is refused exactly as an unknown name is, whatever the
// account it names holds.
func TestUnverifiedLoginReadsAsAnonymous(t *testing.T) {
	g := newUVFossilRigWith(t, 1<<20, func(g *uvFossilRig, f *xfer.Fronter) {
		readers := map[string]bool{"pub": true, "dev": true, "rdr": true}
		// What registrar.SyncReadGate does for a private repository: only
		// the login xfer verified is somebody.
		f.SetReadGate(func(_ *http.Request, _, login string) bool { return readers[login] })
		f.SetAuth(uvSyncAuth{code: g.code,
			pw:      map[string]string{"pub": "pubpw", "dev": "devpw", "rdr": "rdrpw"},
			letters: map[string]string{"pub": "oiy", "dev": "oi", "rdr": "o"}})
	})
	pull := "push x " + g.code + "\npull x " + g.code + "\n"
	// A verified reader is served, so the refusals below are the gate's.
	if st, body := g.post("rdr", "rdrpw", pull); st != 200 || strings.Contains(body, "error ") {
		t.Fatalf("FAIL: the control, a verified reader, was refused: %d %s", st, body)
	}
	var want string
	for i, who := range []string{"", "ghost", "rdr", "dev", "pub"} {
		st, body := g.post(who, "wrong", pull)
		got := strings.Join([]string{http.StatusText(st), body}, "|")
		if st == 200 {
			t.Errorf("FAIL: %q with a wrong password read the private repository: %s", who, body)
		}
		if i == 0 {
			want = got
		} else if got != want {
			t.Errorf("FAIL: wrong password for %q answered %q, unlike an unknown name's %q", who, got, want)
		}
	}
	t.Log("ok: an unverified login is refused the same whoever it names")

}

// A wrong password reads the same on a writer, a reader and an unknown
// name when the request writes nothing, so a caller cannot probe which
// accounts hold the check-in letter.
func TestWrongPasswordOnAPullDoesNotDisclose(t *testing.T) {
	g := newUVFossilRig(t, 1<<20)
	round := "push x " + g.code + "\npull x " + g.code + "\n"
	var want string
	for i, who := range []string{"ghost", "dev", "pub"} {
		st, body := g.post(who, "wrong", round)
		got := http.StatusText(st) + "|" + body
		if !strings.Contains(body, "message pull\\sonly") || strings.Contains(body, "error ") {
			t.Errorf("FAIL: wrong password for %q: %d %s", who, st, body)
		}
		if i == 0 {
			want = got
		} else if got != want {
			t.Errorf("FAIL: wrong password for %q answered %q, unlike an unknown name's %q", who, got, want)
		}
	}
}

// A client holding check-ins the server lacks syncs anonymously as a
// clean pull over every round: its igot cards are not answered with
// gimmes, so it is never led to send files that would be refused.
func TestAnonymousSyncWithLocalCheckInsIsACleanPull(t *testing.T) {
	g := newUVFossilRig(t, 1<<20)
	b := g.clone("b")
	co := t.TempDir()
	g.must(co, "settings", "autosync", "off", "-R", b)
	g.must(co, "open", b, "--nosync")
	if err := os.WriteFile(filepath.Join(co, "LOCAL.md"), []byte("only here\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	g.must(co, "add", "LOCAL.md")
	g.fossil(co, "commit", "-m", "local only", "--no-warnings")
	// Held, but not queued for sending: what a clone of another server
	// holds. A queued artifact is sent as a file card, which is a write.
	g.must(co, "sqlite3", "-R", b, "DELETE FROM unsent")
	g.serverCheckIn("NEWS.md", "news\n", "server moved on")

	out, err := g.fossil(co, "sync", g.base)
	check(t, err == nil && strings.Contains(out, pullOnlyNote) && !strings.Contains(out, "rror"),
		"an anonymous sync holding unpushed check-ins ends as a clean pull", "sync: %v\n%s", err, out)
	check(t, strings.Contains(g.must(co, "timeline"), "server moved on"), "and pulls the server's check-in", "not pulled")
	check(t, !strings.Contains(g.timeline(), "local only"), "and pushes nothing", "the local check-in reached the server")
}