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 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.

core/lfs/transfer.go +35−1

@@ -52,7 +52,12 @@ type Transfer struct {
52 52 // Locks is the lock table. Nil means this server offers none, and the `locking`
53 53 // capability is not advertised.
54 54 Locks *LockStore
55 Log *slog.Logger
55 // RepoMaxBytes caps the objects, logs and artifacts one repository may hold.
56 // It is checked on each upload, against what the repository already holds, so
57 // the bytes are refused where they arrive rather than after a push names them.
58 // Zero disables the cap.
59 RepoMaxBytes int64
60 Log *slog.Logger
56 61 }
57 62
58 63 // Serve runs the session until the client quits or the connection ends. op is
@@ -211,6 +216,23 @@ func (t *Transfer) put(op, oid string, pr *pktReader, pw *pktWriter) error {
211 216 size, _ = strconv.ParseInt(v, 10, 64)
212 217 }
213 218 }
219 if t.RepoMaxBytes > 0 && size > 0 {
220 used, err := t.repoBytes()
221 if err != nil {
222 if _, derr := io.Copy(io.Discard, data); derr != nil && err == nil {
223 err = derr
224 }
225 return pw.statusLines(500, nil, []string{"cannot measure the repository's object usage: " + err.Error()})
226 }
227 if used+size > t.RepoMaxBytes {
228 if _, derr := io.Copy(io.Discard, data); derr != nil && err == nil {
229 err = derr
230 }
231 return pw.statusLines(413, nil, []string{fmt.Sprintf(
232 "this repository's objects, logs and artifacts take %d bytes; a %d-byte object would exceed the limit of %d",
233 used, size, t.RepoMaxBytes)})
234 }
235 }
214 236 n, created, err := t.Store.Put(t.Repo, oid, size, data)
215 237 // Drain whatever the client still has queued so the session stays in sync.
216 238 if _, derr := io.Copy(io.Discard, data); derr != nil && err == nil {
@@ -233,6 +255,18 @@ func (t *Transfer) put(op, oid string, pr *pktReader, pw *pktWriter) error {
233 255 return pw.statusLines(200, nil, nil)
234 256 }
235 257
258 // repoBytes is what this repository's objects, logs and artifacts already take. It
259 // is a walk of the repository's links, so it is done once per upload: an upload is
260 // a large object by definition, and the count is what the cap is measured against.
261 func (t *Transfer) repoBytes() (int64, error) {
262 var total int64
263 err := t.Store.RepoWalk(t.Repo, func(_ string, size int64) error {
264 total += size
265 return nil
266 })
267 return total, err
268 }
269
236 270 // get sends one object. `size=` is mandatory on a successful response; the
237 271 // client uses it to size its temp file and rejects the object without it.
238 272 func (t *Transfer) get(oid string, pr *pktReader, pw *pktWriter) error {