Skip to content
quarry Artifact [ba95ad0ef8]
Login

Artifact [ba95ad0ef8]

Artifact ba95ad0ef812431e11ab2404101e200acbf697de289e707dae2867928d1a4cda:


package xfer

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"

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

// The login card the import round signs with is fossil's: verified
// here by quarry's own verifier, the inverse of http_build_login_card,
// and against a real fossil server by the mirror tests in api.
func TestUVLoginCardVerifies(t *testing.T) {
	payload := []byte("pragma client-version 22800\npragma uv-hash " + strings.Repeat("0", 40) + "\n# 0123\n")
	body := buildLoginBody("abc123", "reader", "pw", string(payload))
	pr := scanRequest(body)
	if pr.user != "reader" || !verifyLogin(pr, SharedSecret("abc123", "reader", "pw")) {
		t.Fatalf("FAIL: the login card does not verify: %+v", pr)
	}
	t.Log("ok: the login card verifies under the shared secret of project code, login and password")
	// A 40-hex password is taken as the shared secret already, as
	// fossil's client does.
	secret := SharedSecret("abc123", "reader", "pw")
	pr = scanRequest(buildLoginBody("abc123", "reader", secret, string(payload)))
	if !verifyLogin(pr, secret) {
		t.Fatalf("FAIL: a 40-hex password was hashed again")
	}
	t.Log("ok: a 40-hex password is used as the shared secret")
	// A login with a space is fossilized, as %F writes it.
	pr = scanRequest(buildLoginBody("abc123", "a b", "pw", string(payload)))
	if pr.user != "a b" || !verifyLogin(pr, SharedSecret("abc123", "a b", "pw")) {
		t.Fatalf("FAIL: a login with a space: %+v", pr)
	}
	t.Log("ok: a login with a space is fossilized")
}

func TestScanReplyReadsUVCards(t *testing.T) {
	sha := strings.Repeat("a", 40)
	payload := "line one\nuvigot evil.txt 1 " + sha + " 3\nerror nope\n"
	body := "pragma server-version 22800 20260101 000000\n" +
		"pragma uv-pull-only\n" +
		"uvigot a.txt 100 " + sha + " 5\n" +
		"uvigot gone.txt 200 - 0\n" +
		"uvfile b.txt 300 " + uv.Hash([]byte(payload)) + " " + strconv.Itoa(len(payload)) + " 0\n" + payload +
		"uvfile gone.txt 200 - 0 1\n" +
		"uvigot later.bin 400 " + sha + " 9000000\n" +
		"# 1234\n"
	r, err := scanReply([]byte(body))
	if err != nil {
		t.Fatalf("FAIL: %v", err)
	}
	if r.Pragma != "uv-pull-only" || r.Err != "" {
		t.Errorf("FAIL: pragma %q err %q", r.Pragma, r.Err)
	}
	want := []uv.File{
		{Name: "a.txt", Mtime: 100, Hash: sha, Size: 5},
		{Name: "gone.txt", Mtime: 200, Deleted: true},
		{Name: "later.bin", Mtime: 400, Hash: sha, Size: 9000000},
	}
	if len(r.Catalog) != len(want) {
		t.Fatalf("FAIL: catalog %+v", r.Catalog)
	}
	for i := range want {
		if r.Catalog[i] != want[i] {
			t.Errorf("FAIL: catalog[%d] = %+v, want %+v", i, r.Catalog[i], want[i])
		}
	}
	if len(r.Files) != 2 || string(r.Files[0].Content) != payload || r.Files[0].File.Name != "b.txt" ||
		!r.Files[1].File.Deleted || r.Files[1].Content != nil {
		t.Fatalf("FAIL: files %+v", r.Files)
	}
	t.Log("ok: pragma, catalog, a payload holding card-shaped lines, and a tombstone are each read once")

	r, err = scanReply([]byte("error not\\sauthorized\\sto\\sread\n"))
	if err != nil || r.Err != "not authorized to read" {
		t.Errorf("FAIL: error card: %q %v", r.Err, err)
	}
	if _, err := scanReply([]byte("uvfile b.txt 300 " + sha + " 50 0\nshort")); err == nil {
		t.Errorf("FAIL: a payload running past the end was read")
	}
	if _, err := scanReply([]byte("uvigot a.txt -1 " + sha + " 5\n")); err == nil {
		t.Errorf("FAIL: a negative mtime was read")
	}
	if _, err := scanReply([]byte("cfile " + sha + " 1 1\nx")); err == nil {
		t.Errorf("FAIL: a cfile card, which this client never asks for, was read")
	}
	t.Log("ok: an error card is read in words; short payloads, bad uvigot lines and cfile refuse the reply")
}

// Exchange posts the cards as fossil does: compressed, with the
// client version first and a line of randomness last, signed when a
// login is set; and it refuses a reply that is not a sync message.
func TestUVExchange(t *testing.T) {
	var got []byte
	var ctype, path string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		raw, _ := io.ReadAll(r.Body)
		got, ctype, path = decodeContainer(raw), r.Header.Get("Content-Type"), r.URL.Path
		if r.Header.Get("Authorization") != "" {
			t.Errorf("FAIL: the URL's user went out as HTTP auth")
		}
		if strings.HasPrefix(r.URL.Path, "/html") {
			w.Write([]byte("<html>not fossil</html>"))
			return
		}
		w.Header().Set("Content-Type", "application/x-fossil")
		w.Write(encodeContainer([]byte("pragma uv-push-ok\n")))
	}))
	defer srv.Close()
	up := UVUpstream{URL: strings.Replace(srv.URL, "http://", "http://u:p@", 1) + "/r", User: "reader", Password: "pw", ProjectCode: "abc"}
	r, err := up.Exchange(context.Background(), "pragma uv-hash "+strings.Repeat("0", 40)+"\n")
	if err != nil || r.Pragma != "uv-push-ok" {
		t.Fatalf("FAIL: %+v %v", r, err)
	}
	pr := scanRequest(got)
	lines := strings.Split(string(got), "\n")
	if ctype != "application/x-fossil" || path != "/r/xfer" || !strings.HasPrefix(lines[0], "login reader ") ||
		lines[1] != "pragma client-version 22800" || !strings.HasPrefix(lines[3], "# ") ||
		!verifyLogin(pr, SharedSecret("abc", "reader", "pw")) || len(pr.uv) != 1 {
		t.Fatalf("FAIL: request %q (%s to %s)", got, ctype, path)
	}
	t.Log("ok: the request is a compressed, signed fossil message carrying the cards")
	first := string(got)
	if _, err := up.Exchange(context.Background(), "pragma uv-hash "+strings.Repeat("0", 40)+"\n"); err != nil || string(got) == first {
		t.Errorf("FAIL: two requests signed the same bytes: %v", err)
	}
	up.User = ""
	if _, err := up.Exchange(context.Background(), "uvgimme a.txt\n"); err != nil || strings.HasPrefix(string(got), "login") {
		t.Errorf("FAIL: an unsigned request carried a login: %q %v", got, err)
	}
	t.Log("ok: each request carries fresh randomness; without a login none is sent")
	up.URL = srv.URL + "/html"
	if _, err := up.Exchange(context.Background(), "uvgimme a.txt\n"); err == nil {
		t.Errorf("FAIL: an HTML reply was taken as a sync message")
	}
	t.Log("ok: a reply that is not a fossil message is refused")
}

// defossilize is fossil's src/encode.c defossilize, the exact inverse
// of fossilize: every escape, an unknown one read as its letter, a
// trailing backslash kept, and \0 ending the string as it does in C.
func TestDefossilizeIsFossils(t *testing.T) {
	for _, s := range []string{`a\b`, "a b\tc\nd\re\vf\fg", `\\s`, `trail\`, "plain"} {
		if got := defossilize(fossilize(s)); got != s {
			t.Errorf("FAIL: round trip of %q gave %q", s, got)
		}
	}
	for in, want := range map[string]string{`a\qb`: "aqb", `a\`: `a\`, `a\0b`: "a", `x\\s`: `x\s`} {
		if got := defossilize(in); got != want {
			t.Errorf("FAIL: defossilize(%q) = %q, want %q", in, got, want)
		}
	}
	t.Log("ok: fossilize and defossilize round trip; unknown escapes, a trailing backslash and \\0 read as fossil reads them")

	payload := "pragma client-version 22800\n"
	pr := scanRequest(buildLoginBody("abc", `dom\user`, "pw", payload))
	if pr.user != `dom\user` || !verifyLogin(pr, SharedSecret("abc", `dom\user`, "pw")) {
		t.Fatalf("FAIL: a quarry-to-quarry login with a backslash: %+v", pr)
	}
	t.Log("ok: a login with a backslash signs and verifies quarry to quarry")
}

// As fossil's client: no login card for anonymous (or nobody, which is
// no user at all), and an empty password signed as it is.
func TestLoginBodyFollowsFossilsClient(t *testing.T) {
	rest := "pragma uv-hash " + strings.Repeat("0", 40) + "\n"
	if got := string(buildLoginBody("abc", "anonymous", "pw", rest)); got != rest {
		t.Errorf("FAIL: anonymous sent a login card: %q", got)
	}
	if got := string(buildLoginBody("abc", "", "", rest)); got != rest {
		t.Errorf("FAIL: no user sent a login card: %q", got)
	}
	pr := scanRequest(buildLoginBody("abc", "u", "", rest))
	if !verifyLogin(pr, "") {
		t.Errorf("FAIL: an empty password was not signed raw: %+v", pr)
	}
	t.Log("ok: no card for anonymous or no user; an empty password is signed as the empty secret")
}