Go · 10440 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 TestInitBare_HEADIsTrunk(t *testing.T) {
150 t.Parallel()
151 if _, err := exec.LookPath("git"); err != nil {
152 t.Skip("git not in PATH")
153 }
154 r, _ := mustNewRepoFS(t)
155 path, err := r.RepoPath("alice", "trunktest")
156 if err != nil {
157 t.Fatalf("RepoPath: %v", err)
158 }
159 if err := r.InitBare(context.Background(), path); err != nil {
160 t.Fatalf("InitBare: %v", err)
161 }
162 // G204: path comes from RepoPath in test setup (whitelisted).
163 out, err := exec.Command("git", "--git-dir", path, "symbolic-ref", "HEAD").Output() //nolint:gosec
164 if err != nil {
165 t.Fatalf("symbolic-ref: %v", err)
166 }
167 got := strings.TrimSpace(string(out))
168 if got != "refs/heads/trunk" {
169 t.Fatalf("HEAD = %q, want refs/heads/trunk", got)
170 }
171 }
172
173 // TestInitBare_SharedGroupContract pins SR2 #287:
174 // `git init --bare --shared=group` MUST be used so two users
175 // (shithubd-web's `shithub` user and the SSH dispatcher's `git`
176 // user, both in the `shithub` group) can write to objects/.
177 //
178 // Pre-fix the SSH-git push path failed with "unable to create
179 // temporary object directory" because objects/ was 0755 with no
180 // group-write bit.
181 //
182 // We assert the persisted config + the directory mode bits the
183 // flag produces.
184 func TestInitBare_SharedGroupContract(t *testing.T) {
185 t.Parallel()
186 if _, err := exec.LookPath("git"); err != nil {
187 t.Skip("git not in PATH")
188 }
189 r, _ := mustNewRepoFS(t)
190 path, err := r.RepoPath("alice", "sharedgrouptest")
191 if err != nil {
192 t.Fatalf("RepoPath: %v", err)
193 }
194 if err := r.InitBare(context.Background(), path); err != nil {
195 t.Fatalf("InitBare: %v", err)
196 }
197
198 // 1) config has core.sharedRepository=group. git stores this as
199 // the integer "1" internally (0=false, 1=group, 2=all, …);
200 // either form satisfies the contract.
201 out, err := exec.Command("git", "--git-dir", path, "config", "--get", "core.sharedRepository").Output() //nolint:gosec
202 if err != nil {
203 t.Fatalf("git config: %v", err)
204 }
205 got := strings.TrimSpace(string(out))
206 if got != "group" && got != "1" {
207 t.Fatalf("core.sharedRepository = %q, want \"group\" or \"1\"", got)
208 }
209
210 // 2) objects/ dir has group-write set (mode bit 0o020).
211 objects := path + "/objects"
212 st, err := os.Stat(objects)
213 if err != nil {
214 t.Fatalf("stat objects: %v", err)
215 }
216 mode := st.Mode().Perm()
217 if mode&0o020 == 0 {
218 t.Fatalf("objects/ mode = %#o; group-write bit (0o020) missing — SSH push will EACCES", mode)
219 }
220 }
221
222 // TestRepairSharedPerms_FixesPreFixRepo pins the backfill path:
223 // a repo created without --shared=group (the pre-SR2 #287 layout)
224 // gets brought to the contract by RepairSharedPerms — config flag
225 // set, group-write bit on objects/, setgid on dirs.
226 func TestRepairSharedPerms_FixesPreFixRepo(t *testing.T) {
227 t.Parallel()
228 if _, err := exec.LookPath("git"); err != nil {
229 t.Skip("git not in PATH")
230 }
231 r, root := mustNewRepoFS(t)
232 path, err := r.RepoPath("alice", "repairtest")
233 if err != nil {
234 t.Fatalf("RepoPath: %v", err)
235 }
236 // Create the parent dir + a deliberately pre-fix bare repo
237 // (NO --shared=group). This simulates a live repo from before
238 // the fix landed.
239 if err := os.MkdirAll(path, 0o750); err != nil {
240 t.Fatalf("mkdir: %v", err)
241 }
242 if out, err := exec.Command("git", "init", "--bare", "--initial-branch=trunk", path).CombinedOutput(); err != nil {
243 t.Fatalf("pre-fix init: %v: %s", err, out)
244 }
245 // Sanity: the pre-fix objects/ should NOT have group-write.
246 objects := path + "/objects"
247 st, err := os.Stat(objects)
248 if err != nil {
249 t.Fatalf("stat: %v", err)
250 }
251 if st.Mode().Perm()&0o020 != 0 {
252 t.Skipf("pre-fix init produced 0%o; test environment differs (umask?). Skipping.", st.Mode().Perm())
253 }
254
255 // Run the repair.
256 if err := r.RepairSharedPerms(context.Background(), path); err != nil {
257 t.Fatalf("RepairSharedPerms: %v", err)
258 }
259
260 // Post-condition: config has the flag.
261 out, err := exec.Command("git", "--git-dir", path, "config", "--get", "core.sharedRepository").Output() //nolint:gosec
262 if err != nil {
263 t.Fatalf("git config: %v", err)
264 }
265 got := strings.TrimSpace(string(out))
266 if got != "group" && got != "1" {
267 t.Fatalf("core.sharedRepository = %q, want \"group\" or \"1\"", got)
268 }
269 // objects/ has g+w.
270 st, err = os.Stat(objects)
271 if err != nil {
272 t.Fatalf("stat after repair: %v", err)
273 }
274 mode := st.Mode().Perm()
275 if mode&0o020 == 0 {
276 t.Fatalf("after repair, objects/ mode = %#o; group-write missing", mode)
277 }
278 // objects/ has setgid.
279 if st.Mode()&os.ModeSetgid == 0 {
280 t.Fatalf("after repair, objects/ missing setgid bit; new files won't inherit group")
281 }
282
283 _ = root
284 }
285
286 func TestInitBare_RefusesNonEmpty(t *testing.T) {
287 t.Parallel()
288 if _, err := exec.LookPath("git"); err != nil {
289 t.Skip("git not in PATH")
290 }
291 r, _ := mustNewRepoFS(t)
292 path, err := r.RepoPath("alice", "twice")
293 if err != nil {
294 t.Fatalf("RepoPath: %v", err)
295 }
296 if err := r.InitBare(context.Background(), path); err != nil {
297 t.Fatalf("first InitBare: %v", err)
298 }
299 if err := r.InitBare(context.Background(), path); !errors.Is(err, ErrAlreadyExists) {
300 t.Fatalf("expected ErrAlreadyExists on second init, got %v", err)
301 }
302 }
303
304 func TestMove_AtomicAndRefusesOverwrite(t *testing.T) {
305 t.Parallel()
306 if _, err := exec.LookPath("git"); err != nil {
307 t.Skip("git not in PATH")
308 }
309 r, _ := mustNewRepoFS(t)
310 src, _ := r.RepoPath("alice", "src")
311 dst, _ := r.RepoPath("alice", "dst")
312 if err := r.InitBare(context.Background(), src); err != nil {
313 t.Fatalf("InitBare src: %v", err)
314 }
315 if err := r.Move(src, dst); err != nil {
316 t.Fatalf("Move: %v", err)
317 }
318 srcExists, _ := r.Exists(src)
319 dstExists, _ := r.Exists(dst)
320 if srcExists || !dstExists {
321 t.Fatalf("expected src absent and dst present, got src=%v dst=%v", srcExists, dstExists)
322 }
323
324 // Refuses overwrite: re-create src then attempt to move into existing dst.
325 if err := r.InitBare(context.Background(), src); err != nil {
326 t.Fatalf("re-init src: %v", err)
327 }
328 if err := r.Move(src, dst); !errors.Is(err, ErrAlreadyExists) {
329 t.Fatalf("expected ErrAlreadyExists, got %v", err)
330 }
331 }
332
333 func TestDelete_RefusesEscape(t *testing.T) {
334 t.Parallel()
335 r, _ := mustNewRepoFS(t)
336 if err := r.Delete("/etc/passwd"); !errors.Is(err, ErrEscapesRoot) {
337 t.Fatalf("expected ErrEscapesRoot, got %v", err)
338 }
339 }
340
341 func TestDelete_RefusesRoot(t *testing.T) {
342 t.Parallel()
343 r, root := mustNewRepoFS(t)
344 if err := r.Delete(root); !errors.Is(err, ErrEscapesRoot) {
345 t.Fatalf("expected ErrEscapesRoot for root, got %v", err)
346 }
347 // Root must still exist.
348 if _, err := os.Stat(root); err != nil {
349 t.Fatalf("root removed: %v", err)
350 }
351 }
352