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.
| 1 | // Package repo owns the on-disk bare-repo store and the metadata refs that live |
| 2 | // inside each repository. |
| 3 | package repo |
| 4 | |
| 5 | import ( |
| 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 | |
| 22 | const ( |
| 23 | ACLRef = "refs/vole/acl" |
| 24 | ACLPath = "access.json" |
| 25 | idFile = "vole-id" |
| 26 | descFile = "description" |
| 27 | ) |
| 28 | |
| 29 | var 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". |
| 36 | var 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. |
| 45 | func 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 | |
| 61 | type 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 | |
| 77 | func 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. |
| 82 | type 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 | |
| 94 | func (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. |
| 98 | func (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. |
| 146 | func (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. |
| 163 | func (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. |
| 178 | func (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. |
| 202 | func (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. |
| 218 | func (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. |
| 238 | func (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 | |
| 262 | func (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 | |
| 280 | func (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 | |
| 313 | func (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. |
| 318 | func (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. |
| 336 | func (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. |
| 347 | func (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. |
| 357 | func (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. |
| 397 | func (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 | |
| 415 | func (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 | |
| 430 | func shellQuote(s string) string { |
| 431 | return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" |
| 432 | } |
| 433 | |
| 434 | func 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 |