Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 19m ago 4b8c9e1d
history blame raw 18.6 KiB
1package hook
2
3import (
4 "bytes"
5 "io"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "rodent.software/vole/ci/runs"
13 "rodent.software/vole/core/git"
14 "rodent.software/vole/forge/access"
15 "rodent.software/vole/forge/identity"
16 "rodent.software/vole/forge/index"
17 "rodent.software/vole/forge/repo"
18 "rodent.software/vole/metadata"
19)
20
21type fixture struct {
22 t *testing.T
23 m *repo.Meta
24 head string
25 usersDB string
26}
27
28func newFixture(t *testing.T, blob string) *fixture {
29 t.Helper()
30 root := t.TempDir()
31 // Access rules live in the instance database, so a fixture that pushes has to give
32 // the gate the same file the repository's rules were written to.
33 acls := testACLs(t, root)
34 st := repo.New(filepath.Join(root, "repos"), "/bin/true", acls)
35 m, err := st.Create("demo", "owner", "")
36 if err != nil {
37 t.Fatal(err)
38 }
39 a, err := m.ACL()
40 if err != nil {
41 t.Fatal(err)
42 }
43 if err := a.SetEntry("writer", "maintain"); err != nil {
44 t.Fatal(err)
45 }
46 if err := a.SetEntry("reader", "read"); err != nil {
47 t.Fatal(err)
48 }
49 if err := a.SetEntry("contributor", "contribute"); err != nil {
50 t.Fatal(err)
51 }
52 if err := m.SetACL(a, "test"); err != nil {
53 t.Fatal(err)
54 }
55
56 g := m.Git()
57 blobSHA, err := g.HashObject([]byte(blob))
58 if err != nil {
59 t.Fatal(err)
60 }
61 tree, err := g.MkTree([]git.Entry{{Mode: "100644", Type: "blob", SHA: blobSHA, Name: "readme.md"}})
62 if err != nil {
63 t.Fatal(err)
64 }
65 commit, err := g.CommitTree(tree, "", "initial")
66 if err != nil {
67 t.Fatal(err)
68 }
69 return &fixture{t: t, m: m, head: commit, usersDB: testUsersDB(root)}
70}
71
72// testUsersDB is the instance database a test fixture uses: one file holding the
73// roster and the access rules, which is what the server has.
74func testUsersDB(root string) string { return filepath.Join(root, "users.db") }
75
76func testACLs(t *testing.T, root string) *access.Store {
77 t.Helper()
78 acls, err := access.Open(testUsersDB(root))
79 if err != nil {
80 t.Fatal(err)
81 }
82 acls.AuditPath = filepath.Join(root, "audit.log")
83 t.Cleanup(func() { acls.Close() })
84 return acls
85}
86
87func (f *fixture) run(actor, ref string, maxBlob int64) (int, string) {
88 f.t.Helper()
89 return f.runWith(Config{
90 Actor: actor,
91 MaxBlobBytes: maxBlob,
92 }, ref)
93}
94
95// runWith runs pre-receive with a full config, so LFS rules can be exercised.
96func (f *fixture) runWith(cfg Config, ref string) (int, string) {
97 f.t.Helper()
98 cfg.RepoDir = f.m.Dir
99 if cfg.UsersDB == "" {
100 cfg.UsersDB = f.usersDB
101 }
102 var out bytes.Buffer
103 cfg.In = strings.NewReader(git.ZeroSHA + " " + f.head + " " + ref + "\n")
104 cfg.Out = &out
105 return PreReceive(cfg), out.String()
106}
107
108// runFrom is a push that starts at an existing metadata commit, which is what a job's
109// write looks like: the branch is already there and the push adds to it.
110func (f *fixture) runFrom(cfg Config, old, ref string) (int, string) {
111 f.t.Helper()
112 cfg.RepoDir = f.m.Dir
113 if cfg.UsersDB == "" {
114 cfg.UsersDB = f.usersDB
115 }
116 var out bytes.Buffer
117 cfg.In = strings.NewReader(old + " " + f.head + " " + ref + "\n")
118 cfg.Out = &out
119 return PreReceive(cfg), out.String()
120}
121
122func (f *fixture) setACL(mut func(*access.ACL)) {
123 f.t.Helper()
124 a, err := f.m.ACL()
125 if err != nil {
126 f.t.Fatal(err)
127 }
128 mut(a)
129 if err := f.m.SetACL(a, "test"); err != nil {
130 f.t.Fatal(err)
131 }
132}
133
134func TestOwnerCanPush(t *testing.T) {
135 t.Parallel()
136 f := newFixture(t, "hello\n")
137 if code, out := f.run("owner", "refs/heads/main", 0); code != 0 {
138 t.Fatalf("owner push rejected: %s", out)
139 }
140 if code, out := f.run("writer", "refs/heads/feature/x", 0); code != 0 {
141 t.Fatalf("writer push rejected: %s", out)
142 }
143}
144
145func TestContributorCanPushOnlyTheirNamespace(t *testing.T) {
146 t.Parallel()
147 f := newFixture(t, "hello\n")
148 own := "refs/heads/contrib/contributor/topic"
149 if code, out := f.run("contributor", own, 0); code != 0 {
150 t.Fatalf("contributor push to their namespace was rejected: %s", out)
151 }
152 for _, ref := range []string{
153 "refs/heads/main",
154 "refs/heads/contrib/someone-else/topic",
155 "refs/heads/contrib/contributor",
156 "refs/tags/contrib/contributor/v1",
157 } {
158 code, out := f.run("contributor", ref, 0)
159 if code == 0 {
160 t.Fatalf("contributor was allowed to push %s", ref)
161 }
162 if !strings.Contains(out, "the contribute role may only push branches under refs/heads/contrib/contributor/") {
163 t.Fatalf("%s: unclear rejection: %s", ref, out)
164 }
165 }
166 // The issues branch is not a namespace rule: a contributor may write it. This push
167 // would create it, which is a maintainer's act, and that is the refusal it gets.
168 if code, out := f.run("contributor", "refs/heads/vole", 0); code == 0 {
169 t.Fatal("a contributor created the issues branch")
170 } else if !strings.Contains(out, "creating the issues branch needs a maintainer") {
171 t.Fatalf("unclear rejection: %s", out)
172 }
173}
174
175func TestInstanceRoleAppliesOnlyToPublicReposAndAdminEverywhere(t *testing.T) {
176 t.Parallel()
177 f := newFixture(t, "hello\n")
178 ident, err := identity.Open(f.usersDB)
179 if err != nil {
180 t.Fatal(err)
181 }
182 ident.AuditPath = filepath.Join(filepath.Dir(f.usersDB), "audit.log")
183 defer ident.Close()
184 if _, _, err := ident.CreateWithInviteRole("invited", access.Contribute, "test", 0); err != nil {
185 t.Fatal(err)
186 }
187
188 own := "refs/heads/contrib/invited/topic"
189 f.setACL(func(a *access.ACL) { a.Default = "read" })
190 if code, out := f.run("invited", own, 0); code != 0 {
191 t.Fatalf("instance contributor could not push their namespace to a public repo: %s", out)
192 }
193 if code, out := f.run("invited", "refs/heads/main", 0); code == 0 {
194 t.Fatal("instance contributor pushed a shared branch")
195 } else if !strings.Contains(out, "the contribute role may only push branches under") {
196 t.Fatalf("unclear contributor rejection: %s", out)
197 }
198
199 f.setACL(func(a *access.ACL) { a.Default = "none" })
200 if code, out := f.run("invited", own, 0); code == 0 {
201 t.Fatal("instance contributor pushed to a private repo")
202 } else if !strings.Contains(out, "does not have maintain or contribute access") {
203 t.Fatalf("unclear private-repo rejection: %s", out)
204 }
205
206 if _, err := ident.SetInstanceRole("invited", access.Admin, "test"); err != nil {
207 t.Fatal(err)
208 }
209 if code, out := f.run("invited", "refs/heads/main", 0); code != 0 {
210 t.Fatalf("instance admin could not push to a private repo: %s", out)
211 }
212}
213
214func TestReadOnlyCannotPush(t *testing.T) {
215 t.Parallel()
216 f := newFixture(t, "hello\n")
217 code, out := f.run("reader", "refs/heads/main", 0)
218 if code == 0 {
219 t.Fatal("read-only user was allowed to push")
220 }
221 if !strings.Contains(out, "does not have maintain or contribute access") {
222 t.Fatalf("message should name the reason, got: %s", out)
223 }
224 if !strings.Contains(out, "reader") {
225 t.Fatalf("message should name the actor, got: %s", out)
226 }
227}
228
229func TestAnonymousCannotPush(t *testing.T) {
230 t.Parallel()
231 f := newFixture(t, "hello\n")
232 code, out := f.run("", "refs/heads/main", 0)
233 if code == 0 {
234 t.Fatal("anonymous push was allowed")
235 }
236 if !strings.Contains(out, "must be authenticated") {
237 t.Fatalf("unclear message: %s", out)
238 }
239}
240
241func TestProtectedBranch(t *testing.T) {
242 t.Parallel()
243 f := newFixture(t, "hello\n")
244 f.setACL(func(a *access.ACL) { a.Protected = []string{"refs/heads/main"} })
245
246 if code, out := f.run("writer", "refs/heads/main", 0); code == 0 {
247 t.Fatal("writer was allowed to push a protected branch")
248 } else if !strings.Contains(out, "protected") {
249 t.Fatalf("message should mention protection: %s", out)
250 }
251 if code, out := f.run("writer", "refs/heads/topic", 0); code != 0 {
252 t.Fatalf("protection leaked to an unprotected ref: %s", out)
253 }
254 if code, out := f.run("owner", "refs/heads/main", 0); code != 0 {
255 t.Fatalf("admin must be able to push a protected branch: %s", out)
256 }
257}
258
259func TestOversizedBlobRejected(t *testing.T) {
260 t.Parallel()
261 f := newFixture(t, strings.Repeat("x", 4096))
262 code, out := f.run("owner", "refs/heads/main", 1024)
263 if code == 0 {
264 t.Fatal("oversized blob was accepted")
265 }
266 if !strings.Contains(out, "over the 1024 byte limit") {
267 t.Fatalf("message should state the limit: %s", out)
268 }
269 if code, out := f.run("owner", "refs/heads/main", 1<<20); code != 0 {
270 t.Fatalf("blob under the limit was rejected: %s", out)
271 }
272}
273
274func TestArbitraryRefsRejected(t *testing.T) {
275 t.Parallel()
276 f := newFixture(t, "hello\n")
277 // refs/vole is no longer server state (access rules moved into the database), but it
278 // is still not a branch or a tag, so it is refused by the same rule as any other
279 // namespace rather than by a rule of its own.
280 for _, ref := range []string{"refs/notes/commits", "refs/vole/acl", "refs/vole/anything"} {
281 code, out := f.run("owner", ref, 0)
282 if code == 0 {
283 t.Fatalf("push to %s was allowed", ref)
284 }
285 if !strings.Contains(out, "only refs/heads/*") {
286 t.Fatalf("%s: unclear message: %s", ref, out)
287 }
288 }
289}
290
291// The push log is the audit trail for an ordinary push: attribution is the boundary's, so
292// what the instance owes a reader is a record of what it decided. One line per accepted
293// ref update, in the same log as the privileged acts.
294func TestPostReceiveRecordsThePush(t *testing.T) {
295 t.Parallel()
296 dir := t.TempDir()
297 repoDir := filepath.Join(dir, "demo.git")
298 if err := git.InitBare(repoDir, "main"); err != nil {
299 t.Fatal(err)
300 }
301 audit := filepath.Join(dir, "audit.log")
302 var out bytes.Buffer
303 code := PostReceive(Config{
304 Actor: "keenan",
305 AuditPath: audit,
306 SpoolDir: filepath.Join(dir, "spool"),
307 RepoDir: repoDir,
308 In: strings.NewReader(
309 "0000000000000000000000000000000000000000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\n" +
310 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000000000000000000000000000000 refs/heads/gone\n"),
311 Out: &out,
312 })
313 if code != 0 {
314 t.Fatalf("post-receive exited %d: %s", code, out.String())
315 }
316 line, err := os.ReadFile(audit)
317 if err != nil {
318 t.Fatal(err)
319 }
320 got := string(line)
321 for _, want := range []string{
322 "\tpush\tkeenan\tdemo\trefs/heads/main none -> ",
323 "\tpush\tkeenan\tdemo\trefs/heads/gone ", "-> none",
324 } {
325 if !strings.Contains(got, want) {
326 t.Fatalf("the push log is missing %q:\n%s", want, got)
327 }
328 }
329 if n := strings.Count(got, "\tpush\t"); n != 2 {
330 t.Fatalf("%d push lines for two ref updates:\n%s", n, got)
331 }
332 // The CI spool is still written, and a push with no spool is still recorded.
333 if entries, err := runs.Next(filepath.Join(dir, "spool")); err != nil || len(entries) != 2 {
334 t.Fatalf("spool = %d entries, err %v", len(entries), err)
335 }
336 code = PostReceive(Config{
337 Actor: "keenan", AuditPath: audit, RepoDir: repoDir,
338 In: strings.NewReader("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/main\n"),
339 Out: &out,
340 })
341 if code != 0 {
342 t.Fatalf("a push with no spool dir exited %d", code)
343 }
344}
345
346// A push log that cannot be written must not fail the push: the update already landed, and
347// reporting it as rejected would make the client re-send something that worked.
348func TestPostReceiveSurvivesAnUnwritableLog(t *testing.T) {
349 t.Parallel()
350 dir := t.TempDir()
351 repoDir := filepath.Join(dir, "demo.git")
352 if err := git.InitBare(repoDir, "main"); err != nil {
353 t.Fatal(err)
354 }
355 code := PostReceive(Config{
356 Actor: "keenan", AuditPath: filepath.Join(dir, "no", "such", "dir", "audit.log"),
357 RepoDir: repoDir,
358 In: strings.NewReader("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/main\n"),
359 Out: io.Discard,
360 })
361 if code != 0 {
362 t.Fatalf("an unwritable audit log failed the push (exit %d)", code)
363 }
364}
365
366// A repository can require a linked issue when a change is opened. The rule is
367// checked for requests a push adds; turning it on must not block edits to the
368// requests that were already there.
369func TestNewChangeRequestRequiresALinkedIssueWhenConfigured(t *testing.T) {
370 t.Parallel()
371 f := newFixture(t, "hello\n")
372 f.setACL(func(a *access.ACL) { a.RequireLinkedIssue = true })
373
374 g := f.m.Git()
375 now := time.Now().UTC()
376 iss, err := metadata.NewIssue("A bug", "details\n", "owner", nil, nil, now)
377 if err != nil {
378 t.Fatal(err)
379 }
380 if err := metadata.CreateIssue(g, iss, "owner"); err != nil {
381 t.Fatal(err)
382 }
383 issueTip, err := g.RevParse(metadata.Ref)
384 if err != nil {
385 t.Fatal(err)
386 }
387
388 linked, err := metadata.NewRequest("Linked change", "body\n", "owner", "main", f.head, now)
389 if err != nil {
390 t.Fatal(err)
391 }
392 linked.Closes = []string{iss.ID}
393 if err := metadata.CreateRequest(g, linked, "owner"); err != nil {
394 t.Fatal(err)
395 }
396 linkedTip, err := g.RevParse(metadata.Ref)
397 if err != nil {
398 t.Fatal(err)
399 }
400 if code, out := f.runRef(Config{Actor: "owner"}, issueTip, linkedTip, metadata.Ref); code != 0 {
401 t.Fatalf("a linked request was rejected: %s", out)
402 }
403
404 unlinked, err := metadata.NewRequest("Unlinked change", "body\n", "owner", "main", f.head, now)
405 if err != nil {
406 t.Fatal(err)
407 }
408 if err := metadata.CreateRequest(g, unlinked, "owner"); err != nil {
409 t.Fatal(err)
410 }
411 unlinkedTip, err := g.RevParse(metadata.Ref)
412 if err != nil {
413 t.Fatal(err)
414 }
415 code, out := f.runRef(Config{Actor: "owner"}, linkedTip, unlinkedTip, metadata.Ref)
416 if code == 0 {
417 t.Fatal("an unlinked request was accepted")
418 }
419 if !strings.Contains(out, "requires at least one linked issue") {
420 t.Fatalf("the refusal does not explain the rule: %s", out)
421 }
422}
423
424// pushFixture is a repository with a metadata branch and an index that agrees with
425// it, which is what post-receive sees on a healthy instance.
426func pushFixture(t *testing.T) (*repo.Meta, string) {
427 t.Helper()
428 root := t.TempDir()
429 acls := testACLs(t, root)
430 st := repo.New(filepath.Join(root, "repos"), "/bin/true", acls)
431 m, err := st.Create("demo", "owner", "")
432 if err != nil {
433 t.Fatal(err)
434 }
435 g := m.Git()
436 tree, err := g.MkTree(nil)
437 if err != nil {
438 t.Fatal(err)
439 }
440 base, err := g.CommitTree(tree, "", "initial")
441 if err != nil {
442 t.Fatal(err)
443 }
444 if err := g.UpdateRef("refs/heads/main", base); err != nil {
445 t.Fatal(err)
446 }
447 // The metadata branch starts one commit further along, so a tree walk in the
448 // trace names which of the two it read.
449 first, err := g.CommitTree(tree, base, "metadata")
450 if err != nil {
451 t.Fatal(err)
452 }
453 if err := g.UpdateRef(metadata.Ref, first); err != nil {
454 t.Fatal(err)
455 }
456 path := filepath.Join(root, "index.db")
457 store, err := index.Open(path)
458 if err != nil {
459 t.Fatal(err)
460 }
461 defer store.Close()
462 if err := store.Upsert(m); err != nil {
463 t.Fatal(err)
464 }
465 // A full build, so the default branch summary is current too: post-receive
466 // refreshes it, and a fixture that left it empty would make that refresh read
467 // trees the test is counting.
468 if err := store.IndexRepo(m); err != nil {
469 t.Fatal(err)
470 }
471 return m, path
472}
473
474// followRequest opens a request that follows branch, which is the only reason a push
475// to that branch has metadata work to do.
476func followRequest(t *testing.T, m *repo.Meta, branch, head string) *metadata.Request {
477 t.Helper()
478 req, err := metadata.NewRequest("Land it", "", "keenan", "main", head, time.Now().UTC())
479 if err != nil {
480 t.Fatal(err)
481 }
482 req.HeadRef = branch
483 if err := metadata.CreateRequest(m.Git(), req, "keenan"); err != nil {
484 t.Fatal(err)
485 }
486 return req
487}
488
489// A push to a branch no open request follows moves no metadata, so post-receive must
490// not read the branch or queue a commit behind the people writing in it. The index is
491// what makes the answer cheap; without one, the hook falls back to doing the work.
492func TestPostReceiveOnlySyncsBranchesARequestFollows(t *testing.T) {
493 m, indexDB := pushFixture(t)
494 head := "00112233445566778899aabbccddeeff00112233"
495 req := followRequest(t, m, "feature", head)
496 reindex(t, m, indexDB)
497
498 // Every child git logs its argv, so "was the metadata branch read?" is a question
499 // the test can answer rather than infer.
500 trace := filepath.Join(t.TempDir(), "git.trace")
501 t.Setenv("GIT_TRACE", trace)
502
503 before, err := m.Git().RevParseRaw(metadata.Ref)
504 if err != nil {
505 t.Fatal(err)
506 }
507 push := func(branch, newTip string) {
508 t.Helper()
509 var out bytes.Buffer
510 code := PostReceive(Config{
511 Actor: "keenan",
512 RepoDir: m.Dir,
513 RepoID: m.ID,
514 IndexDB: indexDB,
515 Out: &out,
516 In: strings.NewReader(git.ZeroSHA + " " + newTip + " refs/heads/" + branch + "\n"),
517 })
518 if code != 0 {
519 t.Fatalf("post-receive exited %d: %s", code, out.String())
520 }
521 }
522 reads := func() int {
523 t.Helper()
524 data, err := os.ReadFile(trace)
525 if os.IsNotExist(err) {
526 return 0
527 }
528 if err != nil {
529 t.Fatal(err)
530 }
531 return bytes.Count(data, []byte("ls-tree"))
532 }
533
534 // A branch nobody proposes from: nothing follows it, so the branch is not read.
535 push("other", head)
536 if n := reads(); n != 0 {
537 t.Fatalf("a push to an unfollowed branch read the metadata tree %d time(s)", n)
538 }
539 if after, err := m.Git().RevParseRaw(metadata.Ref); err != nil || after != before {
540 t.Fatalf("a push to an unfollowed branch moved %s: %s -> %s (%v)", metadata.Ref, before, after, err)
541 }
542
543 // The branch a request follows is read, and the push moves that request's head.
544 if err := os.Remove(trace); err != nil {
545 t.Fatal(err)
546 }
547 next := "ffeeddccbbaa99887766554433221100ffeeddcc"
548 push("feature", next)
549 if n := reads(); n == 0 {
550 t.Fatal("a push to a followed branch did not read the metadata tree")
551 }
552 if after, err := m.Git().RevParseRaw(metadata.Ref); err != nil || after == before {
553 t.Fatalf("a push to a followed branch did not move %s", metadata.Ref)
554 }
555 synced, err := metadata.GetRequest(m.Git(), req.ID)
556 if err != nil {
557 t.Fatal(err)
558 }
559 if synced.Head != next {
560 t.Fatalf("the request follows %s, want %s", synced.Head, next)
561 }
562}
563
564// reindex brings the fixture's index up to the metadata branch, the way a web write
565// would have: without it the hook correctly decides it cannot answer from a stale
566// index and reads the branch.
567func reindex(t *testing.T, m *repo.Meta, path string) {
568 t.Helper()
569 store, err := index.Open(path)
570 if err != nil {
571 t.Fatal(err)
572 }
573 defer store.Close()
574 if err := store.Ensure(m); err != nil {
575 t.Fatal(err)
576 }
577}
578
579// With no index to ask, the hook cannot rule a follower out, so it has to read the
580// branch: the sync is worth more than the read.
581func TestPostReceiveSyncsWithoutAnIndex(t *testing.T) {
582 t.Parallel()
583 m, _ := pushFixture(t)
584 head := "00112233445566778899aabbccddeeff00112233"
585 req := followRequest(t, m, "feature", head)
586 before, err := m.Git().RevParseRaw(metadata.Ref)
587 if err != nil {
588 t.Fatal(err)
589 }
590 next := "ffeeddccbbaa99887766554433221100ffeeddcc"
591 var out bytes.Buffer
592 code := PostReceive(Config{
593 Actor: "keenan",
594 RepoDir: m.Dir,
595 RepoID: m.ID,
596 Out: &out,
597 In: strings.NewReader(git.ZeroSHA + " " + next + " refs/heads/feature\n"),
598 })
599 if code != 0 {
600 t.Fatalf("post-receive exited %d: %s", code, out.String())
601 }
602 if after, err := m.Git().RevParseRaw(metadata.Ref); err != nil || after == before {
603 t.Fatalf("%s did not move: %s -> %s (%v)", metadata.Ref, before, after, err)
604 }
605 got, err := metadata.GetRequest(m.Git(), req.ID)
606 if err != nil {
607 t.Fatal(err)
608 }
609 if got.Head != next {
610 t.Fatalf("the request follows %s, want %s", got.Head, next)
611 }
612}
613