Skip to content
quarry Artifact [c2807a00f4]
Login

Artifact [c2807a00f4]

Artifact c2807a00f469a47c73e25527bf8eb6e0a575a0142de2aebec57d6fa8ce089a0a:


package api

import (
	"bytes"
	"crypto/sha1"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"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)
}

// A write-bearing card in an anonymous request refuses the whole
// request, the pull in it too.
func TestAnonymousRequestWithAFileCardIsRefusedWhole(t *testing.T) {
	g := newUVFossilRig(t, 1<<20)
	for _, card := range []string{"config /project 4\nabcd\n", "cfile " + strings.Repeat("a", 40) + " 4\nabcd\n",
		"uvfile x.txt 1767225600 " + uv.Hash([]byte("x")) + " 1 0\nx"} {
		reply := g.exchange("", "", "push x "+g.code+"\npull x "+g.code+"\n"+card)
		check(t, strings.Contains(reply, "error anonymous\\spush\\sis\\snot\\saccepted") && !strings.Contains(reply, "message "),
			"an anonymous request with "+strings.Fields(card)[0]+" is refused whole",
			"%s request:\n%s", strings.Fields(card)[0], reply)
	}
	// 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 := "pull 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)
		}
	}
}