Artifact 9b328187783f867a26c9355db05034e2edaada56155a90e5f743baf15e7f469c:
- File
internal/xfer/xfer_test.go
— part of check-in
[daa9fc84b5]
at
2026-08-26 22:59:07
on branch protect-2
— RFC 0007 phase 2: the gate turns identity into a rule
The verified login — never the U card — becomes the per-request X-Zit-Protect the --fronted child enforces: the globs this pusher does not own, comma-separated as zit's glob lists are.
Sent on EVERY request, empty when nothing is refused. zit#151 reads an absent header as no rule, so absence is exactly what a forgotten code path would produce; here it cannot happen, and a test proves it against a fake child. The proxy builds a fresh request, so a client's header with that name never reaches the child. A rule source that cannot answer fails the push closed.
End to end with a real zit: a non-owner's push to trunk is refused with the branch named; the owner's identical push lands. (user: dmestas, size: 13882) [annotate] [blame] [check-ins using] [more...]
package xfer import ( "net" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" ) 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. f.mu.Lock() b := f.repos["alpha"] b.cmd.Process.Kill() f.mu.Unlock() 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 } func (m *mapAuth) Secret(_, user string) (string, bool) { s, ok := m.users[user] return s, ok } // 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() }) f.mu.Lock() f.repos[name] = &backend{dir: t.TempDir(), port: ln.Addr().(*net.TCPAddr).Port, cmd: sleeper} 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) } // 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 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) } }