Skip to content

Commit 2803a0f

Browse files
authored
Stabilize git-upload-pack cache key by normalizing volatile fields (#113)
1 parent e246b95 commit 2803a0f

6 files changed

Lines changed: 560 additions & 1 deletion

File tree

internal/cache/handlers.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"gopkg.in/yaml.v3"
2020

2121
"github.com/dependabot/proxy/internal/ctxdata"
22+
"github.com/dependabot/proxy/internal/gitproto"
2223
)
2324

2425
// DB contains the metadata of the disk cache
@@ -97,8 +98,12 @@ func key(r *http.Request) Key {
9798
k.HeaderHash = hex.EncodeToString(headerHash.Sum(nil))
9899
}
99100
if len(data) > 0 {
101+
hashData := data
102+
if gitproto.IsUploadPackRequest(r) {
103+
hashData = gitproto.NormalizeUploadPackBody(data)
104+
}
100105
hash := sha256.New()
101-
hash.Write(data)
106+
hash.Write(hashData)
102107
k.BodyHash = hex.EncodeToString(hash.Sum(nil))
103108
}
104109
return k

internal/cache/handlers_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,62 @@ func Test_key(t *testing.T) {
245245
t.Error("headerHash should be blank, got", key.HeaderHash)
246246
}
247247
})
248+
249+
// Integration tests for the gitproto hookup. Edge-case behaviour of the
250+
// normalizer itself lives in internal/gitproto.
251+
const upUrl = "https://github.com/octocat/Hello-World.git/git-upload-pack"
252+
const upCT = "application/x-git-upload-pack-request"
253+
mkUpReq := func(url, ct, body string) *http.Request {
254+
r := httptest.NewRequest("POST", url, strings.NewReader(body))
255+
if ct != "" {
256+
r.Header.Set("Content-Type", ct)
257+
}
258+
return r
259+
}
260+
261+
t.Run("git-upload-pack: agent= drift collapses to one key", func(t *testing.T) {
262+
body1 := "0080want 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d multi_ack_detailed no-done side-band-64k thin-pack ofs-delta agent=git/2.43.0\n" +
263+
"0032have 553c2077f0edc3d5dc5d17262f6aa498e69d6f8e\n0009done\n"
264+
body2 := "0080want 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d multi_ack_detailed no-done side-band-64k thin-pack ofs-delta agent=git/2.53.0\n" +
265+
"0032have 553c2077f0edc3d5dc5d17262f6aa498e69d6f8e\n0009done\n"
266+
if key(mkUpReq(upUrl, upCT, body1)) != key(mkUpReq(upUrl, upCT, body2)) {
267+
t.Error("agent-only difference must collapse")
268+
}
269+
})
270+
271+
t.Run("git-upload-pack: different haves hash distinctly", func(t *testing.T) {
272+
body1 := "0032want 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d\n0000" +
273+
"0032have 553c2077f0edc3d5dc5d17262f6aa498e69d6f8e\n0009done\n"
274+
body2 := "0032want 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d\n0000" +
275+
"0032have a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n0009done\n"
276+
if key(mkUpReq(upUrl, upCT, body1)) == key(mkUpReq(upUrl, upCT, body2)) {
277+
t.Error("haves shape the upstream pack and must not collapse")
278+
}
279+
})
280+
281+
t.Run("git-upload-pack: malformed body falls back to raw hashing", func(t *testing.T) {
282+
if key(mkUpReq(upUrl, upCT, "garbage one")) == key(mkUpReq(upUrl, upCT, "garbage two")) {
283+
t.Error("malformed bodies must hash distinctly")
284+
}
285+
})
286+
287+
t.Run("non-git POST is not normalized even with similar substrings", func(t *testing.T) {
288+
const u = "https://api.github.com/graphql"
289+
k1 := key(httptest.NewRequest("POST", u, strings.NewReader(`{"q":"have stuff agent=foo"}`)))
290+
k2 := key(httptest.NewRequest("POST", u, strings.NewReader(`{"q":"have other agent=bar"}`)))
291+
if k1 == k2 {
292+
t.Error("non-git POSTs must not be normalized")
293+
}
294+
})
295+
296+
t.Run("upload-pack path without Content-Type is not normalized", func(t *testing.T) {
297+
const u = "https://example.com/foo/git-upload-pack"
298+
body1 := "0032have 553c2077f0edc3d5dc5d17262f6aa498e69d6f8e\n0009done\n"
299+
body2 := "0032have a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n0009done\n"
300+
if key(mkUpReq(u, "", body1)) == key(mkUpReq(u, "", body2)) {
301+
t.Error("missing Content-Type must skip normalization")
302+
}
303+
})
248304
}
249305

250306
type BufferWithClose struct {

internal/gitproto/pktline.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package gitproto
2+
3+
// pktType is the kind of a single pkt-line: either a data packet or one of
4+
// the three special framing packets defined by git's smart-HTTP protocol.
5+
//
6+
// Each pkt-line on the wire begins with a 4-hex-digit length that includes
7+
// itself, or is one of: "0000" flush, "0001" delim (v2), "0002" response-end.
8+
// Any length >= 4 is a data packet whose payload is (length - 4) bytes.
9+
// See https://git-scm.com/docs/protocol-common#_pkt_line_format.
10+
type pktType int
11+
12+
const (
13+
pktData pktType = iota
14+
pktFlush
15+
pktDelim
16+
pktResponseEnd
17+
)
18+
19+
// packet is one parsed pkt-line. payload is set only when typ == pktData and
20+
// excludes the 4-byte length prefix.
21+
type packet struct {
22+
typ pktType
23+
payload []byte
24+
}
25+
26+
const hexDigits = "0123456789abcdef"
27+
28+
// parseHex4 decodes a 4-byte ASCII hex prefix without allocating a string.
29+
func parseHex4(b []byte) (n int, ok bool) {
30+
for i := 0; i < 4; i++ {
31+
c := b[i]
32+
var v int
33+
switch {
34+
case c >= '0' && c <= '9':
35+
v = int(c - '0')
36+
case c >= 'a' && c <= 'f':
37+
v = int(c-'a') + 10
38+
case c >= 'A' && c <= 'F':
39+
v = int(c-'A') + 10
40+
default:
41+
return 0, false
42+
}
43+
n = n<<4 | v
44+
}
45+
return n, true
46+
}
47+
48+
// parsePktLine returns ok=false on malformed or truncated input so callers
49+
// can fall back to opaque hashing of the original bytes.
50+
func parsePktLine(data []byte) (packets []packet, ok bool) {
51+
for len(data) > 0 {
52+
if len(data) < 4 {
53+
return nil, false
54+
}
55+
n, ok := parseHex4(data[:4])
56+
if !ok {
57+
return nil, false
58+
}
59+
switch n {
60+
case 0:
61+
packets = append(packets, packet{typ: pktFlush})
62+
data = data[4:]
63+
case 1:
64+
packets = append(packets, packet{typ: pktDelim})
65+
data = data[4:]
66+
case 2:
67+
packets = append(packets, packet{typ: pktResponseEnd})
68+
data = data[4:]
69+
case 3:
70+
// Reserved; not used by real git. Treat as malformed.
71+
return nil, false
72+
default:
73+
if n > len(data) {
74+
return nil, false
75+
}
76+
packets = append(packets, packet{typ: pktData, payload: data[4:n]})
77+
data = data[n:]
78+
}
79+
}
80+
return packets, true
81+
}
82+
83+
// encodePktLine recomputes each data packet's length prefix, which is what
84+
// makes normalization stable across payloads of differing length.
85+
func encodePktLine(packets []packet) []byte {
86+
buf := make([]byte, 0, encodedSize(packets))
87+
for _, p := range packets {
88+
switch p.typ {
89+
case pktFlush:
90+
buf = append(buf, "0000"...)
91+
case pktDelim:
92+
buf = append(buf, "0001"...)
93+
case pktResponseEnd:
94+
buf = append(buf, "0002"...)
95+
case pktData:
96+
n := 4 + len(p.payload)
97+
buf = append(buf,
98+
hexDigits[(n>>12)&0xf],
99+
hexDigits[(n>>8)&0xf],
100+
hexDigits[(n>>4)&0xf],
101+
hexDigits[n&0xf],
102+
)
103+
buf = append(buf, p.payload...)
104+
}
105+
}
106+
return buf
107+
}
108+
109+
func encodedSize(packets []packet) int {
110+
size := 0
111+
for _, p := range packets {
112+
size += 4
113+
if p.typ == pktData {
114+
size += len(p.payload)
115+
}
116+
}
117+
return size
118+
}

internal/gitproto/pktline_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package gitproto
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
)
7+
8+
func TestParsePktLine_Empty(t *testing.T) {
9+
pkts, ok := parsePktLine(nil)
10+
if !ok {
11+
t.Error("expected ok=true for empty input")
12+
}
13+
if len(pkts) != 0 {
14+
t.Fatalf("expected 0 packets, got %d", len(pkts))
15+
}
16+
}
17+
18+
func TestParsePktLine_SpecialPackets(t *testing.T) {
19+
cases := map[string]pktType{
20+
"0000": pktFlush,
21+
"0001": pktDelim,
22+
"0002": pktResponseEnd,
23+
}
24+
for input, want := range cases {
25+
pkts, ok := parsePktLine([]byte(input))
26+
if !ok || len(pkts) != 1 || pkts[0].typ != want {
27+
t.Errorf("input %q: got %+v ok=%v, want type %d", input, pkts, ok, want)
28+
}
29+
}
30+
}
31+
32+
func TestParsePktLine_DataPacket(t *testing.T) {
33+
// "000ahello\n" = length 0x000a (10), payload "hello\n"
34+
pkts, ok := parsePktLine([]byte("000ahello\n"))
35+
if !ok || len(pkts) != 1 || pkts[0].typ != pktData || string(pkts[0].payload) != "hello\n" {
36+
t.Errorf("got %+v ok=%v", pkts, ok)
37+
}
38+
}
39+
40+
func TestParsePktLine_MalformedAndTruncated(t *testing.T) {
41+
// Bad hex prefix.
42+
if _, ok := parsePktLine([]byte("gggghi")); ok {
43+
t.Error("expected ok=false for malformed length prefix")
44+
}
45+
// Length claims 0x0020 but only 9 bytes available.
46+
if _, ok := parsePktLine([]byte("0020short")); ok {
47+
t.Error("expected ok=false for truncated packet")
48+
}
49+
// Length 3 is reserved; we treat as malformed.
50+
if _, ok := parsePktLine([]byte("00030000")); ok {
51+
t.Error("expected ok=false for reserved length 3")
52+
}
53+
// Less than 4 bytes.
54+
if _, ok := parsePktLine([]byte("ab")); ok {
55+
t.Error("expected ok=false for sub-prefix input")
56+
}
57+
}
58+
59+
func TestParsePktLine_RealV1Body(t *testing.T) {
60+
// Realistic v1 upload-pack body from github.com/octocat/Hello-World
61+
input := "00a4want 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d multi_ack_detailed no-done side-band-64k thin-pack no-progress ofs-delta deepen-since deepen-not agent=git/2.43.0\n" +
62+
"0032want b1b3f9723831141a31a1a7252a213e216ea76e56\n" +
63+
"0000" +
64+
"0032have 553c2077f0edc3d5dc5d17262f6aa498e69d6f8e\n" +
65+
"0009done\n"
66+
pkts, ok := parsePktLine([]byte(input))
67+
if !ok {
68+
t.Fatal("expected ok=true for well-formed v1 body")
69+
}
70+
wantTypes := []pktType{pktData, pktData, pktFlush, pktData, pktData}
71+
if len(pkts) != len(wantTypes) {
72+
t.Fatalf("got %d packets, want %d", len(pkts), len(wantTypes))
73+
}
74+
for i, want := range wantTypes {
75+
if pkts[i].typ != want {
76+
t.Errorf("packet %d: got type %d, want %d", i, pkts[i].typ, want)
77+
}
78+
}
79+
}
80+
81+
func TestParsePktLine_RealV2Body(t *testing.T) {
82+
input := "0012command=fetch\n" +
83+
"0015agent=git/2.43.0\n" +
84+
"0001" +
85+
"000ddeepen 1\n" +
86+
"0032want 7fd1a60b01f91b314f59955a4e4d4e80d8edf11d\n" +
87+
"0009done\n" +
88+
"0000"
89+
pkts, ok := parsePktLine([]byte(input))
90+
if !ok || len(pkts) != 7 {
91+
t.Fatalf("got %d packets ok=%v, want 7 ok=true", len(pkts), ok)
92+
}
93+
if pkts[2].typ != pktDelim || pkts[6].typ != pktFlush {
94+
t.Error("special packets misidentified")
95+
}
96+
}
97+
98+
func TestEncodePktLine_RoundTrip(t *testing.T) {
99+
input := []byte("000ahello\n" + "0000" + "0001" + "000aworld\n" + "0002")
100+
pkts, ok := parsePktLine(input)
101+
if !ok {
102+
t.Fatal("parse failed on well-formed input")
103+
}
104+
if got := encodePktLine(pkts); !bytes.Equal(got, input) {
105+
t.Errorf("round-trip mismatch:\n in: %q\n out: %q", input, got)
106+
}
107+
}

0 commit comments

Comments
 (0)