josie / simplegit

package git

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"strconv"
	"strings"
)

// ServeBackend execs `git http-backend` as a CGI for req, translating it
// into the environment http-backend expects: GIT_PROJECT_ROOT is
// projectRoot, PATH_INFO is the request path, and remoteUser (when the
// caller has authenticated someone) is passed as REMOTE_USER. service is
// the single git service the caller has authorized; it becomes
// QUERY_STRING, so the raw request query cannot smuggle in a second
// service that re-routes the CGI. The CGI response — including any Status
// header — is written to w. The returned error only reports exec/pipe
// failures; a 404 from http-backend is a successful response.
func ServeBackend(projectRoot, remoteUser, service string, req *http.Request, w http.ResponseWriter) error {
	query := ""
	if service != "" {
		query = "service=" + url.QueryEscape(service)
	}
	env := []string{
		"GIT_PROJECT_ROOT=" + projectRoot,
		"GIT_HTTP_EXPORT_ALL=1",
		"GATEWAY_INTERFACE=CGI/1.1",
		"SERVER_PROTOCOL=" + req.Proto,
		"REQUEST_METHOD=" + req.Method,
		"PATH_INFO=" + req.URL.Path,
		"QUERY_STRING=" + query,
		"CONTENT_TYPE=" + req.Header.Get("Content-Type"),
		"CONTENT_LENGTH=" + req.Header.Get("Content-Length"),
		"LANG=C",
		"LC_ALL=C",
		"PATH=" + os.Getenv("PATH"),
	}
	if remoteUser != "" {
		env = append(env, "REMOTE_USER="+remoteUser)
	}
	// The post-receive hook needs to find this binary; ServeBackend is the
	// only caller that runs receive-pack, so it passes the path explicitly
	// rather than letting the hook depend on the ambient environment.
	if self, err := os.Executable(); err == nil {
		env = append(env, "SIMPLEGIT_BIN="+self)
	}

	cmd := exec.Command("git", "http-backend")
	cmd.Env = env
	var stderr bytes.Buffer
	cmd.Stderr = &stderr

	// The request body is streamed into http-backend's stdin through an
	// explicit pipe so we can wait for the copy to finish before writing the
	// response. net/http starts a background reader for the request body as
	// soon as the handler writes a response; that reader would race this copy
	// and truncate large pushes (git's receive-pack then waits forever for
	// the missing pack bytes).
	var stdinDone chan error
	if req.Method != http.MethodGet && req.Body != nil {
		pr, pw, err := os.Pipe()
		if err != nil {
			return fmt.Errorf("http-backend stdin pipe: %w", err)
		}
		cmd.Stdin = pr
		stdinDone = make(chan error, 1)
		go func() {
			_, copyErr := io.Copy(pw, req.Body)
			pw.Close()
			stdinDone <- copyErr
		}()
		defer pr.Close()
	}
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return fmt.Errorf("http-backend stdout pipe: %w", err)
	}
	if err := cmd.Start(); err != nil {
		return fmt.Errorf("start http-backend: %w", err)
	}
	if stdinDone != nil {
		if err := <-stdinDone; err != nil {
			cmd.Wait()
			return fmt.Errorf("feed http-backend stdin: %w", err)
		}
	}

	br := bufio.NewReader(stdout)
	status, headers, err := readCGIHeaders(br)
	if err != nil {
		cmd.Wait()
		return fmt.Errorf("read http-backend headers: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	for _, header := range headers {
		if !strings.EqualFold(header[0], "Transfer-Encoding") {
			w.Header().Add(header[0], header[1])
		}
	}
	w.WriteHeader(status)
	if flusher, ok := w.(http.Flusher); ok {
		flusher.Flush()
	}
	if _, err := io.Copy(w, br); err != nil {
		cmd.Wait()
		return fmt.Errorf("stream http-backend body: %w", err)
	}
	if err := cmd.Wait(); err != nil {
		return fmt.Errorf("http-backend: %w: %s", err, strings.TrimSpace(stderr.String()))
	}
	return nil
}

// readCGIHeaders parses the CGI header block, pulling the optional Status
// header out as the HTTP code; everything else is returned verbatim.
func readCGIHeaders(r *bufio.Reader) (int, [][2]string, error) {
	status := http.StatusOK
	var headers [][2]string
	for {
		line, err := r.ReadString('\n')
		line = strings.TrimRight(line, "\r\n")
		if line == "" {
			if err != nil {
				return 0, nil, fmt.Errorf("eof before end of headers: %w", err)
			}
			return status, headers, nil
		}
		key, value, ok := strings.Cut(line, ":")
		if !ok {
			return 0, nil, fmt.Errorf("malformed header line %q", line)
		}
		value = strings.TrimSpace(value)
		if strings.EqualFold(key, "Status") {
			code, _, _ := strings.Cut(value, " ")
			parsed, err := strconv.Atoi(code)
			if err != nil {
				return 0, nil, fmt.Errorf("bad Status header %q", value)
			}
			status = parsed
			continue
		}
		headers = append(headers, [2]string{key, value})
		if err != nil {
			return 0, nil, fmt.Errorf("eof mid-headers: %w", err)
		}
	}
}