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.

Code

396 commit(s) on main

Turn off the disk reserve for test-spawned runners Keenan Nemetz · 2h ago c284cb2c
history blame raw 13.4 KiB
1// Package repo owns the on-disk bare-repo store and the metadata refs that live
2// inside each repository.
3package repo
4
5import (
6 "crypto/rand"
7 "encoding/hex"
8 "fmt"
9 "os"
10 "path/filepath"
11 "regexp"
12 "sort"
13 "strconv"
14 "strings"
15 "time"
16
17 "rodent.software/vole/core/git"
18 "rodent.software/vole/forge/access"
19 "rodent.software/vole/forge/secret"
20)
21
22const (
23 ACLRef = "refs/vole/acl"
24 ACLPath = "access.json"
25 idFile = "vole-id"
26 descFile = "description"
27)
28
29var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
30
31// reservedNames are the root paths the forge itself must keep. A repository owns
32// the root of the URL space otherwise (/<name> is its page and /<name>.git its
33// transport), so these are the only names it cannot take. The forge's own pages
34// are all below /-/ and cannot collide with a name at all: a repository may be
35// called "admin" or "u".
36var reservedNames = map[string]string{
37 "static": "the asset route /-/static/ serves from the root",
38 "robots.txt": "crawlers only look for /robots.txt at the root",
39 "sitemap.xml": "crawlers only look for /sitemap.xml at the root",
40 "sitemap-pages.xml": "crawlers only look for /sitemap-pages.xml at the root",
41}
42
43// ValidateName enforces a repo naming scheme that is safe as a directory name
44// and as a URL path segment.
45func ValidateName(name string) error {
46 if !nameRE.MatchString(name) {
47 return fmt.Errorf("invalid repo name %q: use lowercase letters, digits, dot, dash or underscore, starting with a letter or digit (max 63)", name)
48 }
49 if strings.HasSuffix(name, ".git") {
50 return fmt.Errorf("invalid repo name %q: drop the .git suffix", name)
51 }
52 if strings.Contains(name, "..") {
53 return fmt.Errorf("invalid repo name %q", name)
54 }
55 if why, ok := reservedNames[name]; ok {
56 return fmt.Errorf("invalid repo name %q: %s; choose another name", name, why)
57 }
58 return nil
59}
60
61type Store struct {
62 Root string
63 BinPath string
64 // MaxInputBytes is written into each repo as git's receive.maxInputSize, so
65 // git enforces the push cap on every transport instead of the proxy
66 // buffering to count bytes.
67 MaxInputBytes int64
68 // ACLs is where access rules live. They are not in the repository: they are
69 // configuration the instance owns, keyed by account name, read on every push and
70 // every request (see the access package).
71 ACLs *access.Store
72 // Secrets is where the repository's sealed values live, keyed by the same stable
73 // id the rules use. A deleted repository takes its secrets with it.
74 Secrets *secret.Store
75}
76
77func New(root, binPath string, acls *access.Store) *Store {
78 return &Store{Root: root, BinPath: binPath, ACLs: acls}
79}
80
81// Meta is a repository's identity plus the facts the web layer needs.
82type Meta struct {
83 ID string
84 Name string
85 Dir string
86 Created time.Time
87 Description string
88 DefaultBranch string
89 // ACLs is the store this repository's rules are read from. It is copied in from
90 // the Store so a Meta always knows where its rules live.
91 ACLs *access.Store
92}
93
94func (s *Store) path(name string) string { return filepath.Join(s.Root, name+".git") }
95
96// Create initialises a bare repo, stamps it with a stable id and seeds its ACL
97// so the creator is admin.
98func (s *Store) Create(name, owner, description string) (*Meta, error) {
99 if err := ValidateName(name); err != nil {
100 return nil, err
101 }
102 dir := s.path(name)
103 if _, err := os.Stat(dir); err == nil {
104 return nil, fmt.Errorf("repo %q already exists", name)
105 }
106 if err := git.InitBare(dir, "main"); err != nil {
107 return nil, err
108 }
109 g := git.Open(dir)
110 if s.MaxInputBytes > 0 {
111 if err := g.SetConfig("receive.maxInputSize", strconv.FormatInt(s.MaxInputBytes, 10)); err != nil {
112 return nil, err
113 }
114 }
115 // http-backend refuses receive-pack unless told otherwise; our own hook is
116 // the actual gate, and HTTP writes are refused in the transport anyway.
117 if err := g.SetConfig("http.receivepack", "false"); err != nil {
118 return nil, err
119 }
120 id, err := newID()
121 if err != nil {
122 return nil, err
123 }
124 if err := os.WriteFile(filepath.Join(dir, idFile), []byte(id+"\n"), 0o644); err != nil {
125 return nil, err
126 }
127 if description != "" {
128 if err := os.WriteFile(filepath.Join(dir, descFile), []byte(description+"\n"), 0o644); err != nil {
129 return nil, err
130 }
131 }
132 m := &Meta{ID: id, Name: name, Dir: dir, Created: time.Now().UTC(), Description: description,
133 DefaultBranch: "main", ACLs: s.ACLs}
134 if err := m.SetACL(access.Empty(owner), owner); err != nil {
135 return nil, err
136 }
137 if err := s.InstallHooks(m.Dir); err != nil {
138 return nil, err
139 }
140 return m, nil
141}
142
143// Public reports whether anonymous readers may read the repository. It avoids
144// git: certificate host policy calls it on a handshake, and a private
145// repository's name should not end up in a public certificate.
146func (s *Store) Public(name string) bool {
147 if ValidateName(name) != nil {
148 return false
149 }
150 idb, err := os.ReadFile(filepath.Join(s.path(name), idFile))
151 if err != nil {
152 return false
153 }
154 id := strings.TrimSpace(string(idb))
155 if id == "" || s.ACLs == nil {
156 return false
157 }
158 a, err := s.ACLs.Get(id)
159 return err == nil && a != nil && a.Can("", access.Read)
160}
161
162// Open loads a repo by name, returning an error if it is not a Vole repo.
163func (s *Store) Open(name string) (*Meta, error) {
164 m, err := s.Meta(name)
165 if err != nil {
166 return nil, err
167 }
168 if br := git.Open(m.Dir).Symref(); br != "" {
169 m.DefaultBranch = br
170 }
171 return m, nil
172}
173
174// Meta loads a repo's instance-side facts without running git. The id, name,
175// directory, description and creation time are files and a stat; the default
176// branch is what Open adds, and that costs a git call. A caller that only needs
177// the ACL or the git directory should use this.
178func (s *Store) Meta(name string) (*Meta, error) {
179 if err := ValidateName(name); err != nil {
180 return nil, err
181 }
182 dir := s.path(name)
183 st, err := os.Stat(dir)
184 if err != nil || !st.IsDir() {
185 return nil, os.ErrNotExist
186 }
187 idb, err := os.ReadFile(filepath.Join(dir, idFile))
188 if err != nil {
189 return nil, fmt.Errorf("repo %q is not a Vole repository (no %s)", name, idFile)
190 }
191 m := &Meta{ID: strings.TrimSpace(string(idb)), Name: name, Dir: dir, DefaultBranch: "main", ACLs: s.ACLs}
192 m.Created = st.ModTime().UTC()
193 if b, err := os.ReadFile(filepath.Join(dir, descFile)); err == nil {
194 m.Description = strings.TrimSpace(string(b))
195 }
196 return m, nil
197}
198
199// SetDescription writes the repository's description beside the bare repo. An
200// empty description removes the file, so a later Open cannot resurrect a value
201// the operator cleared.
202func (m *Meta) SetDescription(description string) error {
203 description = strings.TrimSpace(description)
204 path := filepath.Join(m.Dir, descFile)
205 if description == "" {
206 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
207 return err
208 }
209 } else if err := os.WriteFile(path, []byte(description+"\n"), 0o644); err != nil {
210 return err
211 }
212 m.Description = description
213 return nil
214}
215
216// SetDefaultBranch points HEAD at a branch that already exists. It validates the
217// name and the ref before touching HEAD, so a rejected branch changes nothing.
218func (m *Meta) SetDefaultBranch(branch string) error {
219 branch = strings.TrimSpace(branch)
220 if branch == "" {
221 return fmt.Errorf("default branch is required")
222 }
223 g := m.Git()
224 if !g.ValidBranch(branch) {
225 return fmt.Errorf("%q is not a valid branch name", branch)
226 }
227 if sha, err := g.RevParse("refs/heads/" + branch); err != nil || sha == "" {
228 return fmt.Errorf("branch %q does not exist", branch)
229 }
230 if err := g.SetHEAD(branch); err != nil {
231 return err
232 }
233 m.DefaultBranch = branch
234 return nil
235}
236
237// List returns every repo in the store, sorted by name.
238func (s *Store) List() ([]*Meta, error) {
239 ents, err := os.ReadDir(s.Root)
240 if err != nil {
241 if os.IsNotExist(err) {
242 return nil, nil
243 }
244 return nil, err
245 }
246 var out []*Meta
247 for _, e := range ents {
248 if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") {
249 continue
250 }
251 name := strings.TrimSuffix(e.Name(), ".git")
252 m, err := s.Open(name)
253 if err != nil {
254 continue
255 }
256 out = append(out, m)
257 }
258 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
259 return out, nil
260}
261
262func (s *Store) Rename(oldName, newName string) error {
263 if err := ValidateName(newName); err != nil {
264 return err
265 }
266 oldDir, newDir := s.path(oldName), s.path(newName)
267 if _, err := os.Stat(newDir); err == nil {
268 return fmt.Errorf("repo %q already exists", newName)
269 }
270 if _, err := os.Stat(oldDir); err != nil {
271 return fmt.Errorf("repo %q not found", oldName)
272 }
273 if err := os.Rename(oldDir, newDir); err != nil {
274 return err
275 }
276 // The clone URL changed, so the hooks must still point at a valid binary.
277 return s.InstallHooks(newDir)
278}
279
280func (s *Store) Delete(name, actor string) error {
281 m, err := s.Open(name)
282 if err != nil {
283 return fmt.Errorf("repo %q not found", name)
284 }
285 // The deletion is recorded before anything is removed: a repository that
286 // vanishes without a line is exactly the event the audit log exists for. The
287 // rules themselves go only after the content is gone, so a removal that fails
288 // leaves a repository that can still be administered.
289 if s.ACLs != nil {
290 if err := s.ACLs.RecordDelete(m.ID, actor); err != nil {
291 return err
292 }
293 }
294 if s.Secrets != nil {
295 if err := s.Secrets.RecordRepoDelete(m.ID, actor); err != nil {
296 return err
297 }
298 }
299 if err := os.RemoveAll(m.Dir); err != nil {
300 return err
301 }
302 if s.Secrets != nil {
303 if err := s.Secrets.DeleteRepo(m.ID); err != nil {
304 return err
305 }
306 }
307 if s.ACLs != nil {
308 return s.ACLs.Delete(m.ID)
309 }
310 return nil
311}
312
313func (m *Meta) Git() *git.Repo { return git.Open(m.Dir) }
314
315// ACL reads the repo's access rules from the instance database. A repository with no
316// row gets a deny-by-default ACL with no principals, which is also what a repository
317// whose rules were never written looks like.
318func (m *Meta) ACL() (*access.ACL, error) {
319 if m.ACLs == nil {
320 return nil, fmt.Errorf("repo %q has no access store wired, so its rules cannot be read", m.Name)
321 }
322 a, err := m.ACLs.Get(m.ID)
323 if err != nil {
324 return nil, err
325 }
326 if a == nil {
327 return m.ACLs.Attach(access.Empty("")), nil
328 }
329 return a, nil
330}
331
332// SetACL replaces the repo's access rules. A rule set read from the store carries its
333// revision, so a write against a stale read is refused instead of undoing whatever the
334// other writer did; the old copy's commit message (who changed what) is kept in the row
335// and the audit log.
336func (m *Meta) SetACL(a *access.ACL, actor string) error {
337 if m.ACLs == nil {
338 return fmt.Errorf("repo %q has no access store wired, so its rules cannot be written", m.Name)
339 }
340 return m.ACLs.Set(m.ID, a, actor, time.Now().UTC())
341}
342
343// UpdateACL changes the repo's current rules by applying fn to them, and returns the
344// result. Use it for a change that owns one field — the visibility, one account's role,
345// one runners entry — so a concurrent change to another field is kept rather than
346// overwritten by a map somebody loaded a page ago.
347func (m *Meta) UpdateACL(actor string, fn func(*access.ACL) error) (*access.ACL, error) {
348 if m.ACLs == nil {
349 return nil, fmt.Errorf("repo %q has no access store wired, so its rules cannot be written", m.Name)
350 }
351 return m.ACLs.Update(m.ID, actor, fn)
352}
353
354// ImportACLs reads rules from refs/vole/acl in every repository that has them and no
355// row yet, which is how an instance that stored them in git keeps them across the
356// upgrade. It is idempotent and reports how many repositories it took rules from.
357func (s *Store) ImportACLs() (int, error) {
358 if s.ACLs == nil {
359 return 0, fmt.Errorf("no access store is wired")
360 }
361 repos, err := s.List()
362 if err != nil {
363 return 0, err
364 }
365 imported := 0
366 for _, m := range repos {
367 have, err := s.ACLs.Get(m.ID)
368 if err != nil {
369 return imported, err
370 }
371 if have != nil {
372 continue
373 }
374 g := m.Git()
375 commit, err := g.RevParse(ACLRef)
376 if err != nil || commit == "" {
377 continue // nothing to import: a repository with no rules is deny-all
378 }
379 data, err := g.BlobAt(commit, ACLPath)
380 if err != nil {
381 return imported, fmt.Errorf("%s: read %s: %w", m.Name, ACLRef, err)
382 }
383 a, err := access.Parse(data)
384 if err != nil {
385 return imported, fmt.Errorf("%s: %w", m.Name, err)
386 }
387 if err := s.ACLs.Set(m.ID, a, "import from "+ACLRef, time.Now().UTC()); err != nil {
388 return imported, err
389 }
390 imported++
391 }
392 return imported, nil
393}
394
395// InstallHooks writes the server-side hook that enforces policy. It is
396// regenerated on serve start and after rename so the binary path stays correct.
397func (s *Store) InstallHooks(dir string) error {
398 hookDir := filepath.Join(dir, "hooks")
399 if err := os.MkdirAll(hookDir, 0o755); err != nil {
400 return err
401 }
402 // pre-receive is the policy gate; post-receive only queues CI, so it runs after
403 // the refs have moved and can never reject a push.
404 for _, hook := range []string{"pre-receive", "post-receive"} {
405 body := "#!/bin/sh\n# Generated by Vole. Do not edit: rewritten on startup.\nexec " +
406 shellQuote(s.BinPath) + " hook " + hook + "\n"
407 p := filepath.Join(hookDir, hook)
408 if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
409 return err
410 }
411 }
412 return nil
413}
414
415func (s *Store) InstallAllHooks() (int, error) {
416 repos, err := s.List()
417 if err != nil {
418 return 0, err
419 }
420 n := 0
421 for _, m := range repos {
422 if err := s.InstallHooks(m.Dir); err != nil {
423 return n, err
424 }
425 n++
426 }
427 return n, nil
428}
429
430func shellQuote(s string) string {
431 return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
432}
433
434func newID() (string, error) {
435 b := make([]byte, 12)
436 if _, err := rand.Read(b); err != nil {
437 return "", err
438 }
439 return hex.EncodeToString(b), nil
440}
441