Skip to content
quarry Artifact [21444d1305]
Login

Artifact [21444d1305]

Artifact 21444d130522d24c87d30fbdf67009f7b236fdc0945e9ca24e3d4bc3099734bc:


package xfer

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

// challengeFronter models the three cases that matter, so each takes a
// different path through ServeHTTP:
//
//	secret  private   — the gate refuses it
//	open    public    — the gate passes it and the registry knows it
//	absent  no such   — the gate passes it and the registry does not
//
// Neither "open" nor "secret" has a store behind it, so ensure fails for
// both; what separates them is whether the registry has heard of them,
// which is exactly the distinction the challenge must not expose.
func challengeFronter(t *testing.T) *Fronter {
	t.Helper()
	f := New()
	f.SetReadGate(func(_ *http.Request, repo, login string) bool {
		return repo != "secret" || login == "insider"
	})
	f.SetKnown(func(name string) bool { return name == "secret" || name == "open" })
	return f
}

func syncPost(t *testing.T, repo string) *http.Request {
	t.Helper()
	req := httptest.NewRequest(http.MethodPost, "/r/"+repo, nil)
	req.SetPathValue("repo", repo)
	req.Header.Set("Content-Type", "application/x-fossil")
	return req
}

// A credential-less caller is CHALLENGED, not dismissed.
//
// fossil's first clone request carries no credential: it sends Basic
// only after a 401, and a login card only on later rounds of a sync
// already under way. Answering 404 meant it was never challenged and
// never authenticated, so a private repository was unclonable by the
// people it admits — the gate's judgement was correct and unreachable
// (b112b205). jva found this by tracing the wire after the gate fix
// looked right and changed nothing.
func TestACredentiallessReadIsChallenged(t *testing.T) {
	f := challengeFronter(t)
	w := httptest.NewRecorder()
	f.ServeHTTP(w, syncPost(t, "secret"))

	if w.Code != http.StatusUnauthorized {
		t.Fatalf("a private repository answered %d to a caller with no credential, want 401: "+
			"no stock client ever authenticates without being asked", w.Code)
	}
	if got := w.Header().Get("WWW-Authenticate"); got == "" {
		t.Error("the challenge carries no WWW-Authenticate header, so no client will retry")
	}
}

// THE PROPERTY THAT MAKES THE CHALLENGE SAFE.
//
// An unknown repository must answer a credential-less caller exactly as
// a private one does, or the difference between 401 and 404 becomes an
// existence oracle — which is the leak that answering 404 was chosen to
// avoid in the first place.
func TestPrivateAndUnknownAreIndistinguishable(t *testing.T) {
	f := challengeFronter(t)

	priv := httptest.NewRecorder()
	f.ServeHTTP(priv, syncPost(t, "secret"))
	absent := httptest.NewRecorder()
	f.ServeHTTP(absent, syncPost(t, "absent"))

	if priv.Code != absent.Code {
		t.Errorf("private answered %d and absent answered %d: the pair tells a stranger "+
			"which repositories exist", priv.Code, absent.Code)
	}
	if priv.Header().Get("WWW-Authenticate") != absent.Header().Get("WWW-Authenticate") {
		t.Error("the challenge header differs between a private and an absent repository")
	}
}

// A caller who OFFERED something and still may not read is told 404 and
// not challenged again: they have had their turn, and repeating the
// challenge would loop.
func TestAnOfferedCredentialIsNotChallengedAgain(t *testing.T) {
	f := challengeFronter(t)
	req := syncPost(t, "secret")
	req.SetBasicAuth("outsider", "whatever")
	w := httptest.NewRecorder()
	f.ServeHTTP(w, req)

	if w.Code == http.StatusUnauthorized {
		t.Error("a caller who already presented a credential was challenged again: " +
			"a client that retries on 401 will loop")
	}
	if w.Code != http.StatusNotFound {
		t.Errorf("answered %d, want 404", w.Code)
	}
}

// A public repository is untouched: an anonymous clone must not start
// asking people to log in.
func TestAPublicReadIsNotChallenged(t *testing.T) {
	f := challengeFronter(t)
	w := httptest.NewRecorder()
	f.ServeHTTP(w, syncPost(t, "open"))

	if w.Code == http.StatusUnauthorized {
		t.Fatal("a public repository challenged an anonymous reader: " +
			"an anonymous clone must not start asking people to log in")
	}
}