gitmote

gitmote / internal / githttp / backend.go  ·  history  ·  branches

12338 bytes · raw

// Package githttp serves git smart-HTTP. It materializes the target repo (refs
// from s3lite, objects hydrated from the object store) and delegates protocol
// work — ref advertisement, pack negotiation, receive-pack — to stock
// `git http-backend` over CGI, per the request flows in
// docs/architecture/request-flows.md.
//
// The read path (clone / fetch, upload-pack) needs only a materialized repo.
// The write path (push, receive-pack) additionally serializes per repo, mints a
// per-push nonce, and installs the pre-receive hook that RPCs the parent to
// enforce the content-before-pointer CAS; that machinery lives in the Writer
// (receive.go) and is engaged only when the handler is built with one.
package githttp

import (
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/http/cgi"
	"os"
	"os/exec"
	"strings"

	"github.com/atmin/gitmote/internal/auth"
	"github.com/atmin/gitmote/internal/meta"
	"github.com/atmin/gitmote/internal/repo"
)

// Authorizer guards a request before it is served: it authenticates the PAT and
// checks the user holds perm on repoName, returning auth's error sentinels
// (ErrUnauthorized / ErrForbidden), meta.ErrNotFound, or an internal error.
type Authorizer interface {
	Authorize(r *http.Request, repoName string, perm meta.Perm) (*meta.User, error)
}

// Config assembles a Handler.
type Config struct {
	// Materializer builds the on-disk repo git-http-backend serves. Required.
	Materializer *repo.Materializer
	// Authorizer guards every request. Required.
	Authorizer Authorizer
	// Writer enables the push path. Nil serves the read path only; write
	// endpoints then 404.
	Writer *Writer
	// IsWritable reports whether this instance may serve writes — it holds the
	// litestream lease (see meta.Metadata.IsLeader). Nil means always writable
	// (unreplicated / tests). A follower refuses receive-pack with a retryable
	// 503; reads are unaffected.
	IsWritable func() bool
	// Logger is optional; nil discards.
	Logger *slog.Logger
}

// Handler serves the git smart-HTTP endpoints for any repo, dispatching on the
// git URL suffix. Mount it at "/"; more specific routes (e.g. /healthz) still
// win under http.ServeMux.
type Handler struct {
	mz         *repo.Materializer
	authz      Authorizer
	writer     *Writer
	isWritable func() bool
	gitPath    string
	logger     *slog.Logger
}

// New returns a handler. It fails if the `git` executable is not on PATH, since
// the whole design delegates to it.
func New(cfg Config) (*Handler, error) {
	gitPath, err := exec.LookPath("git")
	if err != nil {
		return nil, fmt.Errorf("githttp: git not found on PATH: %w", err)
	}
	logger := cfg.Logger
	if logger == nil {
		logger = slog.New(slog.DiscardHandler)
	}
	return &Handler{
		mz:         cfg.Materializer,
		authz:      cfg.Authorizer,
		writer:     cfg.Writer,
		isWritable: cfg.IsWritable,
		gitPath:    gitPath,
		logger:     logger,
	}, nil
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	repoName, endpoint, ok := parseGitPath(r.URL.Path)
	if !ok {
		http.NotFound(w, r)
		return
	}
	perm, isReceive, isPush, ok := classify(endpoint, r)
	if !ok {
		http.NotFound(w, r)
		return
	}
	// Writes are served only when a Writer is configured.
	if isReceive && h.writer == nil {
		http.NotFound(w, r)
		return
	}
	// Only the lease-holding writer serves receive-pack; a read-only follower
	// refuses pushes (safety.md §1) with a retryable 503 so the client can try
	// again once the leader is up. This gates both the advertisement and the
	// push POST. Reads (upload-pack) are served in either role.
	if isReceive && h.isWritable != nil && !h.isWritable() {
		w.Header().Set("Retry-After", "5")
		http.Error(w, "gitmote: not the current writer, retry shortly", http.StatusServiceUnavailable)
		return
	}

	user, err := h.authz.Authorize(r, repoName, perm)
	if err != nil {
		h.writeAuthError(w, r, repoName, err)
		return
	}

	if isPush {
		h.serveReceivePack(w, r, repoName, user.ID)
		return
	}

	// Read, or a receive-pack advertisement: materialize then hand off to
	// http-backend. The receive advertisement needs http.receivepack enabled.
	//
	// The info/refs advertisement only lists refs, so it materializes refs
	// alone — no object hydration (task 13 / docs/notes/object-hydration.md).
	// The upload-pack POST that transfers history still full-hydrates.
	materialized := h.materialize
	if endpoint == "info/refs" {
		materialized = h.materializeRefs
	}
	if !materialized(w, r, repoName) {
		return
	}
	var extra []string
	if isReceive {
		extra = gitConfigEnv([2]string{"http.receivepack", "true"})
	}
	h.serveCGI(w, r, extra)
}

// serveReceivePack runs a push under the per-repo write lock: refresh the repo
// from the sources of truth, mint a nonce, and hand off to receive-pack with the
// pre-receive hook wired to call back on the socket. The hook's callback does
// the object PUT + ref CAS (see Writer.handle).
func (h *Handler) serveReceivePack(w http.ResponseWriter, r *http.Request, repoName string, pusherID int64) {
	push, err := h.writer.Begin(r.Context(), repoName, pusherID)
	if err != nil {
		if errors.Is(err, meta.ErrNotFound) {
			http.NotFound(w, r)
			return
		}
		h.logger.Error("begin push failed", "repo", repoName, "error", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	defer push.Release()

	// Materialize under the lock so receive-pack's fast-forward check runs
	// against the authoritative tip.
	if !h.materialize(w, r, repoName) {
		return
	}

	extra := append(
		gitConfigEnv(
			[2]string{"http.receivepack", "true"},
			[2]string{"core.hooksPath", h.writer.HooksPath()},
		),
		"GITMOTE_SOCK="+h.writer.SockPath(),
		"GITMOTE_NONCE="+push.Nonce,
	)
	h.serveCGI(w, r, extra)

	// receive-pack has finished (response flushed, on-disk refs updated). Fire the
	// after-commit hook now, so a CI dispatch reads a warm, consistent repo — never
	// racing receive-pack's ref finalization. Fire-and-forget: it never fails the
	// already-committed push.
	h.writer.FireAfterCommit(pusherID, push.Committed())
}

// materialize builds the on-disk repo, writing the HTTP error itself on failure
// and returning false so the caller stops.
func (h *Handler) materialize(w http.ResponseWriter, r *http.Request, repoName string) bool {
	_, err := h.mz.Materialize(r.Context(), repoName)
	return h.finishMaterialize(w, r, repoName, err)
}

// materializeRefs makes the repo's ref advertisement current without hydrating
// objects — the info/refs path. It has the same signature and error handling as
// materialize.
func (h *Handler) materializeRefs(w http.ResponseWriter, r *http.Request, repoName string) bool {
	_, err := h.mz.MaterializeRefs(r.Context(), repoName)
	return h.finishMaterialize(w, r, repoName, err)
}

// finishMaterialize maps a materialize error to the HTTP response, writing a 404
// for an unknown repo and a 500 otherwise, and returns whether it succeeded.
func (h *Handler) finishMaterialize(w http.ResponseWriter, r *http.Request, repoName string, err error) bool {
	if err != nil {
		if errors.Is(err, meta.ErrNotFound) {
			http.NotFound(w, r)
			return false
		}
		h.logger.Error("materialize failed", "repo", repoName, "error", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return false
	}
	return true
}

// serveCGI delegates to git-http-backend. PATH_INFO carries the full request
// path (empty CGI Root) and GIT_PROJECT_ROOT is the cache root, so http-backend
// resolves the repo dir the materializer just wrote.
func (h *Handler) serveCGI(w http.ResponseWriter, r *http.Request, extraEnv []string) {
	// git http-backend runs via CGI and needs CONTENT_LENGTH. A request with an
	// unknown length — chunked transfer-encoding, which git uses for a pack larger
	// than the client's http.postBuffer (default 1 MiB) — carries none, and
	// http-backend rejects it with 400. Buffer such a body to a temp file to give
	// it a fixed length before handing off, so a large push works with no client
	// http.postBuffer tuning. Bodies with a known length stream through unchanged.
	if r.Body != nil && r.ContentLength < 0 {
		cleanup, err := bufferBodyToFile(r)
		if err != nil {
			h.logger.Error("buffering request body", "error", err)
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		defer cleanup()
	}

	env := append([]string{
		"GIT_PROJECT_ROOT=" + h.mz.Root(),
		"GIT_HTTP_EXPORT_ALL=1",
	}, extraEnv...)
	backend := &cgi.Handler{
		Path: h.gitPath,
		Args: []string{"http-backend"},
		Dir:  h.mz.Root(),
		Env:  env,
	}
	backend.ServeHTTP(w, r)
}

// bufferBodyToFile drains r.Body to a temp file and rewires the request to read
// from it with a known ContentLength, turning a length-unknown (chunked) body
// into a fixed-length one for the CGI child. It returns a cleanup that closes and
// removes the file after the response is served. Spilling to disk (not memory)
// keeps a large pack off the heap.
func bufferBodyToFile(r *http.Request) (func(), error) {
	f, err := os.CreateTemp("", "gitmote-body-*")
	if err != nil {
		return nil, err
	}
	cleanup := func() { _ = f.Close(); _ = os.Remove(f.Name()) }
	n, err := io.Copy(f, r.Body)
	_ = r.Body.Close()
	if err != nil {
		cleanup()
		return nil, err
	}
	if _, err := f.Seek(0, io.SeekStart); err != nil {
		cleanup()
		return nil, err
	}
	r.Body = f
	r.ContentLength = n
	r.TransferEncoding = nil
	return cleanup, nil
}

// writeAuthError maps an Authorize error to the HTTP response git expects: a
// 401 carries the Basic challenge so git (and its credential helpers) retry
// with credentials; a 403 is a hard stop; a missing repo is a 404.
func (h *Handler) writeAuthError(w http.ResponseWriter, r *http.Request, repoName string, err error) {
	switch {
	case errors.Is(err, auth.ErrForbidden):
		http.Error(w, "forbidden", http.StatusForbidden)
	case errors.Is(err, auth.ErrUnauthorized):
		w.Header().Set("WWW-Authenticate", `Basic realm="gitmote"`)
		http.Error(w, "unauthorized", http.StatusUnauthorized)
	case errors.Is(err, meta.ErrNotFound):
		http.NotFound(w, r)
	default:
		h.logger.Error("authorize failed", "repo", repoName, "error", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
	}
}

// classify determines, for a parsed endpoint and request, the required
// permission and whether it targets receive-pack (isReceive) and mutates
// (isPush). ok=false rejects a wrong method or unknown service.
func classify(endpoint string, r *http.Request) (perm meta.Perm, isReceive, isPush, ok bool) {
	switch endpoint {
	case "info/refs":
		if r.Method != http.MethodGet {
			return "", false, false, false
		}
		switch r.URL.Query().Get("service") {
		case "git-upload-pack":
			return meta.PermRead, false, false, true
		case "git-receive-pack":
			return meta.PermWrite, true, false, true
		default:
			return "", false, false, false
		}
	case "git-upload-pack":
		if r.Method != http.MethodPost {
			return "", false, false, false
		}
		return meta.PermRead, false, false, true
	case "git-receive-pack":
		if r.Method != http.MethodPost {
			return "", false, false, false
		}
		return meta.PermWrite, true, true, true
	default:
		return "", false, false, false
	}
}

// parseGitPath splits a git smart-HTTP URL into the repo name and the endpoint
// suffix ("info/refs", "git-upload-pack", or "git-receive-pack"). The repo name
// — which may contain slashes ("atmin/dotfiles") — must be non-empty.
func parseGitPath(urlPath string) (repoName, endpoint string, ok bool) {
	p := strings.TrimPrefix(urlPath, "/")
	for _, ep := range []string{"info/refs", "git-upload-pack", "git-receive-pack"} {
		if suffix := "/" + ep; strings.HasSuffix(p, suffix) {
			name := strings.TrimSuffix(p, suffix)
			if name == "" {
				return "", "", false
			}
			return name, ep, true
		}
	}
	return "", "", false
}

// gitConfigEnv renders config overrides as the GIT_CONFIG_COUNT/KEY/VALUE
// environment git honors in every process, so they reach the spawned
// receive-pack (and its hooks).
func gitConfigEnv(pairs ...[2]string) []string {
	env := []string{fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(pairs))}
	for i, p := range pairs {
		env = append(env,
			fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, p[0]),
			fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, p[1]),
		)
	}
	return env
}