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