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 , flush, have , 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)]) } }