package gitserver
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/bluesky-social/indigo/atproto/syntax"
"tangled.org/core/migrator/config"
"tangled.org/core/migrator/db"
)
func testServer(t *testing.T) (*Server, *db.DB, int64) {
t.Helper()
database, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "gitserver.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { database.Close() })
_, jobs, _, err := database.CreateBatch(context.Background(), db.CreateBatchInput{
ID: "batch", OwnerDid: "did:plc:alice", RequestID: "request", RequestDigest: "digest",
Jobs: []db.CreateJobInput{{Name: "repo", KnotDid: "did:web:knot.example", SourceURL: "https://github.com/alice/repo.git"}},
})
if err != nil {
t.Fatal(err)
}
if err := database.UpdateJobStatus(context.Background(), jobs[0].ID, db.StatusImporting, nil); err != nil {
t.Fatal(err)
}
workDir := t.TempDir()
mirror := filepath.Join(workDir, "job-"+strconv.FormatInt(jobs[0].ID, 10), "repo.git")
for _, directory := range []string{"objects/info", "objects/pack", "refs/heads", "refs/tags"} {
if err := os.MkdirAll(filepath.Join(mirror, directory), 0700); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(mirror, "HEAD"), []byte("ref: refs/heads/main\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mirror, "config"), []byte("[core]\nrepositoryformatversion = 0\nbare = true\n"), 0600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{WorkDir: workDir}
cfg.ServiceDid, _ = syntax.ParseDID("did:web:migrator.example")
return New(database, cfg), database, jobs[0].ID
}
func request(server *Server, method, target, contentType string) *httptest.ResponseRecorder {
return requestBody(server, method, target, contentType, "")
}
func requestBody(server *Server, method, target, contentType, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, strings.NewReader(body))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
return rec
}
func TestCapabilityMapsOnlyItsExactRepositoryAndRevokesToNotFound(t *testing.T) {
server, database, jobID := testServer(t)
token := strings.Repeat("a", 43)
if err := database.SetJobCapability(context.Background(), jobID, token); err != nil {
t.Fatal(err)
}
path := "/git/" + token + "/repo.git/info/refs?service=git-upload-pack"
if rec := request(server, http.MethodGet, path, ""); rec.Code != http.StatusOK {
t.Fatalf("active exact capability: got %d", rec.Code)
} else if !strings.HasPrefix(rec.Body.String(), "001e# service=git-upload-pack\n0000") {
t.Fatalf("invalid smart-http advertisement %q", rec.Body.String())
}
v2req := httptest.NewRequest(http.MethodGet, path, nil)
v2req.Header.Set("Git-Protocol", "version=2")
v2rec := httptest.NewRecorder()
server.ServeHTTP(v2rec, v2req)
if !strings.Contains(v2rec.Body.String(), "version 2") {
t.Fatalf("Git-Protocol was not passed to upload-pack: %q", v2rec.Body.String())
}
if rec := request(server, http.MethodGet, "/git/"+token+"/other.git/info/refs?service=git-upload-pack", ""); rec.Code != http.StatusNotFound {
t.Fatalf("wrong repository name: got %d", rec.Code)
}
if err := database.RevokeJobCapability(context.Background(), jobID); err != nil {
t.Fatal(err)
}
if rec := request(server, http.MethodGet, path, ""); rec.Code != http.StatusNotFound {
t.Fatalf("revoked capability: got %d", rec.Code)
}
}
func TestSmartHTTPRejectsTraversalAndUnsupportedOperations(t *testing.T) {
server, database, jobID := testServer(t)
token := strings.Repeat("b", 43)
if err := database.SetJobCapability(context.Background(), jobID, token); err != nil {
t.Fatal(err)
}
paths := []string{
"/git/" + token + "/../repo.git/info/refs?service=git-upload-pack",
"/git/" + token + "/repo.git/HEAD",
"/git/" + token + "/repo.git/info/refs?service=git-receive-pack",
"/git/" + token + "/repo.git/info/refs?service=git-upload-pack&extra=1",
}
for _, path := range paths {
if rec := request(server, http.MethodGet, path, ""); rec.Code != http.StatusNotFound {
t.Fatalf("%s: got %d", path, rec.Code)
}
}
postPath := "/git/" + token + "/repo.git/git-upload-pack"
if rec := request(server, http.MethodPost, postPath, "application/json"); rec.Code != http.StatusNotFound {
t.Fatalf("wrong upload-pack content type: got %d", rec.Code)
}
}
const lfsOID = "ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34"
func stageLFSObject(t *testing.T, server *Server, database *db.DB, jobID int64) (token, objectPath string) {
t.Helper()
token = strings.Repeat("c", 43)
if err := database.SetJobCapability(context.Background(), jobID, token); err != nil {
t.Fatal(err)
}
mirror := filepath.Join(server.cfg.WorkDir, "job-"+strconv.FormatInt(jobID, 10), "repo.git")
objectPath = filepath.Join(mirror, "lfs", "objects", lfsOID[:2], lfsOID[2:4], lfsOID)
if err := os.MkdirAll(filepath.Dir(objectPath), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(objectPath, []byte("lfs media bytes"), 0600); err != nil {
t.Fatal(err)
}
return token, objectPath
}
func lfsBatchBody(objects string) string {
return `{"operation":"download","transfers":["basic"],"objects":[` + objects + `]}`
}
func TestLFSBatchServesTheMirrorAndTheObjectDownloads(t *testing.T) {
server, database, jobID := testServer(t)
token, objectPath := stageLFSObject(t, server, database, jobID)
media, err := os.ReadFile(objectPath)
if err != nil {
t.Fatal(err)
}
present := `{"oid":"` + lfsOID + `","size":` + strconv.Itoa(len(media)) + `}`
absent := `{"oid":"` + strings.Repeat("ef", 32) + `","size":1}`
invalid := `{"oid":"../../etc/passwd","size":1}`
base := "/git/" + token + "/repo.git"
rec := requestBody(server, http.MethodPost, base+"/info/lfs/objects/batch", lfsContentType, lfsBatchBody(present+","+absent+","+invalid))
if rec.Code != http.StatusOK {
t.Fatalf("batch: got %d %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, lfsContentType) {
t.Fatalf("batch content type = %q", got)
}
var payload struct {
Objects []struct {
Oid string `json:"oid"`
Size *int64 `json:"size"`
Actions *struct {
Download *struct {
Href string `json:"href"`
} `json:"download"`
} `json:"actions"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
} `json:"objects"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Objects) != 3 {
t.Fatalf("batch answered %d objects: %s", len(payload.Objects), rec.Body.String())
}
found := payload.Objects[0]
if found.Oid != lfsOID || found.Error != nil || found.Actions == nil || found.Actions.Download == nil {
t.Fatalf("present object answered wrong: %+v", found)
}
if found.Size == nil || *found.Size != int64(len(media)) {
t.Fatalf("present object size = %v, want %d (a knot batch parser requires size on every object)", found.Size, len(media))
}
wantHref := "https://migrator.example" + base + "/info/lfs/objects/" + lfsOID
if found.Actions.Download.Href != wantHref {
t.Fatalf("download href = %q, want %q", found.Actions.Download.Href, wantHref)
}
missing := payload.Objects[1]
if missing.Oid != strings.Repeat("ef", 32) || missing.Error == nil || missing.Error.Code != http.StatusNotFound {
t.Fatalf("absent object answered wrong: %+v", missing)
}
rejected := payload.Objects[2]
if rejected.Error == nil || rejected.Error.Code != http.StatusUnprocessableEntity {
t.Fatalf("invalid oid answered wrong: %+v", rejected)
}
// the knot's batch parser requires size on every answered object, errors included
for _, object := range payload.Objects {
if object.Size == nil {
t.Fatalf("object %q answered without size: %s", object.Oid, rec.Body.String())
}
}
objectRec := request(server, http.MethodGet, base+"/info/lfs/objects/"+lfsOID, "")
if objectRec.Code != http.StatusOK || objectRec.Body.String() != string(media) {
t.Fatalf("object download: got %d %q", objectRec.Code, objectRec.Body.String())
}
absentRec := request(server, http.MethodGet, base+"/info/lfs/objects/"+strings.Repeat("ef", 32), "")
if absentRec.Code != http.StatusNotFound {
t.Fatalf("absent object download: got %d", absentRec.Code)
}
}
func TestLFSRoutesRejectWrongShapes(t *testing.T) {
server, database, jobID := testServer(t)
token, _ := stageLFSObject(t, server, database, jobID)
base := "/git/" + token + "/repo.git"
if rec := requestBody(server, http.MethodGet, base+"/info/lfs/objects/batch", lfsContentType, lfsBatchBody("")); rec.Code != http.StatusNotFound {
t.Fatalf("GET batch: got %d", rec.Code)
}
if rec := requestBody(server, http.MethodPost, base+"/info/lfs/objects/batch", "text/plain", lfsBatchBody("")); rec.Code != http.StatusNotFound {
t.Fatalf("batch with a non-lfs content type: got %d", rec.Code)
}
if rec := requestBody(server, http.MethodPost, base+"/info/lfs/objects/batch", lfsContentType, `{"operation":"upload","objects":[]}`); rec.Code != http.StatusBadRequest {
t.Fatalf("upload batch: got %d", rec.Code)
}
if rec := requestBody(server, http.MethodPost, base+"/info/lfs/objects/"+lfsOID, lfsContentType, lfsBatchBody("")); rec.Code != http.StatusNotFound {
t.Fatalf("POST object: got %d", rec.Code)
}
if rec := request(server, http.MethodGet, base+"/info/lfs/objects/"+strings.Repeat("z", 64), ""); rec.Code != http.StatusNotFound {
t.Fatalf("non-oid path: got %d", rec.Code)
}
if rec := request(server, http.MethodGet, "/git/"+token+"/../repo.git/info/lfs/objects/"+lfsOID, ""); rec.Code != http.StatusNotFound {
t.Fatalf("traversal name: got %d", rec.Code)
}
}