git@cosmocr.at/gitweb.git @ HEAD · summary refs log
HEAD / internal / githttp / githttp.go /
internal/githttp/githttp.go · 11.6 KiB · raw
view: SourceMarkdown ☑ force

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