git@cosmocr.at/gitweb.git @ HEAD · summary refs log
HEAD / internal / githttp / githttp.go /
internal/githttp/githttp.go · 11.6 KiB · raw
view: Source ☐ force
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
// 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  <repo>/info/refs?service=git-upload-pack   -> ref advertisement
//	GET  <repo>/info/refs?service=git-receive-pack  -> ref advertisement
//	POST <repo>/git-upload-pack                      -> pack negotiation (clone/fetch)
//	POST <repo>/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=<svc>" 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
}