// Package githttp implements the git "smart HTTP" wire protocol on top of // go-git's server transport sessions. go-git ships the upload-pack/receive-pack // session logic and the pkt-line/packp encoders, but not a net/http handler, so // this package supplies the framing that stock `git` expects: // // GET /info/refs?service=git-upload-pack -> ref advertisement // GET /info/refs?service=git-receive-pack -> ref advertisement // POST /git-upload-pack -> pack negotiation (clone/fetch) // POST /git-receive-pack -> ref updates (push) // // Only protocol v0 is implemented. A client that sends "Git-Protocol: version=2" // gets no v2 response and transparently falls back to v0. go-git's server does // not support shallow (--depth) clones. package githttp import ( "bytes" "compress/gzip" "context" "errors" "fmt" "io" "net/http" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/format/pktline" "github.com/go-git/go-git/v5/plumbing/protocol/packp" "github.com/go-git/go-git/v5/plumbing/storer" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/server" ) // maxBody bounds a POST body (the packfile for push, or the negotiation for // fetch) to guard against a malicious or runaway client. const maxBody = 256 << 20 // 256 MiB // Service identifies a git smart-HTTP service. type Service string const ( UploadPack Service = "git-upload-pack" // clone / fetch ReceivePack Service = "git-receive-pack" // push ) // fixedLoader is a go-git transport loader that always serves one storer, // ignoring the requested endpoint. gitweb resolves the repo before dispatch, so // each request already has its storer. type fixedLoader struct{ s storer.Storer } func (l fixedLoader) Load(*transport.Endpoint) (storer.Storer, error) { return l.s, nil } func newUploadSession(s storer.Storer) (transport.UploadPackSession, error) { return server.NewServer(fixedLoader{s}).NewUploadPackSession(&transport.Endpoint{}, nil) } // NewReceivePackSession returns a receive-pack session bound to s. It is // exported so the push write-back path (which must run the session against a // private, writable storer) can reuse the same construction. func NewReceivePackSession(s storer.Storer) (transport.ReceivePackSession, error) { return server.NewServer(fixedLoader{s}).NewReceivePackSession(&transport.Endpoint{}, nil) } // InfoRefs writes the ref advertisement for svc over s, in smart-HTTP form: // the "# service=" banner and a flush, followed by the pkt-line ref list // (with capabilities). The caller must not have written a body yet. func InfoRefs(w http.ResponseWriter, s storer.Storer, svc Service) error { var ar *packp.AdvRefs var err error switch svc { case UploadPack: sess, e := newUploadSession(s) if e != nil { return e } defer sess.Close() ar, err = sess.AdvertisedReferencesContext(context.Background()) case ReceivePack: sess, e := NewReceivePackSession(s) if e != nil { return e } defer sess.Close() ar, err = sess.AdvertisedReferencesContext(context.Background()) default: return fmt.Errorf("githttp: unknown service %q", svc) } if err != nil { return err } w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", svc)) w.Header().Set("Cache-Control", "no-cache") enc := pktline.NewEncoder(w) if err := enc.Encodef("# service=%s\n", svc); err != nil { return err } if err := enc.Flush(); err != nil { return err } return ar.Encode(w) } // negotiation is the tail of an upload-pack request: the "have" lines the // client is offering, and whether it closed the request with "done". type negotiation struct { haves []plumbing.Hash done bool } // decodeNegotiation reads the have/done section that follows the upload // request. UploadRequest.Decode stops at the flush-pkt ending the want section, // so this part of the body is still unread; without it req.Haves stays empty // and every fetch re-sends the full history. func decodeNegotiation(r io.Reader) (negotiation, error) { var n negotiation sc := pktline.NewScanner(r) for sc.Scan() { line := bytes.TrimSuffix(sc.Bytes(), []byte("\n")) switch { case len(line) == 0: // flush-pkt: end of this batch of haves. case bytes.Equal(line, []byte("done")): n.done = true return n, nil case bytes.HasPrefix(line, []byte("have ")): text := string(bytes.TrimPrefix(line, []byte("have "))) h := plumbing.NewHash(text) if h.IsZero() { return n, fmt.Errorf("malformed have line %q", line) } n.haves = append(n.haves, h) } } return n, sc.Err() } // ServeUploadPack handles a POST git-upload-pack: it decodes the client's // negotiation from the (possibly gzip-compressed) body, runs upload-pack over // s, and streams the packfile response. // // Over stateless HTTP the client negotiates across several requests. It offers // its haves in batches — the first holds 16 — and ends a batch that is not its // last with a flush-pkt and no "done". In that round it is reading ACK/NAK // pkt-lines to decide what to offer next, so a packfile must not be sent: the // client would read the literal "PACK" where a pkt-line length belongs and fail // with "protocol error: bad line length character: PACK". We answer such a // round with a bare NAK; the commit we share is acknowledged only once the // client sends "done", which is also when the pack goes out. func ServeUploadPack(w http.ResponseWriter, r *http.Request, s storer.Storer) error { body, err := requestBody(r) if err != nil { return err } defer body.Close() req := packp.NewUploadPackRequest() if err := req.Decode(body); err != nil { return err } neg, err := decodeNegotiation(body) if err != nil { return err } // Only the haves we actually hold may be passed on. The session walks them // to build the exclusion set and fails on any object it cannot find, and a // client with unpushed local commits legitimately offers some we lack. // Excluding everything reachable from the rest is what keeps a fetch from // re-sending history the client already has. common := commonHaves(s, neg.haves) req.Haves = common if !neg.done { // The client is still negotiating, so no packfile yet -- but the answer // must be NAK even when we do share a commit. We advertise no multi_ack, // and in that mode a plain ACK does not mean "noted": it means // negotiation is over and the pack follows. git acts on it immediately, // sending a bare "done" request carrying no haves and reading that // response as a raw packfile, so an ACK here costs both the negotiated // haves and the fetch, which fails with "protocol error: bad pack // header". The common base is acknowledged on the "done" round instead. setUploadPackHeaders(w) return pktline.NewEncoder(w).Encode([]byte("NAK\n")) } sess, err := newUploadSession(s) if err != nil { return err } defer sess.Close() resp, err := sess.UploadPack(r.Context(), req) if err != nil { return err } // The client has finished negotiating, so this ACK names the base the pack // was cut against rather than cutting negotiation short. Without it // find_common returns "no common commits" and git warns on every fetch. if len(common) > 0 { resp.ACKs = []plumbing.Hash{common[0]} } setUploadPackHeaders(w) return resp.Encode(w) } // commonHaves returns the haves present in s, in the order the client offered // them — git sends its newest commits first, so the first entry is the most // recent commit the two sides share. func commonHaves(s storer.Storer, haves []plumbing.Hash) []plumbing.Hash { var common []plumbing.Hash for _, h := range haves { if _, err := s.EncodedObject(plumbing.AnyObject, h); err == nil { common = append(common, h) } } return common } func setUploadPackHeaders(w http.ResponseWriter) { w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", UploadPack)) w.Header().Set("Cache-Control", "no-cache") } // ErrProbe is returned by DecodeReceivePack for git's probe request: a POST // whose whole body is a flush-pkt ("0000"). git sends it before a request too // large for http.postBuffer, to check that the endpoint works (and that it is // authorized) before streaming the real body chunked. It must be answered // with 200 and an empty result, or git abandons the push. var ErrProbe = errors.New("receive-pack probe") // DecodeReceivePack parses a POST git-receive-pack body into a reference-update // request. The returned request's Packfile is a streaming reader over the rest // of the body; the caller runs the receive-pack session (see the push package) // and then calls WriteReceivePackResult. It returns ErrProbe for git's // flush-only probe request. func DecodeReceivePack(r *http.Request) (*packp.ReferenceUpdateRequest, error) { body, err := requestBody(r) if err != nil { return nil, err } // The first pkt-line is always sent in full before the client waits for // anything, so reading its 4-byte length can't block. A real push starts // with a command; only the probe starts with a flush. var head [4]byte if _, err := io.ReadFull(body, head[:]); err != nil { body.Close() return nil, fmt.Errorf("read receive-pack request: %w", err) } if string(head[:]) == "0000" { body.Close() return nil, ErrProbe } body = prefixedBody{Reader: io.MultiReader(bytes.NewReader(head[:]), body), Closer: body} // Note: body is not closed here; req.Packfile streams from it and is closed // by the receive-pack session. req := packp.NewReferenceUpdateRequest() if err := req.Decode(body); err != nil { body.Close() return nil, err } // A pack follows the commands only when at least one ref is created or // updated; a delete-only push sends none (same rule as stock // receive-pack). Decode leaves the body reader in req.Packfile regardless, // and go-git's session unpacks whenever Packfile is non-nil, failing on // the packless stream ("empty packfile") — so nil it out. Detection must // come from the commands, not from probing the body past them: a read // would block, because the client transmits nothing further until it sees // our response. needPack := false for _, cmd := range req.Commands { if cmd.Action() != packp.Delete { needPack = true break } } if !needPack { body.Close() req.Packfile = nil } return req, nil } // WriteReceivePackResult writes the report-status for a completed push. A nil // status (client did not request report-status) writes nothing. func WriteReceivePackResult(w http.ResponseWriter, rs *packp.ReportStatus) error { w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", ReceivePack)) w.Header().Set("Cache-Control", "no-cache") if rs == nil { return nil } return rs.Encode(w) } // requestBody returns the request body, transparently decompressing it when the // client sent Content-Encoding: gzip (stock git gzips larger request bodies), // and capping the amount read. func requestBody(r *http.Request) (io.ReadCloser, error) { var body io.ReadCloser = http.MaxBytesReader(nil, r.Body, maxBody) if r.Header.Get("Content-Encoding") == "gzip" { zr, err := gzip.NewReader(body) if err != nil { body.Close() return nil, err } return gzipBody{zr: zr, under: body}, nil } return body, nil } // prefixedBody puts bytes already read back in front of a body, keeping the // original's Close. type prefixedBody struct { io.Reader io.Closer } // gzipBody couples a gzip reader with the underlying capped body so both are // closed together. type gzipBody struct { zr *gzip.Reader under io.ReadCloser } func (g gzipBody) Read(p []byte) (int, error) { return g.zr.Read(p) } func (g gzipBody) Close() error { err := g.zr.Close() if cerr := g.under.Close(); err == nil { err = cerr } return err }