package xfer
import (
"crypto/sha1"
"encoding/hex"
"net/http"
"testing"
)
// signedLogin builds a card stream carrying a login card that verifies
// against secret, by running the RFC §6 chain forwards: the nonce is
// SHA-1 over the bytes the card covers, the signature SHA-1 over the
// nonce and the shared secret.
func signedLogin(user, secret string) []byte {
covered := "pull aaaa bbbb\n"
sum := sha1.Sum([]byte(covered))
nonce := hex.EncodeToString(sum[:])
sig := sha1.Sum([]byte(nonce + secret))
return []byte("login " + user + " " + nonce + " " + hex.EncodeToString(sig[:]) + "\n" + covered)
}
const (
zitUA = "zig/0.16.0 (std.http)"
fossilUA = "Fossil/2.28 (2026-03-11 11:31:46 [52445a27f1])"
)
// The four cases the design rests on: zit and fossil, each anonymous and
// each with credentials. They do not behave alike, and the difference is
// what the capture has to survive.
func TestTheFourClientCases(t *testing.T) {
for _, tc := range []struct {
name string
ua string
body string
basic string // the account a Basic credential proves; "" for none
wantClient string
wantVersion string
wantAccount string
wantClaimed string
}{
{
name: "zit anonymous",
ua: zitUA,
body: "pragma client-version 22900 20260715 000000\nclone 3 1\n",
wantClient: "zit",
wantVersion: "22900",
},
{
// zit carries credentials in the Authorization header, on the
// clone round itself, which is why zit clones can be named
// and fossil ones mostly cannot.
name: "zit with credentials",
ua: zitUA,
body: "pragma client-version 22900 20260715 000000\nclone 3 1\n",
basic: "alice",
wantClient: "zit",
wantVersion: "22900",
wantAccount: "alice",
},
{
name: "fossil anonymous",
ua: fossilUA,
body: "pragma client-version 22800 20260311 113146\nclone 3 1\n",
wantClient: "fossil",
wantVersion: "22800",
},
{
// A credentialed fossil clone is indistinguishable from an
// anonymous one at the moment the clone is counted: fossil
// never sends Basic, and its login card comes on a later
// round. This case must read as UNIDENTIFIED, not as a
// reader who chose to stay anonymous.
name: "fossil with credentials, clone round",
ua: fossilUA,
body: "pragma client-version 22800 20260311 113146\nclone 3 1\n",
wantClient: "fossil",
wantVersion: "22800",
},
{
name: "an unknown client",
ua: "curl/8.4.0",
body: "clone 3 1\n",
wantClient: "other",
},
{
// No pragma at all is a real shape on the wire, and must not
// invent a version.
name: "no version pragma",
ua: zitUA,
body: "clone 3 1\n",
wantClient: "zit",
},
} {
t.Run(tc.name, func(t *testing.T) {
f := New()
if tc.basic != "" {
account := tc.basic
f.SetIdentify(func(*http.Request) string { return account })
}
pr := scanRequest([]byte(tc.body))
r := f.reader("alpha", pr, pr, probe(tc.ua), true)
if r.Client != tc.wantClient {
t.Errorf("FAIL: client = %q, want %q", r.Client, tc.wantClient)
}
if r.Version != tc.wantVersion {
t.Errorf("FAIL: version = %q, want %q", r.Version, tc.wantVersion)
}
if r.Account != tc.wantAccount {
t.Errorf("FAIL: account = %q, want %q", r.Account, tc.wantAccount)
}
if r.Claimed != tc.wantClaimed {
t.Errorf("FAIL: claimed = %q, want %q", r.Claimed, tc.wantClaimed)
}
})
}
}
// secrets is an Auth that knows one repository's users.
type secrets map[string]string
func (s secrets) Gated(string) bool { return true }
func (s secrets) MayWrite(_, user string) bool { _, ok := s[user]; return ok }
func (s secrets) Secrets(_, user string) []Credential {
v, ok := s[user]
if !ok {
return nil
}
return []Credential{{Secret: v, CheckIn: true, Publish: true}}
}
// A login card whose signature checks out is an ACCOUNT; one that does
// not is a CLAIM. Nothing signs a pull, so a claim is free to forge —
// keeping the two apart is the whole point of capturing either.
func TestAVerifiedLoginCardIsAnAccountAndAForgedOneIsAClaim(t *testing.T) {
const secret = "8d4a1f2b9c0e7a6d5b3f8e1c2a9d4b7e6f0c3a15"
body := signedLogin("alice", secret)
f := New()
f.SetAuth(secrets{"alice": secret})
r := f.reader("alpha", scanRequest(body), scanRequest(body), probe(fossilUA), false)
if r.Account != "alice" {
t.Errorf("FAIL: a verified login gave account %q, want alice", r.Account)
}
if r.Claimed != "" {
t.Errorf("FAIL: a verified login also claimed %q, want nothing", r.Claimed)
}
// The same card against a forge that does not know that secret. The
// name is still worth recording — it is what the client asserted —
// but never as an account.
f = New()
f.SetAuth(secrets{"alice": "0000000000000000000000000000000000000000"})
r = f.reader("alpha", scanRequest(body), scanRequest(body), probe(fossilUA), false)
if r.Account != "" {
t.Errorf("FAIL: an unverifiable login gave account %q, want none", r.Account)
}
if r.Claimed != "alice" {
t.Errorf("FAIL: an unverifiable login claimed %q, want alice", r.Claimed)
}
}
// A forge with no auth plane at all cannot verify anything, so every
// login card it sees is a claim. It must not silently promote them.
func TestWithoutAnAuthPlaneEveryLoginIsAClaim(t *testing.T) {
body := signedLogin("alice", "8d4a1f2b9c0e7a6d5b3f8e1c2a9d4b7e6f0c3a15")
r := New().reader("alpha", scanRequest(body), scanRequest(body), probe(fossilUA), false)
if r.Account != "" {
t.Errorf("FAIL: account = %q with no auth plane, want none", r.Account)
}
if r.Claimed != "alice" {
t.Errorf("FAIL: claimed = %q, want alice", r.Claimed)
}
}
// Anonymous means nobody said anything — not "said something we could
// not check". A page that conflated the two would report a forged name
// and a stranger as the same state.
func TestAnonymousIsNotTheSameAsUnverified(t *testing.T) {
if !(Reader{}).Anonymous() {
t.Error("FAIL: a reader naming nobody is not anonymous")
}
if (Reader{Claimed: "dave"}).Anonymous() {
t.Error("FAIL: a claimed name counts as anonymous")
}
if (Reader{Account: "alice"}).Anonymous() {
t.Error("FAIL: a verified account counts as anonymous")
}
}
// A Basic credential the forge rejects names nobody — Identify answers
// "" — and that must not become a claim either. There is no card to
// claim anything with.
func TestARejectedBasicCredentialNamesNobody(t *testing.T) {
f := New()
f.SetIdentify(func(*http.Request) string { return "" })
pr := scanRequest([]byte("clone 3 1\n"))
r := f.reader("alpha", pr, pr, probe(zitUA), true)
if !r.Anonymous() {
t.Errorf("FAIL: reader = %+v, want anonymous", r)
}
}