Go · 11443 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package storage
4
5 import (
6 "context"
7 "errors"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13 )
14
15 func mustNewRepoFS(t *testing.T) (*RepoFS, string) {
16 t.Helper()
17 dir := t.TempDir()
18 r, err := NewRepoFS(dir)
19 if err != nil {
20 t.Fatalf("NewRepoFS: %v", err)
21 }
22 return r, dir
23 }
24
25 func TestNewRepoFS_RejectsRelativeRoot(t *testing.T) {
26 t.Parallel()
27 if _, err := NewRepoFS("relative/path"); err == nil {
28 t.Fatal("expected error for relative root")
29 }
30 }
31
32 func TestNewRepoFS_RejectsMissingRoot(t *testing.T) {
33 t.Parallel()
34 if _, err := NewRepoFS("/this/path/should/not/exist/abc123xyz"); err == nil {
35 t.Fatal("expected error for missing root")
36 }
37 }
38
39 func TestRepoPath_HappyPath(t *testing.T) {
40 t.Parallel()
41 r, root := mustNewRepoFS(t)
42 got, err := r.RepoPath("alice", "my-project")
43 if err != nil {
44 t.Fatalf("RepoPath: %v", err)
45 }
46 want := filepath.Join(root, "al", "alice", "my-project.git")
47 if got != want {
48 t.Fatalf("RepoPath = %q, want %q", got, want)
49 }
50 }
51
52 func TestRepoPath_AcceptsRepoExtraChars(t *testing.T) {
53 t.Parallel()
54 r, _ := mustNewRepoFS(t)
55 for _, name := range []string{"name.with.dots", "name_under", "rust-by-example", "a1.b2_c3"} {
56 if _, err := r.RepoPath("alice", name); err != nil {
57 t.Errorf("RepoPath %q: %v", name, err)
58 }
59 }
60 }
61
62 func TestRepoPath_ShortOwnerPaddedShard(t *testing.T) {
63 t.Parallel()
64 r, root := mustNewRepoFS(t)
65 got, err := r.RepoPath("a", "x")
66 if err != nil {
67 t.Fatalf("RepoPath: %v", err)
68 }
69 want := filepath.Join(root, "a_", "a", "x.git")
70 if got != want {
71 t.Fatalf("RepoPath = %q, want %q", got, want)
72 }
73 }
74
75 func TestRepoPath_LowercasesOwner(t *testing.T) {
76 t.Parallel()
77 r, root := mustNewRepoFS(t)
78 got, err := r.RepoPath("Alice", "Project")
79 if err != nil {
80 t.Fatalf("RepoPath: %v", err)
81 }
82 want := filepath.Join(root, "al", "alice", "project.git")
83 if got != want {
84 t.Fatalf("RepoPath = %q, want %q", got, want)
85 }
86 }
87
88 // TestRepoPath_RejectsUnsafe is the critical path-validation table-driven
89 // test mandated by S04. Every entry MUST be rejected.
90 func TestRepoPath_RejectsUnsafe(t *testing.T) {
91 t.Parallel()
92 r, _ := mustNewRepoFS(t)
93
94 cases := []struct {
95 owner, name, why string
96 }{
97 {"", "name", "empty owner"},
98 {"alice", "", "empty repo"},
99 {"..", "name", "owner is .."},
100 {"alice", "..", "repo is .."},
101 {"al/ice", "name", "owner contains slash"},
102 {"alice", "na/me", "repo contains slash"},
103 {"alice", "../escape", "repo path traversal"},
104 {"-leading", "name", "owner leading dash"},
105 {"trailing-", "name", "owner trailing dash"},
106 {"alice", "-leading", "repo leading dash"},
107 {"alice", "trailing-", "repo trailing dash"},
108 {".hidden", "name", "owner leading dot"},
109 {"alice", ".hidden", "repo leading dot"},
110 {"alice", ".git", "repo dotfile"},
111 {"/absolute", "name", "owner absolute"},
112 {"alice", "/absolute", "repo absolute"},
113 {"alice", "name with space", "repo space"},
114 {"alice", "name\x00null", "repo nul"},
115 {"alice", "name\nnewline", "repo newline"},
116 {"АliCe", "name", "owner non-ASCII (Cyrillic A)"},
117 {"alice", "café", "repo non-ASCII"},
118 {strings.Repeat("a", 40), "name", "owner too long"},
119 {"alice", strings.Repeat("b", 101), "repo too long"},
120 {"alice", "al!ice", "repo punctuation"},
121 {"alice", "name@thing", "repo @"},
122 {"al!ice", "name", "owner punctuation"},
123 }
124
125 for _, c := range cases {
126 c := c
127 t.Run(c.why, func(t *testing.T) {
128 t.Parallel()
129 _, err := r.RepoPath(c.owner, c.name)
130 if err == nil {
131 t.Fatalf("expected error for %s (owner=%q, name=%q)", c.why, c.owner, c.name)
132 }
133 if !errors.Is(err, ErrInvalidPath) {
134 t.Fatalf("expected ErrInvalidPath for %s, got %v", c.why, err)
135 }
136 })
137 }
138 }
139
140 func TestExists_RejectsOutsideRoot(t *testing.T) {
141 t.Parallel()
142 r, _ := mustNewRepoFS(t)
143 _, err := r.Exists("/etc/passwd")
144 if !errors.Is(err, ErrEscapesRoot) {
145 t.Fatalf("expected ErrEscapesRoot, got %v", err)
146 }
147 }
148
149 func TestDiskUsageBytes(t *testing.T) {
150 t.Parallel()
151 r, _ := mustNewRepoFS(t)
152 path, err := r.RepoPath("alice", "usage")
153 if err != nil {
154 t.Fatalf("RepoPath: %v", err)
155 }
156 if err := os.MkdirAll(filepath.Join(path, "objects", "ab"), 0o750); err != nil {
157 t.Fatalf("mkdir: %v", err)
158 }
159 if err := os.WriteFile(filepath.Join(path, "HEAD"), []byte("ref: refs/heads/trunk\n"), 0o640); err != nil {
160 t.Fatalf("write HEAD: %v", err)
161 }
162 if err := os.WriteFile(filepath.Join(path, "objects", "ab", "pack"), []byte("payload"), 0o640); err != nil {
163 t.Fatalf("write object: %v", err)
164 }
165 got, err := r.DiskUsageBytes(context.Background(), path)
166 if err != nil {
167 t.Fatalf("DiskUsageBytes: %v", err)
168 }
169 want := int64(len("ref: refs/heads/trunk\n") + len("payload"))
170 if got != want {
171 t.Fatalf("DiskUsageBytes = %d, want %d", got, want)
172 }
173 if _, err := r.DiskUsageBytes(context.Background(), "/etc"); !errors.Is(err, ErrEscapesRoot) {
174 t.Fatalf("outside root err = %v, want ErrEscapesRoot", err)
175 }
176 }
177
178 func TestInitBare_HEADIsTrunk(t *testing.T) {
179 t.Parallel()
180 if _, err := exec.LookPath("git"); err != nil {
181 t.Skip("git not in PATH")
182 }
183 r, _ := mustNewRepoFS(t)
184 path, err := r.RepoPath("alice", "trunktest")
185 if err != nil {
186 t.Fatalf("RepoPath: %v", err)
187 }
188 if err := r.InitBare(context.Background(), path); err != nil {
189 t.Fatalf("InitBare: %v", err)
190 }
191 // G204: path comes from RepoPath in test setup (whitelisted).
192 out, err := exec.Command("git", "--git-dir", path, "symbolic-ref", "HEAD").Output() //nolint:gosec
193 if err != nil {
194 t.Fatalf("symbolic-ref: %v", err)
195 }
196 got := strings.TrimSpace(string(out))
197 if got != "refs/heads/trunk" {
198 t.Fatalf("HEAD = %q, want refs/heads/trunk", got)
199 }
200 }
201
202 // TestInitBare_SharedGroupContract pins SR2 #287:
203 // `git init --bare --shared=group` MUST be used so two users
204 // (shithubd-web's `shithub` user and the SSH dispatcher's `git`
205 // user, both in the `shithub` group) can write to objects/.
206 //
207 // Pre-fix the SSH-git push path failed with "unable to create
208 // temporary object directory" because objects/ was 0755 with no
209 // group-write bit.
210 //
211 // We assert the persisted config + the directory mode bits the
212 // flag produces.
213 func TestInitBare_SharedGroupContract(t *testing.T) {
214 t.Parallel()
215 if _, err := exec.LookPath("git"); err != nil {
216 t.Skip("git not in PATH")
217 }
218 r, _ := mustNewRepoFS(t)
219 path, err := r.RepoPath("alice", "sharedgrouptest")
220 if err != nil {
221 t.Fatalf("RepoPath: %v", err)
222 }
223 if err := r.InitBare(context.Background(), path); err != nil {
224 t.Fatalf("InitBare: %v", err)
225 }
226
227 // 1) config has core.sharedRepository=group. git stores this as
228 // the integer "1" internally (0=false, 1=group, 2=all, …);
229 // either form satisfies the contract.
230 out, err := exec.Command("git", "--git-dir", path, "config", "--get", "core.sharedRepository").Output() //nolint:gosec
231 if err != nil {
232 t.Fatalf("git config: %v", err)
233 }
234 got := strings.TrimSpace(string(out))
235 if got != "group" && got != "1" {
236 t.Fatalf("core.sharedRepository = %q, want \"group\" or \"1\"", got)
237 }
238
239 // 2) objects/ dir has group-write set (mode bit 0o020).
240 objects := path + "/objects"
241 st, err := os.Stat(objects)
242 if err != nil {
243 t.Fatalf("stat objects: %v", err)
244 }
245 mode := st.Mode().Perm()
246 if mode&0o020 == 0 {
247 t.Fatalf("objects/ mode = %#o; group-write bit (0o020) missing — SSH push will EACCES", mode)
248 }
249 }
250
251 // TestRepairSharedPerms_FixesPreFixRepo pins the backfill path:
252 // a repo created without --shared=group (the pre-SR2 #287 layout)
253 // gets brought to the contract by RepairSharedPerms — config flag
254 // set, group-write bit on objects/, setgid on dirs.
255 func TestRepairSharedPerms_FixesPreFixRepo(t *testing.T) {
256 t.Parallel()
257 if _, err := exec.LookPath("git"); err != nil {
258 t.Skip("git not in PATH")
259 }
260 r, root := mustNewRepoFS(t)
261 path, err := r.RepoPath("alice", "repairtest")
262 if err != nil {
263 t.Fatalf("RepoPath: %v", err)
264 }
265 // Create the parent dir + a deliberately pre-fix bare repo
266 // (NO --shared=group). This simulates a live repo from before
267 // the fix landed.
268 if err := os.MkdirAll(path, 0o750); err != nil {
269 t.Fatalf("mkdir: %v", err)
270 }
271 if out, err := exec.Command("git", "init", "--bare", "--initial-branch=trunk", path).CombinedOutput(); err != nil {
272 t.Fatalf("pre-fix init: %v: %s", err, out)
273 }
274 // Sanity: the pre-fix objects/ should NOT have group-write.
275 objects := path + "/objects"
276 st, err := os.Stat(objects)
277 if err != nil {
278 t.Fatalf("stat: %v", err)
279 }
280 if st.Mode().Perm()&0o020 != 0 {
281 t.Skipf("pre-fix init produced 0%o; test environment differs (umask?). Skipping.", st.Mode().Perm())
282 }
283
284 // Run the repair.
285 if err := r.RepairSharedPerms(context.Background(), path); err != nil {
286 t.Fatalf("RepairSharedPerms: %v", err)
287 }
288
289 // Post-condition: config has the flag.
290 out, err := exec.Command("git", "--git-dir", path, "config", "--get", "core.sharedRepository").Output() //nolint:gosec
291 if err != nil {
292 t.Fatalf("git config: %v", err)
293 }
294 got := strings.TrimSpace(string(out))
295 if got != "group" && got != "1" {
296 t.Fatalf("core.sharedRepository = %q, want \"group\" or \"1\"", got)
297 }
298 // objects/ has g+w.
299 st, err = os.Stat(objects)
300 if err != nil {
301 t.Fatalf("stat after repair: %v", err)
302 }
303 mode := st.Mode().Perm()
304 if mode&0o020 == 0 {
305 t.Fatalf("after repair, objects/ mode = %#o; group-write missing", mode)
306 }
307 // objects/ has setgid.
308 if st.Mode()&os.ModeSetgid == 0 {
309 t.Fatalf("after repair, objects/ missing setgid bit; new files won't inherit group")
310 }
311
312 _ = root
313 }
314
315 func TestInitBare_RefusesNonEmpty(t *testing.T) {
316 t.Parallel()
317 if _, err := exec.LookPath("git"); err != nil {
318 t.Skip("git not in PATH")
319 }
320 r, _ := mustNewRepoFS(t)
321 path, err := r.RepoPath("alice", "twice")
322 if err != nil {
323 t.Fatalf("RepoPath: %v", err)
324 }
325 if err := r.InitBare(context.Background(), path); err != nil {
326 t.Fatalf("first InitBare: %v", err)
327 }
328 if err := r.InitBare(context.Background(), path); !errors.Is(err, ErrAlreadyExists) {
329 t.Fatalf("expected ErrAlreadyExists on second init, got %v", err)
330 }
331 }
332
333 func TestMove_AtomicAndRefusesOverwrite(t *testing.T) {
334 t.Parallel()
335 if _, err := exec.LookPath("git"); err != nil {
336 t.Skip("git not in PATH")
337 }
338 r, _ := mustNewRepoFS(t)
339 src, _ := r.RepoPath("alice", "src")
340 dst, _ := r.RepoPath("alice", "dst")
341 if err := r.InitBare(context.Background(), src); err != nil {
342 t.Fatalf("InitBare src: %v", err)
343 }
344 if err := r.Move(src, dst); err != nil {
345 t.Fatalf("Move: %v", err)
346 }
347 srcExists, _ := r.Exists(src)
348 dstExists, _ := r.Exists(dst)
349 if srcExists || !dstExists {
350 t.Fatalf("expected src absent and dst present, got src=%v dst=%v", srcExists, dstExists)
351 }
352
353 // Refuses overwrite: re-create src then attempt to move into existing dst.
354 if err := r.InitBare(context.Background(), src); err != nil {
355 t.Fatalf("re-init src: %v", err)
356 }
357 if err := r.Move(src, dst); !errors.Is(err, ErrAlreadyExists) {
358 t.Fatalf("expected ErrAlreadyExists, got %v", err)
359 }
360 }
361
362 func TestDelete_RefusesEscape(t *testing.T) {
363 t.Parallel()
364 r, _ := mustNewRepoFS(t)
365 if err := r.Delete("/etc/passwd"); !errors.Is(err, ErrEscapesRoot) {
366 t.Fatalf("expected ErrEscapesRoot, got %v", err)
367 }
368 }
369
370 func TestDelete_RefusesRoot(t *testing.T) {
371 t.Parallel()
372 r, root := mustNewRepoFS(t)
373 if err := r.Delete(root); !errors.Is(err, ErrEscapesRoot) {
374 t.Fatalf("expected ErrEscapesRoot for root, got %v", err)
375 }
376 // Root must still exist.
377 if _, err := os.Stat(root); err != nil {
378 t.Fatalf("root removed: %v", err)
379 }
380 }
381