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/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 }