git@cosmocr.at/gitweb.git @ main · summary refs log
main / internal / server / clone_push_test.go /
internal/server/clone_push_test.go · 10.8 KiB · raw
view: SourceMarkdown ☑ force

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)
	}
})

}