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.
server/pages/catfile.go +28−7
| @@ -56,9 +56,12 @@ type object struct { | |||
| 56 | 56 | Data []byte | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | // read resolves rev and returns its content. rev may be a ref name, which is how | ||
| 60 | // the pool answers "what commit is this branch on" without a second subprocess. | ||
| 61 | func (p *objectPool) read(rev string) (object, error) { | ||
| 59 | // read resolves rev and returns its content, refusing anything past max bytes. | ||
| 60 | // rev may be a ref name, which is how the pool answers "what commit is this | ||
| 61 | // branch on" without a second subprocess. A refused object is never allocated: | ||
| 62 | // its size is in the batch header, so the answer costs one header line and the | ||
| 63 | // process is dropped rather than drained. | ||
| 64 | func (p *objectPool) read(rev string, max int64) (object, error) { | ||
| 62 | 65 | if strings.ContainsAny(rev, "\n\r") { | |
| 63 | 66 | return object{}, fmt.Errorf("pages: malformed revision %q", rev) | |
| 64 | 67 | } | |
| @@ -66,7 +69,7 @@ func (p *objectPool) read(rev string) (object, error) { | |||
| 66 | 69 | proc := p.procs[p.next%len(p.procs)] | |
| 67 | 70 | p.next++ | |
| 68 | 71 | p.mu.Unlock() | |
| 69 | return proc.read(rev) | ||
| 72 | return proc.read(rev, max) | ||
| 70 | 73 | } | |
| 71 | 74 | ||
| 72 | 75 | func (p *objectPool) Close() { | |
| @@ -75,7 +78,7 @@ func (p *objectPool) Close() { | |||
| 75 | 78 | } | |
| 76 | 79 | } | |
| 77 | 80 | ||
| 78 | func (proc *catFileProc) read(rev string) (object, error) { | ||
| 81 | func (proc *catFileProc) read(rev string, max int64) (object, error) { | ||
| 79 | 82 | proc.mu.Lock() | |
| 80 | 83 | defer proc.mu.Unlock() | |
| 81 | 84 | if proc.dead || proc.cmd == nil { | |
| @@ -105,6 +108,13 @@ func (proc *catFileProc) read(rev string) (object, error) { | |||
| 105 | 108 | proc.dead = true | |
| 106 | 109 | return object{}, fmt.Errorf("pages: bad object size in %q", line) | |
| 107 | 110 | } | |
| 111 | if max > 0 && size > max { | ||
| 112 | // The object is still pending on the stream. Killing the process is | ||
| 113 | // cheaper and safer than reading a blob we are not going to keep: the next | ||
| 114 | // read starts a fresh one. | ||
| 115 | proc.stop() | ||
| 116 | return object{}, fmt.Errorf("%w: %s is %d bytes, limit %d", errObjectTooLarge, rev, size, max) | ||
| 117 | } | ||
| 108 | 118 | data := make([]byte, size) | |
| 109 | 119 | if _, err := io.ReadFull(proc.out, data); err != nil { | |
| 110 | 120 | proc.dead = true | |
| @@ -141,11 +151,22 @@ func (proc *catFileProc) start() error { | |||
| 141 | 151 | func (proc *catFileProc) close() { | |
| 142 | 152 | proc.mu.Lock() | |
| 143 | 153 | defer proc.mu.Unlock() | |
| 154 | proc.stop() | ||
| 155 | } | ||
| 156 | |||
| 157 | // stop kills the process behind the stream, leaving it to restart lazily. The | ||
| 158 | // caller holds proc.mu. | ||
| 159 | func (proc *catFileProc) stop() { | ||
| 144 | 160 | if proc.cmd == nil { | |
| 161 | proc.dead = true | ||
| 145 | 162 | return | |
| 146 | 163 | } | |
| 147 | proc.in.Close() | ||
| 148 | proc.cmd.Process.Kill() | ||
| 164 | if proc.in != nil { | ||
| 165 | proc.in.Close() | ||
| 166 | } | ||
| 167 | if proc.cmd.Process != nil { | ||
| 168 | proc.cmd.Process.Kill() | ||
| 169 | } | ||
| 149 | 170 | proc.cmd.Wait() | |
| 150 | 171 | proc.cmd, proc.in, proc.out, proc.dead = nil, nil, nil, true | |
| 151 | 172 | } | |