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")
}