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