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
|
package githttp
import (
"bytes"
"encoding/binary"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/storage/memory"
)
// pktLine encodes s as a pkt-line.
func pktLine(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) }
// twoCommitRepo builds an in-memory repo with two commits and returns its
// storer plus the two hashes, oldest first.
func twoCommitRepo(t *testing.T) (*memory.Storage, plumbing.Hash, plumbing.Hash) {
t.Helper()
st := memory.NewStorage()
repo, err := git.Init(st, memfs.New())
if err != nil {
t.Fatal(err)
}
wt, err := repo.Worktree()
if err != nil {
t.Fatal(err)
}
var hashes []plumbing.Hash
for i := range 2 {
f, err := wt.Filesystem.Create("f.txt")
if err != nil {
t.Fatal(err)
}
fmt.Fprintf(f, "v%d\n", i)
f.Close()
if _, err := wt.Add("f.txt"); err != nil {
t.Fatal(err)
}
h, err := wt.Commit(fmt.Sprintf("commit %d", i), &git.CommitOptions{
Author: &object.Signature{Name: "T", Email: "t@e.com", When: time.Unix(int64(1700000000+i), 0)},
})
if err != nil {
t.Fatal(err)
}
hashes = append(hashes, h)
}
return st, hashes[0], hashes[1]
}
// TestServeUploadPackNegotiationRoundSendsNoPackfile pins the pkt-line contract
// for a negotiation round that the client has not finished.
//
// Over stateless HTTP, git offers its haves in batches: the first batch ends
// with a flush-pkt and no "done", and the client then reads only ACK/NAK
// pkt-lines while it decides what to offer next. Emitting the packfile in that
// round desynchronises the client, which reads the literal bytes "PACK" where a
// 4-hex pkt-line length belongs and aborts with
//
// fatal: protocol error: bad line length character: PACK
//
// The packfile may only follow once the client has sent "done".
func TestServeUploadPackNegotiationRoundSendsNoPackfile(t *testing.T) {
st, first, tip := twoCommitRepo(t)
// want <tip>, flush, have <first>, flush -- deliberately no "done".
body := pktLine("want "+tip.String()+" ofs-delta agent=git/test\n") +
"0000" +
pktLine("have "+first.String()+"\n") +
"0000"
r := httptest.NewRequest("POST", "/x/git-upload-pack", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
if err := ServeUploadPack(rec, r, st); err != nil {
t.Fatalf("ServeUploadPack: %v", err)
}
got := rec.Body.Bytes()
if i := bytes.Index(got, []byte("PACK")); i >= 0 {
t.Errorf("response to an unfinished negotiation round contains a packfile at byte %d; "+
"only ACK/NAK pkt-lines are allowed until the client sends \"done\"\nresponse starts: %q",
i, got[:min(len(got), 32)])
}
}
// TestServeUploadPackDoneSendsPackfile is the companion: once the client says
// "done", the packfile is expected in the same response.
func TestServeUploadPackDoneSendsPackfile(t *testing.T) {
st, _, tip := twoCommitRepo(t)
body := pktLine("want "+tip.String()+" ofs-delta agent=git/test\n") +
"0000" +
pktLine("done\n")
r := httptest.NewRequest("POST", "/x/git-upload-pack", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
if err := ServeUploadPack(rec, r, st); err != nil {
t.Fatalf("ServeUploadPack: %v", err)
}
if !bytes.Contains(rec.Body.Bytes(), []byte("PACK")) {
t.Errorf("response to a finished negotiation has no packfile: %q",
rec.Body.Bytes()[:min(rec.Body.Len(), 32)])
}
}
// packObjectCount returns the object count from the header of the packfile
// embedded in an upload-pack response: "PACK", a 4-byte version, then a 4-byte
// big-endian count.
func packObjectCount(t *testing.T, resp []byte) int {
t.Helper()
i := bytes.Index(resp, []byte("PACK"))
if i < 0 {
t.Fatalf("response has no packfile: %q", resp[:min(len(resp), 32)])
}
if len(resp) < i+12 {
t.Fatalf("truncated packfile header")
}
return int(binary.BigEndian.Uint32(resp[i+8 : i+12]))
}
// TestServeUploadPackHonoursHaves checks that a "have" the server also holds
// trims the packfile. The haves arrive after the flush-pkt that ends the want
// section, which UploadRequest.Decode does not read past; left unparsed they
// never reach the session and every fetch re-sends the whole history.
func TestServeUploadPackHonoursHaves(t *testing.T) {
st, first, tip := twoCommitRepo(t)
post := func(body string) []byte {
t.Helper()
r := httptest.NewRequest("POST", "/x/git-upload-pack", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
if err := ServeUploadPack(rec, r, st); err != nil {
t.Fatalf("ServeUploadPack: %v", err)
}
return rec.Body.Bytes()
}
want := pktLine("want "+tip.String()+" ofs-delta agent=git/test\n") + "0000"
// A client with nothing gets the full history: 2 commits, 2 trees, 2 blobs.
full := packObjectCount(t, post(want+pktLine("done\n")))
if full != 6 {
t.Fatalf("full clone pack = %d objects, want 6", full)
}
// A client that already has the first commit gets only the second's objects.
delta := packObjectCount(t, post(want+pktLine("have "+first.String()+"\n")+pktLine("done\n")))
if delta >= full {
t.Errorf("pack with have %s = %d objects, full = %d: the have was ignored",
first.String()[:8], delta, full)
}
if delta != 3 {
t.Errorf("pack with have = %d objects, want 3 (one commit, tree, blob)", delta)
}
}
// TestServeUploadPackNegotiationRoundNeverAcks pins a subtlety that looks like
// a missed optimisation. We share the commit the client offers here, but the
// round must still answer NAK: we advertise no multi_ack, and in that mode a
// plain ACK means "negotiation over, pack follows". git acts on it at once,
// sending a bare "done" request with no haves and reading that response as a
// raw packfile — so ACKing here throws away every negotiated have and breaks
// the fetch with "protocol error: bad pack header". The ACK belongs on the
// "done" round, which TestServeUploadPackAcksCommonHaveOnDone covers.
func TestServeUploadPackNegotiationRoundNeverAcks(t *testing.T) {
st, first, tip := twoCommitRepo(t)
body := pktLine("want "+tip.String()+" ofs-delta agent=git/test\n") +
"0000" +
pktLine("have "+first.String()+"\n") +
"0000" // no "done"
r := httptest.NewRequest("POST", "/x/git-upload-pack", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
if err := ServeUploadPack(rec, r, st); err != nil {
t.Fatalf("ServeUploadPack: %v", err)
}
if got, want := rec.Body.String(), pktLine("NAK\n"); got != want {
t.Errorf("response = %q, want %q", got, want)
}
}
// TestServeUploadPackAcksCommonHaveOnDone checks the final ACK. git's
// find_common reports "no common commits" — and warns — unless the response to
// the "done" round acknowledges a shared commit.
func TestServeUploadPackAcksCommonHaveOnDone(t *testing.T) {
st, first, tip := twoCommitRepo(t)
body := pktLine("want "+tip.String()+" ofs-delta agent=git/test\n") +
"0000" +
pktLine("have "+first.String()+"\n") +
pktLine("done\n")
r := httptest.NewRequest("POST", "/x/git-upload-pack", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
if err := ServeUploadPack(rec, r, st); err != nil {
t.Fatalf("ServeUploadPack: %v", err)
}
if got, want := rec.Body.String(), pktLine("ACK "+first.String()+"\n"); !strings.HasPrefix(got, want) {
t.Errorf("response = %q..., want ACK prefix %q", got[:min(len(got), 60)], want)
}
}
// TestServeUploadPackUnknownHaveIsIgnored guards the have-filtering. A client
// with local commits it has never pushed offers them as haves; the session
// walks req.Haves to build its exclusion set and returns ErrObjectNotFound for
// any it cannot resolve, which would turn an ordinary fetch into a 500.
func TestServeUploadPackUnknownHaveIsIgnored(t *testing.T) {
st, first, tip := twoCommitRepo(t)
unknown := plumbing.NewHash("1111111111111111111111111111111111111111")
body := pktLine("want "+tip.String()+" ofs-delta agent=git/test\n") +
"0000" +
pktLine("have "+unknown.String()+"\n") +
pktLine("have "+first.String()+"\n") +
pktLine("done\n")
r := httptest.NewRequest("POST", "/x/git-upload-pack", bytes.NewReader([]byte(body)))
rec := httptest.NewRecorder()
if err := ServeUploadPack(rec, r, st); err != nil {
t.Fatalf("ServeUploadPack with an unknown have: %v", err)
}
// The known have must still trim the pack, and be acknowledged.
if n := packObjectCount(t, rec.Body.Bytes()); n != 3 {
t.Errorf("pack = %d objects, want 3: the known have was dropped along with the unknown one", n)
}
if got := rec.Body.String(); !strings.HasPrefix(got, pktLine("ACK "+first.String()+"\n")) {
t.Errorf("response does not start with ACK %s: %q", first, got[:min(len(got), 60)])
}
}
|