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