vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Bound what one repository and one account can cost
9e8cc1553e7b4479e0d9a3d7afd044a0755423ba · Keenan Nemetz · 13d ago · parent 701232cf
Bound what one repository and one account can cost
A repository had no quotas. A contributor could add branches in their own
namespace without limit, any writer could add issues and comments, a
maintainer could add tags, and every page that listed or parsed them grew
with the count. The instance's only answer was an admin deleting things
by hand.
Repository quotas now run in pre-receive, so a refused push moves no
refs, and they describe the repository the push would leave rather than
the one it found:
repo_max_refs branches and tags, counted with the refs the
push creates and deletes
repo_max_metadata_entries documents on the metadata branch: issues,
requests, comments, approvals, runs, releases
repo_max_metadata_bytes their total size
repo_max_bytes git objects on disk; a repository already
over it refuses further pushes, so growth is
the cap plus one permitted push
repo_max_lfs_bytes LFS objects, logs and artifacts, enforced on
the upload where the bytes arrive, with 413
The values reach the hook the same way the blob limit does: from the
instance config through the SSH server's environment, so a quota is one
setting rather than a second policy.
Browser writes are budgeted per account (`account_writes_per_hour`, 300).
The limiter is keyed by the account rather than the address, because the
account is what a write is attributed to; a person discussing never
reaches it.
Reads that a repository's own writers control are capped: for-each-ref
listings (branches, tags) return at most 500, while the quota check uses
a count that sees all of them. Activity feeds and state changes were
already bounded.
Open registration's baseline is unchanged (off, role none) and its role
is now restricted to none, read or contribute: a stranger who typed a
name must not be able to move a shared branch or approve a review.
Tests: refs (a new ref is counted, moving one is not), metadata entries
and bytes (a tree with two issues refuses at one, and passes at a
generous limit), repository size, and the LFS quota against a real store;
the transfer refuses a 413 upload over the object cap; the account write
budget; and the registration role refusal.
server/hook/quotas_test.go +171
| @@ -0,0 +1,171 @@ | |||
| 1 | package hook | ||
| 2 | |||
| 3 | // Quotas are the bound a repository's own writers cannot raise. Each check is | ||
| 4 | // exercised with the limit set around what the fixture holds, so the refusal is | ||
| 5 | // about the quota and nothing else. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "bytes" | ||
| 9 | "crypto/sha256" | ||
| 10 | "encoding/hex" | ||
| 11 | "strings" | ||
| 12 | "testing" | ||
| 13 | |||
| 14 | "github.com/rodent-software/vole/core/git" | ||
| 15 | "github.com/rodent-software/vole/core/lfs" | ||
| 16 | "github.com/rodent-software/vole/metadata" | ||
| 17 | ) | ||
| 18 | |||
| 19 | // runRef runs pre-receive for one ref update with the old value a real push would | ||
| 20 | // carry, which is what the ref quota counts against. | ||
| 21 | func (f *fixture) runRef(cfg Config, old, next, ref string) (int, string) { | ||
| 22 | f.t.Helper() | ||
| 23 | cfg.RepoDir = f.m.Dir | ||
| 24 | if cfg.UsersDB == "" { | ||
| 25 | cfg.UsersDB = f.usersDB | ||
| 26 | } | ||
| 27 | var out bytes.Buffer | ||
| 28 | cfg.In = strings.NewReader(old + " " + next + " " + ref + "\n") | ||
| 29 | cfg.Out = &out | ||
| 30 | return PreReceive(cfg), out.String() | ||
| 31 | } | ||
| 32 | |||
| 33 | func TestRefQuotaRefusesAPushThatWouldExceedIt(t *testing.T) { | ||
| 34 | t.Parallel() | ||
| 35 | f := newFixture(t, "hello\n") | ||
| 36 | g := f.m.Git() | ||
| 37 | if err := g.UpdateRef("refs/heads/main", f.head); err != nil { | ||
| 38 | t.Fatal(err) | ||
| 39 | } | ||
| 40 | // The repository holds one branch, so a limit of one refuses a second. | ||
| 41 | code, out := f.runRef(Config{Actor: "owner", MaxRefs: 1}, git.ZeroSHA, f.head, "refs/heads/topic") | ||
| 42 | if code == 0 { | ||
| 43 | t.Fatalf("a push over the ref quota was accepted:\n%s", out) | ||
| 44 | } | ||
| 45 | if !strings.Contains(out, "repo_max_refs") { | ||
| 46 | t.Fatalf("the refusal does not name the quota:\n%s", out) | ||
| 47 | } | ||
| 48 | // Moving an existing branch is not a new ref. | ||
| 49 | tree, err := g.MkTree([]git.Entry{}) | ||
| 50 | if err != nil { | ||
| 51 | t.Fatal(err) | ||
| 52 | } | ||
| 53 | next, err := g.CommitTree(tree, f.head, "second") | ||
| 54 | if err != nil { | ||
| 55 | t.Fatal(err) | ||
| 56 | } | ||
| 57 | if code, out := f.runRef(Config{Actor: "owner", MaxRefs: 1}, f.head, next, "refs/heads/main"); code != 0 { | ||
| 58 | t.Fatalf("updating an existing branch was counted as a new ref:\n%s", out) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | // metadataTip builds a metadata commit holding n issue files of roughly size bytes. | ||
| 63 | func (f *fixture) metadataTip(t *testing.T, n, size int) string { | ||
| 64 | t.Helper() | ||
| 65 | g := f.m.Git() | ||
| 66 | empty, err := g.MkTree(nil) | ||
| 67 | if err != nil { | ||
| 68 | t.Fatal(err) | ||
| 69 | } | ||
| 70 | base, err := g.CommitTree(empty, "", "metadata") | ||
| 71 | if err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | if err := g.UpdateRef(metadata.Ref, base); err != nil { | ||
| 75 | t.Fatal(err) | ||
| 76 | } | ||
| 77 | defer f.m.Git().UpdateRef(metadata.Ref, base) | ||
| 78 | body := "---\ntitle: t\n---\n" + strings.Repeat("x", size) + "\n" | ||
| 79 | var entries []git.Entry | ||
| 80 | for i := 0; i < n; i++ { | ||
| 81 | blob, err := g.HashObject([]byte(body)) | ||
| 82 | if err != nil { | ||
| 83 | t.Fatal(err) | ||
| 84 | } | ||
| 85 | tree, err := g.MkTree([]git.Entry{{Mode: "100644", Type: "blob", SHA: blob, Name: "issue.md"}}) | ||
| 86 | if err != nil { | ||
| 87 | t.Fatal(err) | ||
| 88 | } | ||
| 89 | entries = append(entries, git.Entry{ | ||
| 90 | Mode: "040000", Type: "tree", SHA: tree, | ||
| 91 | Name: "01M5ISSUE000000000000000" + string(rune('A'+i)), | ||
| 92 | }) | ||
| 93 | } | ||
| 94 | issues, err := g.MkTree([]git.Entry{{Mode: "040000", Type: "tree", SHA: mustTree(t, g, entries), Name: metadata.IssuesDir}}) | ||
| 95 | if err != nil { | ||
| 96 | t.Fatal(err) | ||
| 97 | } | ||
| 98 | tip, err := g.CommitTree(issues, base, "issues") | ||
| 99 | if err != nil { | ||
| 100 | t.Fatal(err) | ||
| 101 | } | ||
| 102 | return tip | ||
| 103 | } | ||
| 104 | |||
| 105 | func mustTree(t *testing.T, g *git.Repo, entries []git.Entry) string { | ||
| 106 | t.Helper() | ||
| 107 | sha, err := g.MkTree(entries) | ||
| 108 | if err != nil { | ||
| 109 | t.Fatal(err) | ||
| 110 | } | ||
| 111 | return sha | ||
| 112 | } | ||
| 113 | |||
| 114 | func TestMetadataQuotaCountsEntriesAndBytes(t *testing.T) { | ||
| 115 | t.Parallel() | ||
| 116 | f := newFixture(t, "hello\n") | ||
| 117 | tip := f.metadataTip(t, 2, 100) | ||
| 118 | |||
| 119 | // Two entries are over a limit of one, and the refusal names the quota. | ||
| 120 | code, out := f.runRef(Config{Actor: "owner", MaxMetadataEntries: 1}, git.ZeroSHA, tip, metadata.Ref) | ||
| 121 | if code == 0 || !strings.Contains(out, "repo_max_metadata_entries") { | ||
| 122 | t.Fatalf("the metadata entry quota did not refuse (code %d):\n%s", code, out) | ||
| 123 | } | ||
| 124 | |||
| 125 | // The byte quota catches the same tree when the entry count is generous. | ||
| 126 | code, out = f.runRef(Config{Actor: "owner", MaxMetadataBytes: 10}, git.ZeroSHA, tip, metadata.Ref) | ||
| 127 | if !strings.Contains(out, "repo_max_metadata_bytes") { | ||
| 128 | t.Fatalf("the metadata byte quota did not refuse (code %d):\n%s", code, out) | ||
| 129 | } | ||
| 130 | |||
| 131 | // A generous quota says nothing about metadata. | ||
| 132 | if _, out := f.runRef(Config{Actor: "owner", MaxMetadataEntries: 100, MaxMetadataBytes: 1 << 20}, | ||
| 133 | git.ZeroSHA, tip, metadata.Ref); strings.Contains(out, "repo_max_metadata") { | ||
| 134 | t.Fatalf("a push inside the quota was refused by it:\n%s", out) | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | func TestRepoSizeQuotaRefusesAFullRepository(t *testing.T) { | ||
| 139 | t.Parallel() | ||
| 140 | f := newFixture(t, strings.Repeat("hello\n", 1024)) | ||
| 141 | if err := f.m.Git().UpdateRef("refs/heads/main", f.head); err != nil { | ||
| 142 | t.Fatal(err) | ||
| 143 | } | ||
| 144 | code, out := f.runRef(Config{Actor: "owner", MaxRepoBytes: 1}, f.head, f.head, "refs/heads/main") | ||
| 145 | if code == 0 { | ||
| 146 | t.Fatalf("a repository over its size quota accepted a push:\n%s", out) | ||
| 147 | } | ||
| 148 | if !strings.Contains(out, "repo_max_bytes") { | ||
| 149 | t.Fatalf("the refusal does not name the quota:\n%s", out) | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | func TestLFSQuotaRefusesAFullStore(t *testing.T) { | ||
| 154 | t.Parallel() | ||
| 155 | f := newFixture(t, "hello\n") | ||
| 156 | store := lfs.New(t.TempDir(), 0) | ||
| 157 | body := []byte("an object") | ||
| 158 | sum := sha256.Sum256(body) | ||
| 159 | oid := hex.EncodeToString(sum[:]) | ||
| 160 | if _, _, err := store.Put(f.m.ID, oid, int64(len(body)), strings.NewReader(string(body))); err != nil { | ||
| 161 | t.Fatal(err) | ||
| 162 | } | ||
| 163 | code, out := f.runRef(Config{Actor: "owner", MaxLFSBytes: 1, LFS: store, RepoID: f.m.ID}, | ||
| 164 | git.ZeroSHA, f.head, "refs/heads/main") | ||
| 165 | if code == 0 { | ||
| 166 | t.Fatalf("a repository over its object quota accepted a push:\n%s", out) | ||
| 167 | } | ||
| 168 | if !strings.Contains(out, "repo_max_lfs_bytes") { | ||
| 169 | t.Fatalf("the refusal does not name the quota:\n%s", out) | ||
| 170 | } | ||
| 171 | } | ||