mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
A pkg:git version can now be a tag as well as a full commit ID. With a version, the submodules the commit records are mirrored at the commits it pins, from the upstream.git hosts serving them; one no host serves is skipped with a warning.
341 lines
12 KiB
Go
341 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cgi"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/proxy/internal/config"
|
|
"github.com/git-pkgs/proxy/internal/gitcache"
|
|
)
|
|
|
|
func TestParseGitRequest(t *testing.T) {
|
|
h := NewGitHandler(nil, nil, config.UpstreamConfig{Git: map[string]config.GitUpstream{
|
|
"up": {URL: "https://git.example/r/"},
|
|
"limited": {URL: "https://git.example", Repos: []string{"allowed/*"}},
|
|
}})
|
|
up, limited := h.upstreams["up"].dir, h.upstreams["limited"].dir
|
|
tests := []struct {
|
|
method, target string
|
|
status int
|
|
key, repoURL string
|
|
}{
|
|
{"GET", "/up/repo.git/info/refs?service=git-upload-pack", 0, up + "/repo.git", "https://git.example/r/repo.git"},
|
|
{"GET", "/up/a/b/info/refs?service=git-upload-pack", 0, up + "/a/b.git", "https://git.example/r/a/b"},
|
|
{"POST", "/up/a/b%20c/git-upload-pack", 0, up + "/a/b c.git", "https://git.example/r/a/b%20c"},
|
|
{"GET", "/up/a.git/b/info/refs?service=git-upload-pack", http.StatusNotFound, "", ""},
|
|
{"GET", "/up/a/../b/info/refs?service=git-upload-pack", http.StatusNotFound, "", ""},
|
|
{"GET", "/up/a//b/info/refs?service=git-upload-pack", http.StatusNotFound, "", ""},
|
|
{"GET", "/up/.git/info/refs?service=git-upload-pack", http.StatusNotFound, "", ""},
|
|
{"GET", "/other/repo/info/refs?service=git-upload-pack", http.StatusNotFound, "", ""},
|
|
{"GET", "/up/info/refs?service=git-upload-pack", http.StatusNotFound, "", ""},
|
|
{"GET", "/up/repo/info/refs?service=git-receive-pack", http.StatusForbidden, "", ""},
|
|
{"POST", "/up/repo/git-receive-pack", http.StatusForbidden, "", ""},
|
|
{"GET", "/up/repo/info/refs", http.StatusForbidden, "", ""},
|
|
{"GET", "/up/repo/objects/info/packs", http.StatusNotFound, "", ""},
|
|
{"POST", "/up/repo/info/refs?service=git-upload-pack", http.StatusMethodNotAllowed, "", ""},
|
|
{"GET", "/up/repo/git-upload-pack", http.StatusMethodNotAllowed, "", ""},
|
|
{"GET", "/limited/allowed/repo/info/refs?service=git-upload-pack", 0, limited + "/allowed/repo.git", "https://git.example/allowed/repo"},
|
|
{"GET", "/limited/other/repo/info/refs?service=git-upload-pack", http.StatusNotFound, "", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.method+" "+tt.target, func(t *testing.T) {
|
|
r := httptest.NewRequest(tt.method, tt.target, nil)
|
|
r.Header.Set("Content-Type", gitRequestType)
|
|
req, status, _ := h.parse(r)
|
|
if status != tt.status {
|
|
t.Fatalf("status %d, want %d", status, tt.status)
|
|
}
|
|
if status == 0 && (req.key != tt.key || req.repoURL != tt.repoURL) {
|
|
t.Errorf("key %q, URL %q; want %q, %q", req.key, req.repoURL, tt.key, tt.repoURL)
|
|
}
|
|
})
|
|
}
|
|
|
|
r := httptest.NewRequest(http.MethodPost, "/up/repo/git-upload-pack", nil)
|
|
if _, status, _ := h.parse(r); status != http.StatusUnsupportedMediaType {
|
|
t.Errorf("POST without the request content type: status %d, want 415", status)
|
|
}
|
|
}
|
|
|
|
// TestGitUpstreamCacheDirectory checks the cache directory follows the
|
|
// upstream's URL, so repointing an upstream does not serve the old host's
|
|
// repositories.
|
|
func TestGitUpstreamCacheDirectory(t *testing.T) {
|
|
dir := func(url string) string {
|
|
h := NewGitHandler(nil, nil, config.UpstreamConfig{Git: map[string]config.GitUpstream{"up": {URL: url}}})
|
|
return h.upstreams["up"].dir
|
|
}
|
|
if a, b := dir("https://git.example"), dir("https://other.example"); a == b || !strings.HasPrefix(a, "up-") {
|
|
t.Errorf("directories %q and %q, want distinct ones named after the upstream", a, b)
|
|
}
|
|
if a, b := dir("https://git.example"), dir("https://git.example/"); a != b {
|
|
t.Errorf("a trailing slash changed the directory: %q, %q", a, b)
|
|
}
|
|
}
|
|
|
|
// TestGitFetchByIDOffWithCredentials checks commits are not fetched by ID
|
|
// for an upstream that upstream.auth has credentials for: there a fetch by
|
|
// ID could reach repositories outside its repos allowlist.
|
|
func TestGitFetchByIDOffWithCredentials(t *testing.T) {
|
|
h := NewGitHandler(nil, nil, config.UpstreamConfig{
|
|
Git: map[string]config.GitUpstream{
|
|
"public": {URL: "https://github.com"},
|
|
"private": {URL: "https://git.example", Repos: []string{"team/*"}},
|
|
"scoped": {URL: "https://other.example", Repos: []string{"team/*"}},
|
|
},
|
|
Auth: map[string]config.AuthConfig{
|
|
"https://git.example": {Type: "bearer", Token: "t"},
|
|
"https://other.example/team": {Type: "bearer", Token: "t"},
|
|
},
|
|
})
|
|
for name, want := range map[string]bool{"public": true, "private": false, "scoped": false} {
|
|
if got := h.upstreams[name].fetchWants; got != want {
|
|
t.Errorf("%s: fetchWants = %v, want %v", name, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func runGitCommand(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = []string{
|
|
"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1",
|
|
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
|
|
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
|
|
}
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// gitTestSetup is a GitHandler in front of a cache of one upstream
|
|
// repository, "repo", served by git http-backend.
|
|
type gitTestSetup struct {
|
|
work string
|
|
// root holds the upstream's bare repositories.
|
|
root string
|
|
server *httptest.Server
|
|
// upstream serves the repository, at upstreamURL/repo, and handler is
|
|
// the GitHandler under test.
|
|
upstream *httptest.Server
|
|
upstreamURL string
|
|
handler *GitHandler
|
|
logs *syncLog
|
|
// slow delays every upstream request by half a second.
|
|
slow atomic.Bool
|
|
// active counts the requests the handler is serving.
|
|
active atomic.Int32
|
|
}
|
|
|
|
// newGitTestSetup serves the cache with the given server read timeout and,
|
|
// unless zero, GitHandler.writeStall.
|
|
func newGitTestSetup(t *testing.T, readTimeout, writeStall time.Duration) *gitTestSetup {
|
|
t.Helper()
|
|
execPath := runGitCommand(t, "", "--exec-path")
|
|
backendPath := ""
|
|
for _, name := range []string{"git-http-backend", "git-http-backend.exe"} {
|
|
if path := filepath.Join(execPath, name); fileExists(path) {
|
|
backendPath = path
|
|
}
|
|
}
|
|
if backendPath == "" {
|
|
t.Skip("git http-backend is not installed")
|
|
}
|
|
root := t.TempDir()
|
|
bare := filepath.Join(root, "repo.git")
|
|
runGitCommand(t, "", "init", "--quiet", "--bare", "--initial-branch=main", bare)
|
|
runGitCommand(t, bare, "config", "uploadpack.allowAnySHA1InWant", "true")
|
|
g := &gitTestSetup{work: t.TempDir(), root: root, logs: &syncLog{}}
|
|
runGitCommand(t, "", "clone", "--quiet", bare, g.work)
|
|
runGitCommand(t, g.work, "commit", "--quiet", "--allow-empty", "-m", "first")
|
|
runGitCommand(t, g.work, "push", "--quiet", "origin", "HEAD:main")
|
|
|
|
backend := &cgi.Handler{
|
|
Path: backendPath,
|
|
Env: []string{"PATH=" + os.Getenv("PATH"), "GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"},
|
|
}
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if g.slow.Load() {
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
backend.ServeHTTP(w, r)
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
g.upstream, g.upstreamURL = upstream, upstream.URL
|
|
|
|
logger := slog.New(slog.NewTextHandler(g.logs, nil))
|
|
cache, err := gitcache.New(gitcache.Options{
|
|
CacheDir: t.TempDir(),
|
|
RefreshWait: 5 * time.Second,
|
|
StaleMaxAge: time.Hour,
|
|
Client: &http.Client{},
|
|
Logger: logger,
|
|
Upstreams: map[string]string{"up": gitcache.UpstreamDir("up", upstream.URL)},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = cache.Close() })
|
|
h := NewGitHandler(&Proxy{Logger: logger}, cache, config.UpstreamConfig{Git: map[string]config.GitUpstream{"up": {URL: upstream.URL}}})
|
|
if writeStall > 0 {
|
|
h.writeStall = writeStall
|
|
}
|
|
g.handler = h
|
|
routes := http.StripPrefix("/git", h.Routes())
|
|
g.server = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
g.active.Add(1)
|
|
defer g.active.Add(-1)
|
|
routes.ServeHTTP(w, r)
|
|
}))
|
|
g.server.Config.ReadTimeout = readTimeout
|
|
g.server.Start()
|
|
t.Cleanup(g.server.Close)
|
|
return g
|
|
}
|
|
|
|
func (g *gitTestSetup) url() string {
|
|
return g.server.URL + "/git/up/repo"
|
|
}
|
|
|
|
type syncLog struct {
|
|
mu sync.Mutex
|
|
buf strings.Builder
|
|
}
|
|
|
|
func (l *syncLog) Write(p []byte) (int, error) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.buf.Write(p)
|
|
}
|
|
|
|
func (l *syncLog) String() string {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.buf.String()
|
|
}
|
|
|
|
// TestGitRequestBodyOutlastsServerReadTimeout sends a protocol v0 request
|
|
// whose haves come after a want the cache must fetch first, so upload-pack
|
|
// reads them after the server's read timeout has passed.
|
|
func TestGitRequestBodyOutlastsServerReadTimeout(t *testing.T) {
|
|
g := newGitTestSetup(t, 200*time.Millisecond, 0)
|
|
runGitCommand(t, g.work, "commit", "--quiet", "--allow-empty", "-m", "pull request")
|
|
runGitCommand(t, g.work, "push", "--quiet", "origin", "HEAD:refs/pull/1/head")
|
|
sha := runGitCommand(t, g.work, "rev-parse", "HEAD")
|
|
runGitCommand(t, "", "clone", "--quiet", g.url(), filepath.Join(t.TempDir(), "clone"))
|
|
|
|
var body strings.Builder
|
|
body.WriteString(pktLine("want "+sha+" side-band-64k\n") + "0000")
|
|
for i := range 2000 {
|
|
body.WriteString(pktLine(fmt.Sprintf("have %040x\n", i+1)))
|
|
}
|
|
body.WriteString(pktLine("done\n"))
|
|
|
|
g.slow.Store(true)
|
|
resp, err := http.Post(g.url()+"/git-upload-pack", gitRequestType, strings.NewReader(body.String()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
out, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(out), "PACK") {
|
|
t.Errorf("status %d, no pack in %d bytes: %.200q", resp.StatusCode, len(out), out)
|
|
}
|
|
}
|
|
|
|
// TestGitSlowRequestBodyStartsNothing sends part of a request body and then
|
|
// nothing. The request ends at the server's read timeout, without an
|
|
// upload-pack waiting for the rest.
|
|
func TestGitSlowRequestBodyStartsNothing(t *testing.T) {
|
|
g := newGitTestSetup(t, 200*time.Millisecond, 0)
|
|
runGitCommand(t, "", "clone", "--quiet", g.url(), filepath.Join(t.TempDir(), "clone"))
|
|
|
|
conn, err := net.Dial("tcp", g.server.Listener.Addr().String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
part := pktLine("command=ls-refs\n")
|
|
_, err = fmt.Fprintf(conn, "POST /git/up/repo/git-upload-pack HTTP/1.1\r\nHost: proxy\r\n"+
|
|
"Content-Type: %s\r\nGit-Protocol: version=2\r\nContent-Length: 100\r\n\r\n%s", gitRequestType, part)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
|
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("no answer to a request whose body stopped: %v", err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("status %d, want %d", resp.StatusCode, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// TestGitStalledClientIsDropped fetches a pack larger than the connection
|
|
// buffers and never reads it. The request ends once a write has waited
|
|
// writeStall, rather than holding upload-pack.
|
|
func TestGitStalledClientIsDropped(t *testing.T) {
|
|
g := newGitTestSetup(t, 0, 200*time.Millisecond)
|
|
data := make([]byte, 16<<20)
|
|
_, _ = rand.NewChaCha8([32]byte{}).Read(data)
|
|
if err := os.WriteFile(filepath.Join(g.work, "large"), data, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGitCommand(t, g.work, "add", "large")
|
|
runGitCommand(t, g.work, "commit", "--quiet", "-m", "large")
|
|
runGitCommand(t, g.work, "push", "--quiet", "origin", "HEAD:main")
|
|
sha := runGitCommand(t, g.work, "rev-parse", "HEAD")
|
|
runGitCommand(t, "", "clone", "--quiet", g.url(), filepath.Join(t.TempDir(), "clone"))
|
|
|
|
conn, err := net.Dial("tcp", g.server.Listener.Addr().String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
body := pktLine("want "+sha+" side-band-64k\n") + "0000" + pktLine("done\n")
|
|
_, err = fmt.Fprintf(conn, "POST /git/up/repo/git-upload-pack HTTP/1.1\r\nHost: proxy\r\n"+
|
|
"Content-Type: %s\r\nContent-Length: %d\r\n\r\n%s", gitRequestType, len(body), body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for deadline := time.Now().Add(10 * time.Second); g.active.Load() == 0; {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("request never reached the handler")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
for deadline := time.Now().Add(10 * time.Second); g.active.Load() != 0; {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the handler still serves a client that stopped reading")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func pktLine(payload string) string {
|
|
return fmt.Sprintf("%04x%s", len(payload)+4, payload)
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|