vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | package hook |
| 2 | |
| 3 | // Quotas. A repository is a place several people write to, and every one of them |
| 4 | // can choose how much: a contributor can add branches in their own namespace, any |
| 5 | // writer can add issues and comments, and a maintainer can add tags. Without a |
| 6 | // bound, one account can make every anonymous page read slowly or fill the disk, |
| 7 | // and the instance's only answer is an admin deleting things by hand. |
| 8 | // |
| 9 | // The checks run in pre-receive, where a refusal moves no refs. They are counts at |
| 10 | // the *new* tip, so they describe the repository the push would leave behind, not |
| 11 | // the one it found. The one thing that cannot be seen here is bytes still in the |
| 12 | // quarantine; that is why a push is also bounded by receive_max_bytes, and why the |
| 13 | // size check refuses a repository that is already over its cap: growth is bounded |
| 14 | // by the cap plus one permitted push. |
| 15 | |
| 16 | import ( |
| 17 | "fmt" |
| 18 | "strings" |
| 19 | |
| 20 | "rodent.software/vole/core/git" |
| 21 | "rodent.software/vole/internal/format" |
| 22 | "rodent.software/vole/metadata" |
| 23 | ) |
| 24 | |
| 25 | // quotaProblems reports every limit this push would break. An empty result means |
| 26 | // the push is inside every quota. |
| 27 | func quotaProblems(cfg Config, g *git.Repo, changes []change) []string { |
| 28 | var problems []string |
| 29 | |
| 30 | if cfg.MaxRefs > 0 { |
| 31 | if n, err := refsAfter(g, changes); err != nil { |
| 32 | problems = append(problems, fmt.Sprintf("cannot count refs to check the repository quota: %v", err)) |
| 33 | } else if n > cfg.MaxRefs { |
| 34 | problems = append(problems, fmt.Sprintf( |
| 35 | "this push would leave %d branches and tags, over the limit of %d; delete some or raise repo_max_refs", |
| 36 | n, cfg.MaxRefs)) |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | if cfg.MaxMetadataEntries > 0 || cfg.MaxMetadataBytes > 0 { |
| 41 | if tip := metadataAfter(changes); tip != "" { |
| 42 | entries, bytes, err := metadataSize(g, tip) |
| 43 | if err != nil { |
| 44 | problems = append(problems, fmt.Sprintf("cannot measure the metadata branch to check its quota: %v", err)) |
| 45 | } else { |
| 46 | if cfg.MaxMetadataEntries > 0 && entries > cfg.MaxMetadataEntries { |
| 47 | problems = append(problems, fmt.Sprintf( |
| 48 | "%s would hold %d issues, requests, runs and releases, over the limit of %d; raise repo_max_metadata_entries or remove some", |
| 49 | metadata.Ref, entries, cfg.MaxMetadataEntries)) |
| 50 | } |
| 51 | if cfg.MaxMetadataBytes > 0 && bytes > cfg.MaxMetadataBytes { |
| 52 | problems = append(problems, fmt.Sprintf( |
| 53 | "%s would hold %s, over the limit of %s; raise repo_max_metadata_bytes or remove some", |
| 54 | metadata.Ref, format.Bytes(bytes), format.Bytes(cfg.MaxMetadataBytes))) |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | if cfg.MaxRepoBytes > 0 { |
| 61 | if used, err := g.DiskUsage(); err != nil { |
| 62 | problems = append(problems, fmt.Sprintf("cannot measure the repository to check its quota: %v", err)) |
| 63 | } else if used > cfg.MaxRepoBytes { |
| 64 | problems = append(problems, fmt.Sprintf( |
| 65 | "this repository holds %s, over the limit of %s; an admin can run `vole admin gc` or raise repo_max_bytes", |
| 66 | format.Bytes(used), format.Bytes(cfg.MaxRepoBytes))) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | if cfg.MaxLFSBytes > 0 && cfg.LFS != nil && cfg.RepoID != "" { |
| 71 | if used, err := lfsUsage(cfg.LFS, cfg.RepoID); err != nil { |
| 72 | problems = append(problems, fmt.Sprintf("cannot measure the object store to check its quota: %v", err)) |
| 73 | } else if used > cfg.MaxLFSBytes { |
| 74 | problems = append(problems, fmt.Sprintf( |
| 75 | "this repository's LFS objects, logs and artifacts take %s, over the limit of %s; raise repo_max_lfs_bytes or remove some", |
| 76 | format.Bytes(used), format.Bytes(cfg.MaxLFSBytes))) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | return problems |
| 81 | } |
| 82 | |
| 83 | // refsAfter is the number of branches and tags the repository would hold if this |
| 84 | // push landed: what is there now, plus the refs the push creates, minus the ones it |
| 85 | // deletes. Pre-receive sees the refs as they were, so the change list is what makes |
| 86 | // the number describe the outcome. |
| 87 | func refsAfter(g *git.Repo, changes []change) (int, error) { |
| 88 | now, err := g.RefCount() |
| 89 | if err != nil { |
| 90 | return 0, err |
| 91 | } |
| 92 | for _, c := range changes { |
| 93 | if !strings.HasPrefix(c.ref, "refs/heads/") && !strings.HasPrefix(c.ref, "refs/tags/") { |
| 94 | continue |
| 95 | } |
| 96 | switch { |
| 97 | case c.old == "" || c.old == git.ZeroSHA: |
| 98 | if c.new != "" && c.new != git.ZeroSHA { |
| 99 | now++ |
| 100 | } |
| 101 | case c.new == "" || c.new == git.ZeroSHA: |
| 102 | if now > 0 { |
| 103 | now-- |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | return now, nil |
| 108 | } |
| 109 | |
| 110 | // metadataAfter is the metadata tip the push would leave, or "" when it does not |
| 111 | // touch that branch (or deletes it). |
| 112 | func metadataAfter(changes []change) string { |
| 113 | for _, c := range changes { |
| 114 | if c.ref == metadata.Ref && c.new != "" && c.new != git.ZeroSHA { |
| 115 | return c.new |
| 116 | } |
| 117 | } |
| 118 | return "" |
| 119 | } |
| 120 | |
| 121 | // metadataSize counts what a page load on a repository has to parse: every issue, |
| 122 | // request, comment and approval file, and their bytes. |
| 123 | func metadataSize(g *git.Repo, tip string) (entries int, bytes int64, err error) { |
| 124 | es, err := g.LsTreeSizes(tip) |
| 125 | if err != nil { |
| 126 | return 0, 0, err |
| 127 | } |
| 128 | for _, e := range es { |
| 129 | if e.Type != "blob" { |
| 130 | continue |
| 131 | } |
| 132 | // Everything the metadata branch carries as a document: issues and their |
| 133 | // comments, requests with approvals and comments, and the run and release |
| 134 | // records a runner or a maintainer adds. These are what a page load parses. |
| 135 | for _, dir := range []string{ |
| 136 | metadata.IssuesDir, metadata.ChangesDir, metadata.ReleasesDir, |
| 137 | } { |
| 138 | if strings.HasPrefix(e.Name, dir+"/") { |
| 139 | entries++ |
| 140 | bytes += e.Size |
| 141 | break |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | return entries, bytes, nil |
| 146 | } |
| 147 | |
| 148 | func lfsUsage(store lfsStore, repoID string) (int64, error) { |
| 149 | var total int64 |
| 150 | err := store.RepoWalk(repoID, func(_ string, size int64) error { |
| 151 | total += size |
| 152 | return nil |
| 153 | }) |
| 154 | return total, err |
| 155 | } |
| 156 | |
| 157 | // lfsStore is the part of the object store the quota check needs. It is an |
| 158 | // interface so a test can answer without a directory. |
| 159 | type lfsStore interface { |
| 160 | RepoWalk(repo string, fn func(oid string, size int64) error) error |
| 161 | } |
| 162 |