Skip to content
quarry Artifact [372c40a6e5]
Login

Artifact [372c40a6e5]

Artifact 372c40a6e5a645b1ba2ec22e0ba3be5fb1959fb0f74095ef3f254955844fcdf1:


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 })
			}
			r := f.reader("alpha", scanRequest([]byte(tc.body)), probe(tc.ua), []byte(tc.body), 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) Secret(_, user string) (string, bool) {
	v, ok := s[user]
	return v, ok
}

// 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), probe(fossilUA), body, 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), probe(fossilUA), body, 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), probe(fossilUA), body, 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 "" })
	r := f.reader("alpha", scanRequest([]byte("clone 3 1\n")), probe(zitUA), nil, true)
	if !r.Anonymous() {
		t.Errorf("FAIL: reader = %+v, want anonymous", r)
	}
}