package server import ( "bytes" "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "testing" "gitweb/internal/store" ) // gitEnv returns a hermetic environment for invoking git in tests: no prompts, // no system/global config, no credential helpers, and an isolated HOME. func gitEnv(t *testing.T) []string { return append(os.Environ(), "HOME="+t.TempDir(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_ASKPASS=true", "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@e.com", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@e.com", ) } func runGit(t *testing.T, env []string, dir string, args ...string) (string, error) { t.Helper() c := exec.Command("git", args...) c.Dir = dir c.Env = env out, err := c.CombinedOutput() return string(out), err } func mustGit(t *testing.T, env []string, dir string, args ...string) string { t.Helper() out, err := runGit(t, env, dir, args...) if err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) } return out } // makeBareRepo builds a bare repo at ns/ with one commit, and writes // an Access file with the given content beside it. func makeBareRepo(t *testing.T, env []string, ns, repoPath, access string) { t.Helper() work := t.TempDir() mustGit(t, env, work, "init", "-q", "-b", "main") if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# hello\n"), 0o644); err != nil { t.Fatal(err) } mustGit(t, env, work, "add", "-A") mustGit(t, env, work, "commit", "-qm", "initial") dst := filepath.Join(ns, filepath.FromSlash(repoPath)) if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { t.Fatal(err) } mustGit(t, env, work, "clone", "-q", "--bare", work, dst) if err := os.WriteFile(filepath.Join(dst, "Access"), []byte(access), 0o644); err != nil { t.Fatal(err) } } // writeTokens writes a gitweb tokens file granting the given plaintext tokens to // user (by storing their SHA-256 hashes). func writeTokens(t *testing.T, ns, user string, tokens ...string) { t.Helper() dir := filepath.Join(ns, user, "gitweb") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } var b strings.Builder for _, tok := range tokens { h := sha256.Sum256([]byte(tok)) fmt.Fprintf(&b, "%s token\n", hex.EncodeToString(h[:])) } if err := os.WriteFile(filepath.Join(dir, "tokens"), []byte(b.String()), 0o644); err != nil { t.Fatal(err) } } // cloneURL builds an http clone URL, optionally embedding Basic-auth creds. func cloneURL(base, repoPath, user, token string) string { u, _ := url.Parse(base) if user != "" { u.User = url.UserPassword(user, token) } u.Path = "/" + repoPath return u.String() } func TestCloneAndPush(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } env := gitEnv(t) ns := t.TempDir() const owner = "u@d.com" const writer = "w@d.com" const reader = "r@d.com" makeBareRepo(t, env, ns, owner+"/public.git", "read,list: all\n*: "+owner+"\n") makeBareRepo(t, env, ns, owner+"/private.git", "*: "+owner+"\nread,list,write,create,delete: "+writer+"\nread,list: "+reader+"\n") writeTokens(t, ns, writer, "writer-token") writeTokens(t, ns, reader, "reader-token") web := os.DirFS("../../web") cacheDir := t.TempDir() srv, err := New(Config{Store: store.NewLocalStore(ns), Web: http.FS(web), CacheDir: cacheDir}) if err != nil { t.Fatal(err) } ts := httptest.NewServer(srv.Handler()) defer ts.Close() t.Run("anon clone public", func(t *testing.T) { dst := filepath.Join(t.TempDir(), "pub") if out, err := runGit(t, env, ".", "clone", "-q", cloneURL(ts.URL, owner+"/public.git", "", ""), dst); err != nil { t.Fatalf("clone public failed: %v\n%s", err, out) } if _, err := os.Stat(filepath.Join(dst, "README.md")); err != nil { t.Fatalf("cloned repo missing README: %v", err) } }) t.Run("anon clone private denied", func(t *testing.T) { dst := filepath.Join(t.TempDir(), "priv") if out, err := runGit(t, env, ".", "clone", "-q", cloneURL(ts.URL, owner+"/private.git", "", ""), dst); err == nil { t.Fatalf("anonymous clone of private repo succeeded, want failure\n%s", out) } }) t.Run("authed clone private", func(t *testing.T) { dst := filepath.Join(t.TempDir(), "priv-ok") u := cloneURL(ts.URL, owner+"/private.git", writer, "writer-token") if out, err := runGit(t, env, ".", "clone", "-q", u, dst); err != nil { t.Fatalf("authed clone failed: %v\n%s", err, out) } }) t.Run("wrong token denied", func(t *testing.T) { dst := filepath.Join(t.TempDir(), "priv-bad") u := cloneURL(ts.URL, owner+"/private.git", writer, "nope") if out, err := runGit(t, env, ".", "clone", "-q", u, dst); err == nil { t.Fatalf("clone with wrong token succeeded, want failure\n%s", out) } }) t.Run("push as writer", func(t *testing.T) { dst := filepath.Join(t.TempDir(), "push") u := cloneURL(ts.URL, owner+"/private.git", writer, "writer-token") mustGit(t, env, ".", "clone", "-q", u, dst) if err := os.WriteFile(filepath.Join(dst, "new.txt"), []byte("added\n"), 0o644); err != nil { t.Fatal(err) } mustGit(t, env, dst, "add", "-A") mustGit(t, env, dst, "commit", "-qm", "add new.txt") if out, err := runGit(t, env, dst, "push", "-q", "origin", "main"); err != nil { t.Fatalf("push failed: %v\n%s", err, out) } // A fresh clone must see the pushed commit. fresh := filepath.Join(t.TempDir(), "fresh") mustGit(t, env, ".", "clone", "-q", u, fresh) if _, err := os.Stat(filepath.Join(fresh, "new.txt")); err != nil { t.Fatalf("pushed file not visible after re-clone: %v", err) } }) t.Run("incremental push (thin pack)", func(t *testing.T) { dst := filepath.Join(t.TempDir(), "thin") u := cloneURL(ts.URL, owner+"/private.git", writer, "writer-token") mustGit(t, env, ".", "clone", "-q", u, dst) // A file large enough that git deltas the second version against the // first. On the second push that base blob is not in the pack (the // server already has it), so git sends a thin pack with a REF_DELTA — // the case that used to fail with "reference delta not found". lines := strings.Repeat("a line of text that compresses and deltas well\n", 200) if err := os.WriteFile(filepath.Join(dst, "big.txt"), []byte(lines), 0o644); err != nil { t.Fatal(err) } mustGit(t, env, dst, "add", "-A") mustGit(t, env, dst, "commit", "-qm", "add big.txt") mustGit(t, env, dst, "push", "-q", "origin", "main") if err := os.WriteFile(filepath.Join(dst, "big.txt"), []byte(lines+"one changed line\n"), 0o644); err != nil { t.Fatal(err) } mustGit(t, env, dst, "add", "-A") mustGit(t, env, dst, "commit", "-qm", "append to big.txt") if out, err := runGit(t, env, dst, "push", "origin", "main"); err != nil { t.Fatalf("incremental push failed: %v\n%s", err, out) } fresh := filepath.Join(t.TempDir(), "fresh-thin") mustGit(t, env, ".", "clone", "-q", u, fresh) got, err := os.ReadFile(filepath.Join(fresh, "big.txt")) if err != nil { t.Fatal(err) } if string(got) != lines+"one changed line\n" { t.Error("re-clone does not have the incrementally pushed content") } }) t.Run("push branch create and delete", func(t *testing.T) { dst := filepath.Join(t.TempDir(), "branch") u := cloneURL(ts.URL, owner+"/private.git", writer, "writer-token") mustGit(t, env, ".", "clone", "-q", u, dst) mustGit(t, env, dst, "push", "-q", "origin", "main:refs/heads/scratch") // Deleting a ref sends no packfile at all; the server must not choke // on the empty pack stream. if out, err := runGit(t, env, dst, "push", "origin", ":refs/heads/scratch"); err != nil { t.Fatalf("delete push failed: %v\n%s", err, out) } refs := mustGit(t, env, dst, "ls-remote", "origin") if strings.Contains(refs, "scratch") { t.Errorf("scratch branch still on remote:\n%s", refs) } }) // Over http.postBuffer, git first sends a probe POST whose body is just a // flush-pkt ("0000") and only streams the real (chunked) pack after the // probe gets a 200. Answering the probe with 400 used to fail every push // over ~1 MiB (docs/FR-large-push-400.md). t.Run("large push over http.postBuffer", func(t *testing.T) { dst := filepath.Join(t.TempDir(), "large") u := cloneURL(ts.URL, owner+"/private.git", writer, "writer-token") mustGit(t, env, ".", "clone", "-q", u, dst) payload := make([]byte, 3<<20) // incompressible, so the wire size stays > 1 MiB if _, err := rand.Read(payload); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dst, "payload.bin"), payload, 0o644); err != nil { t.Fatal(err) } mustGit(t, env, dst, "add", "-A") mustGit(t, env, dst, "commit", "-qm", "add 3 MiB payload") // Pin git's default postBuffer (1 MiB) so the test doesn't depend on // the environment. if out, err := runGit(t, env, dst, "-c", "http.postBuffer=1048576", "push", "origin", "main"); err != nil { t.Fatalf("large push failed: %v\n%s", err, out) } fresh := filepath.Join(t.TempDir(), "fresh-large") mustGit(t, env, ".", "clone", "-q", u, fresh) got, err := os.ReadFile(filepath.Join(fresh, "payload.bin")) if err != nil || !bytes.Equal(got, payload) { t.Fatalf("re-clone lacks the large payload (err=%v, %d bytes)", err, len(got)) } }) t.Run("large push as reader denied", func(t *testing.T) { // The probe must still be authorized: a reader gets refused at the // probe, before any pack is sent. dst := filepath.Join(t.TempDir(), "large-ro") u := cloneURL(ts.URL, owner+"/private.git", reader, "reader-token") mustGit(t, env, ".", "clone", "-q", u, dst) payload := make([]byte, 2<<20) rand.Read(payload) if err := os.WriteFile(filepath.Join(dst, "ro.bin"), payload, 0o644); err != nil { t.Fatal(err) } mustGit(t, env, dst, "add", "-A") mustGit(t, env, dst, "commit", "-qm", "reader large") if out, err := runGit(t, env, dst, "-c", "http.postBuffer=1048576", "push", "origin", "main"); err == nil { t.Fatalf("reader large push succeeded, want failure\n%s", out) } }) t.Run("push copies cleaned up", func(t *testing.T) { // Every push materializes a private push-* dir under the cache root and // must remove it when done. children, err := os.ReadDir(cacheDir) if err != nil { t.Fatal(err) } for _, ch := range children { if strings.HasPrefix(ch.Name(), "push-") { t.Errorf("leftover push dir %s in cache root", ch.Name()) } } }) t.Run("push as reader denied", func(t *testing.T) { dst := filepath.Join(t.TempDir(), "push-ro") u := cloneURL(ts.URL, owner+"/private.git", reader, "reader-token") mustGit(t, env, ".", "clone", "-q", u, dst) if err := os.WriteFile(filepath.Join(dst, "sneaky.txt"), []byte("x\n"), 0o644); err != nil { t.Fatal(err) } mustGit(t, env, dst, "add", "-A") mustGit(t, env, dst, "commit", "-qm", "sneak") if out, err := runGit(t, env, dst, "push", "-q", "origin", "main"); err == nil { t.Fatalf("reader push succeeded, want 403 failure\n%s", out) } }) }