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 and stream the anonymous read path
4581690d1ae9d87c272428d101a0fbe3538d7f44 · Keenan Nemetz · 13d ago · parent e05f1c48
Bound and stream the anonymous read path Every anonymous read path read a whole git object before deciding what to do with it: the code browser truncated after allocating, the overview rendered the README it had just read in full, and the site pool allocated the entire object for every request. `max_blob_bytes` allows a 100 MiB blob, so one pushed file made every concurrent request for it a 100 MiB allocation, with no account needed to ask for it. Read the header first instead. `core/git` gains `BlobSize`, `BlobSizes` (one `cat-file --batch-check` for a whole tree) and `BlobStream`, which parses the batch header before touching the payload and stops the process when a caller wants only a prefix. Then use it: `/raw` streams with the object's own `Content-Length`, the inline viewer reads a one-MiB prefix and reports the file's real size, the README is capped and named instead of rendered when it is too large, site blobs are capped at 25 MiB with a 413, and the commit and tree objects read to resolve a site path are capped separately. Metadata gets the same treatment on both sides: 4 MiB per file, 2 MiB per body, 20,000 files and 64 MiB for `issues/` and `requests/` together. The loader asks git for every size in one call and refuses the tree before reading it; the push boundary refuses the same tree, so the author learns why instead of finding that their issue list stopped loading. Counting is now per subtree, so the machine written `runs/` tree no longer eats the issue file budget.
metadata/limits_test.go +130
| @@ -0,0 +1,130 @@ | |||
| 1 | package metadata | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | "time" | ||
| 7 | |||
| 8 | "github.com/rodent-software/vole/core/git" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // limitsRepo is a bare repository with an empty metadata branch. | ||
| 12 | func limitsRepo(t *testing.T) *git.Repo { | ||
| 13 | t.Helper() | ||
| 14 | root := t.TempDir() | ||
| 15 | if err := git.InitBare(root, "main"); err != nil { | ||
| 16 | t.Fatal(err) | ||
| 17 | } | ||
| 18 | g := git.Open(root) | ||
| 19 | seed, err := g.CommitTree(mustTree(t, g), "", "seed") | ||
| 20 | if err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | if err := g.UpdateRef(Ref, seed); err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | return g | ||
| 27 | } | ||
| 28 | |||
| 29 | // putIssueFile writes raw issue bytes exactly as a hand-edited push would, which | ||
| 30 | // is the only way to produce a metadata branch the writers would have refused. | ||
| 31 | func putIssueFile(t *testing.T, g *git.Repo, path string, body []byte) string { | ||
| 32 | t.Helper() | ||
| 33 | commit, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 34 | files[path] = body | ||
| 35 | return nil | ||
| 36 | }, "issue: hand written", "keenan") | ||
| 37 | if err != nil { | ||
| 38 | t.Fatal(err) | ||
| 39 | } | ||
| 40 | return commit | ||
| 41 | } | ||
| 42 | |||
| 43 | func issueDoc(title, author, body string) []byte { | ||
| 44 | return []byte("---\nid: 01M5JR2JK29KKMAA1T9MDNTPBZ\ntitle: " + title + | ||
| 45 | "\nstate: open\nauthor: " + author + "\ncreated: 2026-10-27T12:00:00Z\n---\n\n" + body) | ||
| 46 | } | ||
| 47 | |||
| 48 | // TestLoadIssuesRefusesAnOversizedFile is the read-side half of the limit: an | ||
| 49 | // anonymous page render must not allocate a file a contributor pushed. Before | ||
| 50 | // the cap, LoadIssues read every blob in the tree in full. | ||
| 51 | func TestLoadIssuesRefusesAnOversizedFile(t *testing.T) { | ||
| 52 | t.Parallel() | ||
| 53 | g := limitsRepo(t) | ||
| 54 | huge := strings.Repeat("x", MaxFileBytes+1) | ||
| 55 | putIssueFile(t, g, IssuesDir+"/01M5JR2JK29KKMAA1T9MDNTPBZ/issue.md", | ||
| 56 | issueDoc("huge", "keenan", huge)) | ||
| 57 | |||
| 58 | _, err := LoadIssues(g, Ref) | ||
| 59 | if err == nil { | ||
| 60 | t.Fatal("LoadIssues read a file past MaxFileBytes") | ||
| 61 | } | ||
| 62 | if !strings.Contains(err.Error(), "limit") { | ||
| 63 | t.Fatalf("refusal did not name the limit: %v", err) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | // TestLoadRequestsRefusesAnOversizedFile is the same rule for change requests, | ||
| 68 | // which had no entry cap at all before this. | ||
| 69 | func TestLoadRequestsRefusesAnOversizedFile(t *testing.T) { | ||
| 70 | t.Parallel() | ||
| 71 | g := limitsRepo(t) | ||
| 72 | huge := strings.Repeat("x", MaxFileBytes+1) | ||
| 73 | putIssueFile(t, g, RequestsDir+"/01M5JR2JK29KKMAA1T9MDNTPBZ/request.md", | ||
| 74 | []byte("---\nid: 01M5JR2JK29KKMAA1T9MDNTPBZ\ntitle: huge\nauthor: keenan\nbase: main\nhead: "+ | ||
| 75 | strings.Repeat("a", 40)+"\ncreated: 2026-10-27T12:00:00Z\n---\n\n"+huge)) | ||
| 76 | |||
| 77 | if _, err := LoadRequests(g, Ref); err == nil { | ||
| 78 | t.Fatal("LoadRequests read a file past MaxFileBytes") | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | // TestValidateIssuesRefusesAnOversizedTree is the push-side half: pre-receive | ||
| 83 | // runs this on the pushed commit, so the author learns why instead of finding | ||
| 84 | // that the issue list stopped loading. | ||
| 85 | func TestValidateIssuesRefusesAnOversizedTree(t *testing.T) { | ||
| 86 | t.Parallel() | ||
| 87 | g := limitsRepo(t) | ||
| 88 | commit := putIssueFile(t, g, IssuesDir+"/01M5JR2JK29KKMAA1T9MDNTPBZ/issue.md", | ||
| 89 | issueDoc("huge", "keenan", strings.Repeat("x", MaxFileBytes+1))) | ||
| 90 | |||
| 91 | problems := ValidateIssues(g, commit) | ||
| 92 | if len(problems) == 0 { | ||
| 93 | t.Fatal("ValidateIssues accepted a file past MaxFileBytes") | ||
| 94 | } | ||
| 95 | if !strings.Contains(problems[0], "limit") { | ||
| 96 | t.Fatalf("rejection did not name the limit: %v", problems) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | // TestOversizedBodyIsNotWritten keeps the CLI and the web form in step with the | ||
| 101 | // push boundary: a body that cannot be pushed should not be committable either. | ||
| 102 | func TestOversizedBodyIsNotWritten(t *testing.T) { | ||
| 103 | t.Parallel() | ||
| 104 | iss, err := NewIssue("title", strings.Repeat("x", MaxBodyBytes+1), "keenan", nil, nil, | ||
| 105 | time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)) | ||
| 106 | if err != nil { | ||
| 107 | t.Fatal(err) | ||
| 108 | } | ||
| 109 | if err := validateIssueForWrite(iss); err == nil { | ||
| 110 | t.Fatal("an oversized issue body passed local validation") | ||
| 111 | } | ||
| 112 | if _, err := NewIssueComment(strings.Repeat("x", MaxBodyBytes+1), "keenan", time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)); err == nil { | ||
| 113 | t.Fatal("an oversized comment body passed local validation") | ||
| 114 | } | ||
| 115 | if _, err := NewRequestComment(strings.Repeat("x", MaxBodyBytes+1), "keenan", time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)); err == nil { | ||
| 116 | t.Fatal("an oversized request comment passed local validation") | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | // TestParseRefusesOversizedBody covers a body that arrives in a file rather than | ||
| 121 | // through a writer: the parse path is what pre-receive uses. | ||
| 122 | func TestParseRefusesOversizedBody(t *testing.T) { | ||
| 123 | t.Parallel() | ||
| 124 | _, prob := ParseComment("01M5JR2JK29KKMAA1T9MDNTPBZ", | ||
| 125 | "---\nauthor: keenan\ncreated: 2026-10-27T12:00:00Z\n---\n\n"+strings.Repeat("x", MaxBodyBytes+1), | ||
| 126 | "issues/01M5JR2JK29KKMAA1T9MDNTPBZ/comments/01M5JR2JK29KKMAA1T9MDNTPBZ.md") | ||
| 127 | if prob == "" { | ||
| 128 | t.Fatal("ParseComment accepted a body past MaxBodyBytes") | ||
| 129 | } | ||
| 130 | } | ||