Commit 65a1d7220c

65a1d7220c9c588f6e6150b6cd6367c7806c6dae

parent: 65ec67cc47

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:21 UTC

control: apply-suggestion and repo commit-file refuse past the owner's storage quota

Ref #288

Layout: unified · split

internal/control/commitfile.go +3
@@ -72,6 +72,9 @@ func runCommitFile(c *Ctx, args []string) int {
72 "%s requires signed commits; this writes an unsigned one — push a signed commit instead", 72 "%s requires signed commits; this writes an unsigned one — push a signed commit instead",
73 repo.Path()) 73 repo.Path())
74 } 74 }
75 if code := checkStorageQuota(c, repo); code >= 0 {
76 return code
77 }
75 // A commit carries an identity, and an unverified address is not one. 78 // A commit carries an identity, and an unverified address is not one.
76 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID) 79 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
77 if err != nil { 80 if err != nil {
internal/control/quota.go +19
@@ -81,6 +81,25 @@ func checkRepoQuota(c *Ctx) int {
81 return -1 81 return -1
82} 82}
83 83
84// checkStorageQuota refuses a server-side write into repo once its
85// owner's storage quota is used up, the check sshd makes before a push.
86// Repositories an org owns have no quota.
87func checkStorageQuota(c *Ctx, repo store.Repo) int {
88 if repo.OwnerKind != "user" {
89 return -1
90 }
91 limit := ByteLimit(c.Store, limitsOf(c), repo.OwnerID)
92 if limit <= 0 {
93 return -1
94 }
95 if used := OwnedBytes(c.Store, c.Cfg.Server.Root, repo.OwnerID); used >= limit {
96 return c.fail(protocol.ExitDenied,
97 "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit",
98 repo.OwnerName, used, limit)
99 }
100 return -1
101}
102
84func init() { 103func init() {
85 register(Command{Path: []string{"admin", "user", "limits"}, 104 register(Command{Path: []string{"admin", "user", "limits"},
86 Summary: "show or set an account's repository and storage caps (instance admins)", 105 Summary: "show or set an account's repository and storage caps (instance admins)",
internal/control/suggestion.go +3
@@ -283,6 +283,9 @@ func runMRApplySuggestion(c *Ctx, args []string) int {
283 "%s requires signed commits and the server cannot sign one; apply it from a clone, which commits with your own key: gitbay mr apply-suggestion %s %d %d", 283 "%s requires signed commits and the server cannot sign one; apply it from a clone, which commits with your own key: gitbay mr apply-suggestion %s %d %d",
284 source, repo.Path(), mr.Number, threadID) 284 source, repo.Path(), mr.Number, threadID)
285 } 285 }
286 if code := checkStorageQuota(c, src); code >= 0 {
287 return code
288 }
286 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID) 289 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
287 if err != nil { 290 if err != nil {
288 return c.failErr(err) 291 return c.failErr(err)
internal/control/suggestion_test.go +32
@@ -496,3 +496,35 @@ func TestSuggestionsProcessCountPerFile(t *testing.T) {
496 t.Fatalf("git processes: %d for one suggestion, %d for six on the same file", one, six) 496 t.Fatalf("git processes: %d for one suggestion, %d for six on the same file", one, six)
497 } 497 }
498} 498}
499
500// A server-side write stops at the owner's storage quota as a push does:
501// both apply-suggestion and repo commit-file.
502func TestServerWritesHonourStorageQuota(t *testing.T) {
503 f := newSuggestFixture(t, nil)
504 f.verified(f.alice)
505 id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
506 full := func(c *Ctx) {
507 c.Cfg.Limits.WriteRate = -1
508 c.Cfg.Limits.MaxBytesPerUser = 1
509 }
510 before := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature"))
511 code, _, errOut := f.runWith(f.alice, full, "mr", "apply-suggestion", f.repo.Path(), "1", id)
512 if code != protocol.ExitDenied || !strings.Contains(errOut, "storage quota is used up") {
513 t.Errorf("apply-suggestion over quota: exit %d %q", code, errOut)
514 }
515 code, _, errOut = f.runWith(f.alice, func(c *Ctx) { full(c); c.Stdin = strings.NewReader("x\n") },
516 "repo", "commit-file", f.repo.Path(), "new.txt", "--ref", "feature", "--file", "-")
517 if code != protocol.ExitDenied || !strings.Contains(errOut, "storage quota is used up") {
518 t.Errorf("commit-file over quota: exit %d %q", code, errOut)
519 }
520 if after := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature")); after != before {
521 t.Fatal("a refused write moved the branch")
522 }
523 // Under the quota both go through.
524 f.mustWrite(f.alice, "mr", "apply-suggestion", f.repo.Path(), "1", id)
525 code, _, errOut = f.runWith(f.alice, func(c *Ctx) { unlimited(c); c.Stdin = strings.NewReader("x\n") },
526 "repo", "commit-file", f.repo.Path(), "new.txt", "--ref", "feature", "--file", "-")
527 if code != protocol.ExitOK {
528 t.Errorf("commit-file under quota: exit %d %q", code, errOut)
529 }
530}