package xfer
import (
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func zrun(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("zit", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("zit %v: %v\n%s", args, err, out)
}
return string(out)
}
// TestCloneAndPushThroughFronter is the 0001 §7 acceptance: `zit clone
// <quarry>/r/<repo>` works, and a push through the same URL lands
// artifacts in the fronted store.
func TestCloneAndPushThroughFronter(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
origin := t.TempDir()
zrun(t, origin, "init")
if err := os.WriteFile(filepath.Join(origin, "README.md"), []byte("# fronted\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, origin, "add", "README.md")
zrun(t, origin, "commit", "-m", "first")
f := New()
defer f.Close()
f.Register("alpha", origin)
mux := http.NewServeMux()
mux.Handle("POST /r/{repo}", f)
mux.Handle("POST /r/{repo}/xfer", f)
srv := httptest.NewServer(mux)
defer srv.Close()
// Clone through quarry: the repo URL is used verbatim by zit.
work := t.TempDir()
clone := filepath.Join(work, "clone")
zrun(t, work, "clone", srv.URL+"/r/alpha", clone)
if tl := zrun(t, clone, "timeline", "-n", "3"); !strings.Contains(tl, "first") {
t.Fatalf("clone missing history:\n%s", tl)
}
// Push back through the same URL (saved as the clone's remote).
if err := os.WriteFile(filepath.Join(clone, "NOTES.md"), []byte("pushed\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, clone, "add", "NOTES.md")
zrun(t, clone, "commit", "-m", "pushed through quarry")
zrun(t, clone, "push")
if tl := zrun(t, origin, "timeline", "-n", "3"); !strings.Contains(tl, "pushed through quarry") {
t.Fatalf("push did not land in origin:\n%s", tl)
}
// The /xfer alias answers too.
zrun(t, work, "clone", srv.URL+"/r/alpha/xfer", filepath.Join(work, "clone2"))
// Unknown repos 404 rather than 502.
resp, err := http.Post(srv.URL+"/r/nope", "application/x-fossil", strings.NewReader(""))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown repo: status %d", resp.StatusCode)
}
}
// TestBackendRestartAfterDeath: a dead child is restarted on the next
// request instead of 502ing forever.
func TestBackendRestartAfterDeath(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
origin := t.TempDir()
zrun(t, origin, "init")
f := New()
defer f.Close()
f.Register("alpha", origin)
mux := http.NewServeMux()
mux.Handle("POST /r/{repo}", f)
srv := httptest.NewServer(mux)
defer srv.Close()
work := t.TempDir()
zrun(t, work, "clone", srv.URL+"/r/alpha", filepath.Join(work, "c1"))
// Kill the child behind its back, and wait until it is dead. Kill
// only sends the signal: until the process is gone its listening
// socket can still take ensure's liveness dial, and the clone is
// then relayed to a dying child and answered 502 (CI, b3faaff7).
// That is a child dying mid-request, not the dead child this test
// is about.
f.mu.Lock()
b := f.repos["alpha"]
b.cmd.Process.Kill()
exited := b.exited
f.mu.Unlock()
<-exited
zrun(t, work, "clone", srv.URL+"/r/alpha", filepath.Join(work, "c2"))
}
type mapAuth struct{ users map[string]string } // user -> secret, one repo
func (m *mapAuth) Gated(string) bool { return len(m.users) > 0 }
// Every account this fake knows may write; the refusal cases that turn
// on the LETTER are exercised where the letter is the subject.
func (m *mapAuth) MayWrite(_, user string) bool { _, ok := m.users[user]; return ok }
func (m *mapAuth) Secrets(_, user string) []Credential {
s, ok := m.users[user]
if !ok {
return nil
}
return []Credential{{Secret: s, CheckIn: true, Publish: true}}
}
// TestPushGating is 0001 §7 v1 auth: with a sync user configured,
// anonymous pushes bounce with an error card and pulls stay anonymous.
// The authenticated-push leg drives the fossil client: it sends a
// credential on every round per sync RFC §6.5, where the zit client
// stops after round 1 (zit#74) — until that lands, zit pushes to a
// gated repo fail closed.
func TestPushGating(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
origin := t.TempDir()
zrun(t, origin, "init")
if err := os.WriteFile(filepath.Join(origin, "README.md"), []byte("# gated\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, origin, "add", "README.md")
zrun(t, origin, "commit", "-m", "first")
code, err := ProjectCode(origin)
if err != nil {
t.Fatal(err)
}
if len(code) < 40 {
t.Fatalf("suspicious project code %q", code)
}
f := New()
defer f.Close()
f.Register("alpha", origin)
f.SetAuth(&mapAuth{users: map[string]string{
"dan": SharedSecret(code, "dan", "hunter2"),
}})
mux := http.NewServeMux()
mux.Handle("POST /r/{repo}", f)
srv := httptest.NewServer(mux)
defer srv.Close()
// Pull stays anonymous (--once so the commit below cannot autosync).
work := t.TempDir()
clone := filepath.Join(work, "clone")
zrun(t, work, "clone", srv.URL+"/r/alpha", clone, "--once")
if err := os.WriteFile(filepath.Join(clone, "NOTES.md"), []byte("gated push\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, clone, "add", "NOTES.md")
zrun(t, clone, "commit", "-m", "wants in")
// Anonymous push: refused, and nothing lands.
cmd := exec.Command("zit", "push", srv.URL+"/r/alpha")
cmd.Dir = clone
if out, err := cmd.CombinedOutput(); err == nil {
t.Fatalf("anonymous push succeeded against a gated repo:\n%s", out)
}
if tl := zrun(t, origin, "timeline", "-n", "2"); strings.Contains(tl, "wants in") {
t.Fatalf("refused push still landed:\n%s", tl)
}
if _, err := exec.LookPath("fossil"); err != nil {
t.Skip("fossil not on PATH for the authenticated-push leg")
}
frun := func(dir string, args ...string) (string, error) {
cmd := exec.Command("fossil", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
return string(out), err
}
// Wrong password: refused.
fwork := t.TempDir()
if out, err := frun(fwork, "clone", srv.URL+"/r/alpha", "bad.fossil"); err != nil {
t.Fatalf("fossil clone (pull is anonymous) should succeed: %v\n%s", err, out)
}
co := filepath.Join(fwork, "co")
if err := os.MkdirAll(co, 0o755); err != nil {
t.Fatal(err)
}
if out, err := frun(co, "open", filepath.Join(fwork, "bad.fossil")); err != nil {
t.Fatalf("fossil open: %v\n%s", err, out)
}
if err := os.WriteFile(filepath.Join(co, "F.md"), []byte("fossil push\n"), 0o644); err != nil {
t.Fatal(err)
}
frun(co, "add", "F.md")
if out, err := frun(co, "commit", "-m", "fossil wants in", "--no-warnings"); err != nil && !strings.Contains(out, "not authorized") {
t.Fatalf("fossil commit: %v\n%s", err, out)
}
if out, _ := frun(co, "push", strings.Replace(srv.URL, "http://", "http://dan:wrong@", 1)+"/r/alpha", "--once"); !strings.Contains(strings.ToLower(out), "not authorized") {
t.Fatalf("wrong-password fossil push not refused:\n%s", out)
}
if tl := zrun(t, origin, "timeline", "-n", "2"); strings.Contains(tl, "fossil wants in") {
t.Fatalf("refused fossil push landed:\n%s", tl)
}
// Right credentials: lands.
if out, err := frun(co, "push", strings.Replace(srv.URL, "http://", "http://dan:hunter2@", 1)+"/r/alpha", "--once"); err != nil {
t.Fatalf("authenticated fossil push failed: %v\n%s", err, out)
}
if tl := zrun(t, origin, "timeline", "-n", "2"); !strings.Contains(tl, "fossil wants in") {
t.Fatalf("authenticated fossil push missing from origin:\n%s", tl)
}
}
// ProjectCode must work on a BARE store — a directory holding only the
// .zit file, with no checkout. That is the whole point of reading it
// through `zit stat -R` (zit#85) instead of handshaking against a
// transient `zit serve`, which is checkout-only. quarry's read plane is
// already -R-only, so this is the last thing that stood between it and
// serving a bare store.
func TestProjectCodeOnBareStore(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
origin := t.TempDir()
zrun(t, origin, "init")
if err := os.WriteFile(filepath.Join(origin, "a.txt"), []byte("a\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, origin, "add", "a.txt")
zrun(t, origin, "commit", "-m", "first")
want, err := ProjectCode(origin)
if err != nil {
t.Fatal(err)
}
// Copy ONLY the store: no .zit-checkout, no .zit-cache, no files.
bare := t.TempDir()
blob, err := os.ReadFile(filepath.Join(origin, ".zit"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bare, ".zit"), blob, 0o644); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(bare)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != ".zit" {
t.Fatalf("bare store is not bare: %v", entries)
}
got, err := ProjectCode(bare)
if err != nil {
t.Fatalf("bare store: %v", err)
}
if got != want {
t.Fatalf("bare store reports %q, checkout reports %q", got, want)
}
}
// fakeChild stands in for a zit serve child and records what the gate
// sent it. The gate believes a backend is alive when its process has
// not exited and its port answers, so a sleeping process fronts a
// listener the test owns.
func fakeChild(t *testing.T, f *Fronter, name string) (*httptest.Server, *http.Header) {
t.Helper()
ln, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
var got http.Header
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Clone()
w.Header().Set("Content-Type", "application/x-fossil")
w.Write([]byte("pragma ok\n"))
}))
srv.Listener = ln
srv.Start()
t.Cleanup(srv.Close)
sleeper := exec.Command("sleep", "60")
if err := sleeper.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { sleeper.Process.Kill() })
// Reaped the way startChild reaps, since stop waits on exited.
exited := make(chan struct{})
go func() {
sleeper.Wait()
close(exited)
}()
f.mu.Lock()
f.repos[name] = &backend{dir: t.TempDir(), port: ln.Addr().(*net.TCPAddr).Port, cmd: sleeper, exited: exited}
f.mu.Unlock()
return srv, &got
}
// RFC 0007 §Enforcement, requirements 1 and 2 on the gate: the protect
// header is on EVERY request the child sees — an empty value when
// nothing is refused, never an absent header — and a client-supplied
// header with the same name never survives the gate. zit#151 treats an
// absent header as no rule, so absence is the failure mode a gate bug
// would produce, and it must be impossible by construction.
func TestGateAlwaysSetsItsOwnProtectHeader(t *testing.T) {
f := New()
defer f.Close()
_, seen := fakeChild(t, f, "alpha")
mux := http.NewServeMux()
mux.Handle("POST /r/{repo}", f)
srv := httptest.NewServer(mux)
defer srv.Close()
send := func(clientHeader string) {
t.Helper()
req, _ := http.NewRequest("POST", srv.URL+"/r/alpha", strings.NewReader("pull\n"))
req.Header.Set("Content-Type", "application/x-fossil")
if clientHeader != "" {
req.Header.Set(ProtectHeader, clientHeader)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
// No rule source at all: the header is still present, and empty.
send("")
if v, ok := (*seen)[http.CanonicalHeaderKey(ProtectHeader)]; !ok {
t.Fatalf("no protect header reached the child; zit reads absence as no rule")
} else if strings.Join(v, "") != "" {
t.Errorf("header %q with no rule source, want empty", v)
}
// A client's header is not forwarded: the child sees the gate's
// value, which with no rule source is empty.
send("trunk,release-*")
if v := (*seen).Get(ProtectHeader); v != "" {
t.Fatalf("client-supplied protect header %q reached the child", v)
}
// Nor is a client's divert login: only the gate names who an
// attempt branch is kept for, and an anonymous round names nobody.
req, _ := http.NewRequest("POST", srv.URL+"/r/alpha", strings.NewReader("pull\n"))
req.Header.Set("Content-Type", "application/x-fossil")
req.Header.Set(DivertLoginHeader, "mallory")
if resp, err := http.DefaultClient.Do(req); err != nil {
t.Fatal(err)
} else {
resp.Body.Close()
}
if v, ok := (*seen)[http.CanonicalHeaderKey(DivertLoginHeader)]; ok {
t.Fatalf("client-supplied divert login %q reached the child", v)
}
// With a rule source, the gate's computed value — and still not
// the client's.
f.SetProtect(func(repo, user string) ([]string, error) { return []string{"trunk", "release-*"}, nil })
send("nothing")
if v := (*seen).Get(ProtectHeader); v != "trunk,release-*" {
t.Fatalf("child saw %q, want the gate's rule", v)
}
}
// The child's diverted header is one fossilized line per move; a name
// with a space or newline in it stays one field, and a malformed line is
// dropped rather than misread.
func TestDiversionsParse(t *testing.T) {
h := http.Header{}
h.Add(DivertedHeader, `aa attempt/jan/aa release,\sx\nx-evil:\s1`)
h.Add(DivertedHeader, "bb attempt/jan/bb trunk\ncc too many fields here")
got := diversions(h)
want := []Diversion{
{Checkin: "aa", Branch: "attempt/jan/aa", Protected: "release, x\nx-evil: 1"},
{Checkin: "bb", Branch: "attempt/jan/bb", Protected: "trunk"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("diversions = %#v, want %#v", got, want)
}
}
// zit#209 end to end: a non-owner's push onto trunk is refused once,
// the second push lands on the pusher's attempt branch while trunk stays
// as it was, and the listener hears which check-in went where, named for
// the authenticated pusher.
func TestDivertedPushReachesTheListener(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
origin := t.TempDir()
zrun(t, origin, "init")
if err := os.WriteFile(filepath.Join(origin, "README.md"), []byte("# protected\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, origin, "add", "README.md")
zrun(t, origin, "commit", "-m", "first")
code, err := ProjectCode(origin)
if err != nil {
t.Fatal(err)
}
f := New()
defer f.Close()
f.Register("alpha", origin)
f.SetAuth(&mapAuth{users: map[string]string{"bob": SharedSecret(code, "bob", "pw-b")}})
f.SetProtect(func(repo, user string) ([]string, error) { return []string{"trunk"}, nil })
heard := make(chan []Diversion, 4)
f.SetDiverted(func(repo, pusher string, moves []Diversion) {
if repo != "alpha" || pusher != "bob" {
t.Errorf("listener told repo %q pusher %q", repo, pusher)
}
heard <- moves
})
mux := http.NewServeMux()
mux.Handle("POST /r/{repo}", f)
srv := httptest.NewServer(mux)
defer srv.Close()
work := t.TempDir()
clone := filepath.Join(work, "clone")
zrun(t, work, "clone", srv.URL+"/r/alpha", clone, "--once")
if err := os.WriteFile(filepath.Join(clone, "ONTRUNK.md"), []byte("joins trunk\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, clone, "add", "ONTRUNK.md")
zrun(t, clone, "commit", "-m", "bob wants trunk")
push := func() (string, error) {
cmd := exec.Command("zit", "push", srv.URL+"/r/alpha", "--user", "bob", "--password", "pw-b")
cmd.Dir = clone
out, err := cmd.CombinedOutput()
return string(out), err
}
if out, err := push(); err == nil {
t.Fatalf("first non-owner push to a protected branch succeeded:\n%s", out)
}
select {
case m := <-heard:
t.Fatalf("a refusal was reported as a diversion: %#v", m)
default:
}
out, _ := push()
var moves []Diversion
select {
case moves = <-heard:
case <-time.After(10 * time.Second):
t.Fatalf("second push diverted nothing the listener heard:\n%s", out)
}
if len(moves) != 1 || !strings.HasPrefix(moves[0].Branch, "attempt/bob/") || moves[0].Protected != "trunk" ||
!strings.HasPrefix(moves[0].Checkin, strings.TrimPrefix(moves[0].Branch, "attempt/bob/")) {
t.Fatalf("moves = %#v", moves)
}
if tl := zrun(t, origin, "timeline", "-n", "5", "-b", "trunk"); strings.Contains(tl, "bob wants trunk") {
t.Fatalf("diverted check-in shows on trunk:\n%s", tl)
}
if tl := zrun(t, origin, "timeline", "-n", "5", "-b", moves[0].Branch); !strings.Contains(tl, "bob wants trunk") {
t.Fatalf("attempt branch lacks the check-in:\n%s", tl)
}
}
// The push path end to end: a non-owner's merge onto trunk is refused
// by the child on the gate's rule, the whole round, with the branch
// named; an owner's identical push is admitted. The rule is the gate's
// (who pushed) and the decision is the child's (which branch), which
// is the split RFC 0007 chose.
func TestProtectedBranchRefusesNonOwnerPush(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
origin := t.TempDir()
zrun(t, origin, "init")
if err := os.WriteFile(filepath.Join(origin, "README.md"), []byte("# protected\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, origin, "add", "README.md")
zrun(t, origin, "commit", "-m", "first")
code, err := ProjectCode(origin)
if err != nil {
t.Fatal(err)
}
f := New()
defer f.Close()
f.Register("alpha", origin)
f.SetAuth(&mapAuth{users: map[string]string{
"alice": SharedSecret(code, "alice", "pw-a"),
"bob": SharedSecret(code, "bob", "pw-b"),
}})
// alice owns trunk; bob does not.
f.SetProtect(func(repo, user string) ([]string, error) {
if user == "alice" {
return nil, nil
}
return []string{"trunk"}, nil
})
mux := http.NewServeMux()
mux.Handle("POST /r/{repo}", f)
srv := httptest.NewServer(mux)
defer srv.Close()
work := t.TempDir()
clone := filepath.Join(work, "clone")
zrun(t, work, "clone", srv.URL+"/r/alpha", clone, "--once")
if err := os.WriteFile(filepath.Join(clone, "ONTRUNK.md"), []byte("joins trunk\n"), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, clone, "add", "ONTRUNK.md")
zrun(t, clone, "commit", "-m", "bob wants trunk")
push := func(user, pw string) (string, error) {
cmd := exec.Command("zit", "push", srv.URL+"/r/alpha", "--user", user, "--password", pw)
cmd.Dir = clone
out, err := cmd.CombinedOutput()
return string(out), err
}
out, err := push("bob", "pw-b")
if err == nil {
t.Fatalf("non-owner push to a protected branch succeeded:\n%s", out)
}
if !strings.Contains(out, "trunk") {
t.Errorf("refusal does not name the branch:\n%s", out)
}
if tl := zrun(t, origin, "timeline", "-n", "3"); strings.Contains(tl, "bob wants trunk") {
t.Fatalf("refused push still landed:\n%s", tl)
}
if out, err := push("alice", "pw-a"); err != nil {
t.Fatalf("owner push refused: %v\n%s", err, out)
}
if tl := zrun(t, origin, "timeline", "-n", "3"); !strings.Contains(tl, "bob wants trunk") {
t.Fatalf("owner push missing from origin:\n%s", tl)
}
}