e2e/lfs_test.go
175 lines · 6192 bytes
1package e2e
2
3import (
4 "bytes"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "net"
11 "net/http"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "strings"
16 "testing"
17 "time"
18)
19
20func waitForPort(t *testing.T, port int) {
21 t.Helper()
22 deadline := time.Now().Add(10 * time.Second)
23 for {
24 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond)
25 if err == nil {
26 conn.Close()
27 return
28 }
29 if time.Now().After(deadline) {
30 t.Fatal("listener did not come back")
31 }
32 time.Sleep(50 * time.Millisecond)
33 }
34}
35
36func TestLFS(t *testing.T) {
37 if _, err := exec.LookPath("git-lfs"); err != nil {
38 t.Skip("git-lfs client not installed")
39 }
40 inst := startInstance(t)
41 // LFS hands clients absolute hrefs built from site_url; point it at
42 // the live HTTP listener so the real git-lfs client can follow them.
43 inst.proc.Process.Kill()
44 inst.proc.Wait()
45 raw, err := os.ReadFile(inst.config)
46 if err != nil {
47 t.Fatal(err)
48 }
49 raw = bytes.Replace(raw, []byte(`site_url = "https://gitbay.test"`),
50 []byte(fmt.Sprintf(`site_url = "http://127.0.0.1:%d"`, inst.httpPort)), 1)
51 os.WriteFile(inst.config, raw, 0o600)
52 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
53 inst.proc.Stderr = os.Stderr
54 if err := inst.proc.Start(); err != nil {
55 t.Fatal(err)
56 }
57 waitForPort(t, inst.port)
58
59 aliceKey := inst.newKey(t, "alice")
60 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
61
62 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/big"); code != 0 {
63 t.Fatalf("repo create: %s", errOut)
64 }
65 env := inst.gitEnv(aliceKey)
66 work := t.TempDir()
67 mustGit(t, work, env, "clone", inst.sshURL("alice/big"), "w")
68 dir := filepath.Join(work, "w")
69 mustGit(t, dir, env, "lfs", "install", "--local")
70 mustGit(t, dir, env, "lfs", "track", "*.bin")
71 payload := make([]byte, 1<<20)
72 rand.Read(payload)
73 os.WriteFile(filepath.Join(dir, "data.bin"), payload, 0o644)
74 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
75 mustGit(t, dir, env, "add", ".")
76 mustGit(t, dir, env, "commit", "-q", "-m", "big file")
77 mustGit(t, dir, env, "push", "-q", "origin", "main")
78
79 // The object landed in content-addressed storage, not in git.
80 oid := sha256.Sum256(payload)
81 oidHex := hex.EncodeToString(oid[:])
82 stored := filepath.Join(inst.root, "lfs", oidHex[:2], oidHex[2:4], oidHex)
83 if fi, err := os.Stat(stored); err != nil || fi.Size() != int64(len(payload)) {
84 t.Fatalf("object not in lfs store: %v", err)
85 }
86
87 // A fresh SSH clone round-trips the content through the smudge filter.
88 work2 := t.TempDir()
89 mustGit(t, work2, env, "clone", inst.sshURL("alice/big"), "w")
90 dir2 := filepath.Join(work2, "w")
91 mustGit(t, dir2, env, "lfs", "install", "--local")
92 mustGit(t, dir2, env, "lfs", "pull", "origin")
93 got, err := os.ReadFile(filepath.Join(dir2, "data.bin"))
94 if err != nil || !bytes.Equal(got, payload) {
95 t.Fatalf("ssh round-trip: %v, %d bytes", err, len(got))
96 }
97
98 // Anonymous HTTPS: public repos serve LFS downloads with no credentials.
99 httpURL := fmt.Sprintf("http://127.0.0.1:%d/alice/big.git", inst.httpPort)
100 work3 := t.TempDir()
101 mustGit(t, work3, env, "clone", httpURL, "w")
102 dir3 := filepath.Join(work3, "w")
103 mustGit(t, dir3, env, "lfs", "install", "--local")
104 mustGit(t, dir3, env, "lfs", "pull", "origin")
105 if got, err := os.ReadFile(filepath.Join(dir3, "data.bin")); err != nil || !bytes.Equal(got, payload) {
106 t.Fatalf("anonymous http round-trip: %v, %d bytes", err, len(got))
107 }
108
109 // Anonymous upload is refused; so is anything on a private repo.
110 batch := func(repo, op, auth string) int {
111 body := fmt.Sprintf(`{"operation":%q,"transfers":["basic"],"objects":[{"oid":%q,"size":4}]}`, op, oidHex)
112 req, _ := http.NewRequest("POST",
113 fmt.Sprintf("http://127.0.0.1:%d/alice/%s.git/info/lfs/objects/batch", inst.httpPort, repo),
114 strings.NewReader(body))
115 req.Header.Set("Content-Type", "application/vnd.git-lfs+json")
116 if auth != "" {
117 req.Header.Set("Authorization", auth)
118 }
119 resp, err := http.DefaultClient.Do(req)
120 if err != nil {
121 t.Fatal(err)
122 }
123 resp.Body.Close()
124 return resp.StatusCode
125 }
126 if code := batch("big", "upload", ""); code != 403 {
127 t.Fatalf("anonymous upload: %d", code)
128 }
129 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/vault", "--private"); code != 0 {
130 t.Fatal("private repo create failed")
131 }
132 if code := batch("vault", "download", ""); code != 404 {
133 t.Fatalf("anonymous private batch: %d", code)
134 }
135
136 // Access rules over SSH: a stranger's authenticate on a private repo
137 // reads as nonexistence; upload needs write.
138 bobKey := inst.newKey(t, "bob")
139 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
140 if _, errOut, code := inst.ssh(t, bobKey, "", "git-lfs-authenticate", "alice/vault", "download"); code != 3 || !strings.Contains(errOut, "not found") {
141 t.Fatalf("stranger authenticate: exit %d, %s", code, errOut)
142 }
143 if _, errOut, code := inst.ssh(t, bobKey, "", "git-lfs-authenticate", "alice/big", "upload"); code != 4 || !strings.Contains(errOut, "denied") {
144 t.Fatalf("read-only upload authenticate: exit %d, %s", code, errOut)
145 }
146
147 // A corrupt upload is refused and stores nothing: mint an upload token
148 // via authenticate, then PUT a body that does not match the oid.
149 out, _, code := inst.ssh(t, aliceKey, "", "git-lfs-authenticate", "alice/big", "upload")
150 if code != 0 {
151 t.Fatalf("authenticate: %s", out)
152 }
153 var grant struct {
154 Header map[string]string `json:"header"`
155 }
156 if err := json.Unmarshal([]byte(out), &grant); err != nil {
157 t.Fatalf("authenticate JSON: %v\n%s", err, out)
158 }
159 fakeOID := strings.Repeat("ab", 32)
160 req, _ := http.NewRequest("PUT",
161 fmt.Sprintf("http://127.0.0.1:%d/alice/big.git/info/lfs/objects/%s", inst.httpPort, fakeOID),
162 strings.NewReader("not the content"))
163 req.Header.Set("Authorization", grant.Header["Authorization"])
164 resp, err := http.DefaultClient.Do(req)
165 if err != nil {
166 t.Fatal(err)
167 }
168 resp.Body.Close()
169 if resp.StatusCode != 422 {
170 t.Fatalf("corrupt upload: %d", resp.StatusCode)
171 }
172 if _, err := os.Stat(filepath.Join(inst.root, "lfs", "ab", "ab", fakeOID)); err == nil {
173 t.Fatal("corrupt object was stored")
174 }
175}