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

Phase 1: a working git remote with access control

653e0cfc227c992e5b26ea10057aa0b424b6ce4f · Keenan Nemetz · 18d ago · parent c5626f1b

Phase 1: a working git remote with access control

Vole is now a functioning forge foundation: one CGO-free binary that serves
real git over smart HTTP and enforces per-repo policy in pre-receive.

What is in:

- internal/transport drives `git http-backend` as a CGI subprocess. Chunked
  uploads are spooled to learn CONTENT_LENGTH (git's default client config
  produces them), spool files are cleaned on error and abort, and a janitor
  sweeps anything a crash left behind. Pushes are capped by size and
  serialised behind a semaphore, because Phase 0 measured 1.13 GB of git RSS
  for a single 1 GiB push.
- internal/acl stores roles on refs/vole/acl inside each repository, so
  access rules travel with a clone. One evaluation function is shared by the
  hook and the web layer.
- internal/hook enforces policy in pre-receive: authentication, write role,
  protected refs, per-blob size limits, and a refusal to accept pushes to
  refs/vole/* - without that last check a writer could push their own
  acl.json and grant themselves admin.
- internal/auth holds users, personal access tokens and web sessions in
  SQLite. Passwords use PBKDF2-SHA256; tokens are 256-bit random values and
  are stored as SHA-256 hashes, since there is no low-entropy secret to grind.
- internal/index is the derived read model, rebuildable from disk and refs.
- internal/repo owns the bare-repo store and the metadata ref CAS helper.
- internal/web is the browse surface: repo list, tree, blob, raw, history,
  login, and per-repo settings for access rules, rename and delete.
- internal/signing creates the instance ed25519 key at first boot for the
  Phase 7 work.

Every Phase 1 exit criterion has a test that would fail if it regressed:
clone/push against a real listener, read-only and anonymous rejection with the
ref proven unmoved, rebuild-from-refs compared against the actual acl ref, a
spool file proven absent after an aborted chunked upload, and two pushes
completing through a single-slot semaphore. scripts/ci.sh runs gofmt, vet,
build, a markdown link check and the race-enabled suite.

Verified by hand as well: a clean `vole init` -> user -> token -> push ->
clone cycle, web login and browse, and a 256 MiB chunked push killed
mid-flight leaving no ref, no spool file and no orphaned git processes.

internal/gitx/gitx.go +329

@@ -0,0 +1,329 @@
1 // Package gitx wraps the git binary. Vole shells out rather than reimplementing
2 // git: hooks, pack handling, and compare-and-swap ref updates all come from the
3 // real thing.
4 package gitx
5
6 import (
7 "bytes"
8 "fmt"
9 "os"
10 "os/exec"
11 "sort"
12 "strconv"
13 "strings"
14 )
15
16 const ZeroSHA = "0000000000000000000000000000000000000000"
17
18 // Entry is one record from `git ls-tree` or one line of `git mktree` input.
19 type Entry struct {
20 Mode string
21 Type string
22 SHA string
23 Name string
24 }
25
26 type Repo struct {
27 Dir string
28 }
29
30 func Open(dir string) *Repo { return &Repo{Dir: dir} }
31
32 func (r *Repo) run(stdin []byte, args ...string) ([]byte, string, error) {
33 cmd := exec.Command("git", args...)
34 cmd.Env = append(os.Environ(),
35 "GIT_DIR="+r.Dir,
36 "GIT_TERMINAL_PROMPT=0",
37 "GIT_AUTHOR_NAME=vole", "GIT_AUTHOR_EMAIL=vole@localhost",
38 "GIT_COMMITTER_NAME=vole", "GIT_COMMITTER_EMAIL=vole@localhost",
39 )
40 if stdin != nil {
41 cmd.Stdin = bytes.NewReader(stdin)
42 }
43 var out, errb bytes.Buffer
44 cmd.Stdout = &out
45 cmd.Stderr = &errb
46 err := cmd.Run()
47 return out.Bytes(), strings.TrimSpace(errb.String()), err
48 }
49
50 // Run exposes raw git invocation for callers that need it.
51 func (r *Repo) Run(stdin []byte, args ...string) ([]byte, error) {
52 out, errs, err := r.run(stdin, args...)
53 if err != nil {
54 return out, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, errs)
55 }
56 return out, nil
57 }
58
59 // InitBare creates a bare repo at dir with the given default branch.
60 func InitBare(dir, branch string) error {
61 if err := os.MkdirAll(dir, 0o755); err != nil {
62 return err
63 }
64 cmd := exec.Command("git", "init", "--bare", "-q", "--initial-branch="+branch, dir)
65 var errb bytes.Buffer
66 cmd.Stderr = &errb
67 if err := cmd.Run(); err != nil {
68 return fmt.Errorf("git init: %w: %s", err, strings.TrimSpace(errb.String()))
69 }
70 return nil
71 }
72
73 // RevParse resolves a revision to a commit SHA, returning "" when it does not
74 // exist (an unborn branch is not an error).
75 func (r *Repo) RevParse(rev string) (string, error) {
76 out, _, err := r.run(nil, "rev-parse", "--verify", "--quiet", rev+"^{commit}")
77 if err != nil {
78 return "", nil
79 }
80 return strings.TrimSpace(string(out)), nil
81 }
82
83 // ResolveAny resolves a rev to an object of any type (tree, blob, commit).
84 func (r *Repo) ResolveAny(rev string) (string, string, error) {
85 out, errs, err := r.run(nil, "cat-file", "-t", rev)
86 if err != nil {
87 return "", "", fmt.Errorf("resolve %q: %w: %s", rev, err, errs)
88 }
89 sha, err := r.RevParseAny(rev)
90 if err != nil {
91 return "", "", err
92 }
93 return sha, strings.TrimSpace(string(out)), nil
94 }
95
96 func (r *Repo) RevParseAny(rev string) (string, error) {
97 out, errs, err := r.run(nil, "rev-parse", "--verify", "--quiet", rev)
98 if err != nil {
99 return "", fmt.Errorf("rev-parse %q: %w: %s", rev, err, errs)
100 }
101 return strings.TrimSpace(string(out)), nil
102 }
103
104 func (r *Repo) Exists() bool {
105 st, err := os.Stat(r.Dir)
106 return err == nil && st.IsDir()
107 }
108
109 // LsTree lists the entries at treeish (a commit, a tree, or commit:path).
110 func (r *Repo) LsTree(treeish string) ([]Entry, error) {
111 out, errs, err := r.run(nil, "ls-tree", "-z", treeish)
112 if err != nil {
113 return nil, fmt.Errorf("ls-tree %q: %w: %s", treeish, err, errs)
114 }
115 var es []Entry
116 for _, rec := range bytes.Split(out, []byte{0}) {
117 if len(rec) == 0 {
118 continue
119 }
120 tab := bytes.IndexByte(rec, '\t')
121 if tab < 0 {
122 return nil, fmt.Errorf("bad ls-tree record %q", rec)
123 }
124 f := strings.Fields(string(rec[:tab]))
125 if len(f) != 3 {
126 return nil, fmt.Errorf("bad ls-tree fields %q", rec)
127 }
128 es = append(es, Entry{Mode: f[0], Type: f[1], SHA: f[2], Name: string(rec[tab+1:])})
129 }
130 return es, nil
131 }
132
133 func (r *Repo) MkTree(es []Entry) (string, error) {
134 sorted := make([]Entry, len(es))
135 copy(sorted, es)
136 sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
137 var buf bytes.Buffer
138 for _, e := range sorted {
139 fmt.Fprintf(&buf, "%s %s %s\t%s\x00", e.Mode, e.Type, e.SHA, e.Name)
140 }
141 out, errs, err := r.run(buf.Bytes(), "mktree", "-z")
142 if err != nil {
143 return "", fmt.Errorf("mktree: %w: %s", err, errs)
144 }
145 return strings.TrimSpace(string(out)), nil
146 }
147
148 func (r *Repo) HashObject(payload []byte) (string, error) {
149 out, errs, err := r.run(payload, "hash-object", "-w", "--stdin")
150 if err != nil {
151 return "", fmt.Errorf("hash-object: %w: %s", err, errs)
152 }
153 return strings.TrimSpace(string(out)), nil
154 }
155
156 func (r *Repo) CommitTree(tree, parent, msg string) (string, error) {
157 args := []string{"commit-tree", tree}
158 if parent != "" {
159 args = append(args, "-p", parent)
160 }
161 out, errs, err := r.run([]byte(msg), args...)
162 if err != nil {
163 return "", fmt.Errorf("commit-tree: %w: %s", err, errs)
164 }
165 return strings.TrimSpace(string(out)), nil
166 }
167
168 // UpdateRefCAS moves ref from old to new. Returns false when another writer won
169 // the race, which the caller retries. An empty old means "must not exist".
170 func (r *Repo) UpdateRefCAS(ref, new, old string) (bool, error) {
171 if old == "" {
172 old = ZeroSHA
173 }
174 _, errs, err := r.run(nil, "update-ref", ref, new, old)
175 if err == nil {
176 return true, nil
177 }
178 if strings.Contains(errs, "cannot lock ref") ||
179 strings.Contains(errs, "is at") ||
180 strings.Contains(errs, "unable to resolve") ||
181 strings.Contains(errs, "reference already exists") {
182 return false, nil
183 }
184 return false, fmt.Errorf("update-ref %s: %w: %s", ref, err, errs)
185 }
186
187 func (r *Repo) UpdateRef(ref, new string) error {
188 _, err := r.Run(nil, "update-ref", ref, new)
189 return err
190 }
191
192 func (r *Repo) DeleteRef(ref string) error {
193 _, err := r.Run(nil, "update-ref", "-d", ref)
194 return err
195 }
196
197 func (r *Repo) ListRefs(prefix string) (map[string]string, error) {
198 out, err := r.Run(nil, "for-each-ref", "--format=%(refname)%1f%(objectname)", prefix)
199 if err != nil {
200 return nil, err
201 }
202 m := map[string]string{}
203 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
204 if line == "" {
205 continue
206 }
207 f := strings.SplitN(line, "\x1f", 2)
208 if len(f) == 2 {
209 m[f[0]] = f[1]
210 }
211 }
212 return m, nil
213 }
214
215 // BlobAt returns the contents of a blob at commit:path.
216 func (r *Repo) BlobAt(commit, path string) ([]byte, error) {
217 out, errs, err := r.run(nil, "cat-file", "blob", commit+":"+path)
218 if err != nil {
219 return nil, fmt.Errorf("cat-file %s:%s: %w: %s", commit, path, err, errs)
220 }
221 return out, nil
222 }
223
224 func (r *Repo) ObjectType(rev string) (string, error) {
225 out, _, err := r.run(nil, "cat-file", "-t", rev)
226 if err != nil {
227 return "", err
228 }
229 return strings.TrimSpace(string(out)), nil
230 }
231
232 // Symref returns the branch HEAD points at, if any.
233 func (r *Repo) Symref() string {
234 out, _, err := r.run(nil, "symbolic-ref", "--short", "HEAD")
235 if err != nil {
236 return ""
237 }
238 return strings.TrimSpace(string(out))
239 }
240
241 type Commit struct {
242 SHA string
243 Author string
244 Email string
245 When int64
246 Subject string
247 }
248
249 // Log returns up to n commits from rev.
250 func (r *Repo) Log(rev string, n int) ([]Commit, error) {
251 out, errs, err := r.run(nil, "log", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%s",
252 "-n", strconv.Itoa(n), rev, "--")
253 if err != nil {
254 return nil, fmt.Errorf("log %q: %w: %s", rev, err, errs)
255 }
256 var cs []Commit
257 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
258 if line == "" {
259 continue
260 }
261 f := strings.SplitN(line, "\x1f", 5)
262 if len(f) != 5 {
263 continue
264 }
265 ts, _ := strconv.ParseInt(f[3], 10, 64)
266 cs = append(cs, Commit{SHA: f[0], Author: f[1], Email: f[2], When: ts, Subject: f[4]})
267 }
268 return cs, nil
269 }
270
271 func (r *Repo) CountCommits(rev string) (int, error) {
272 out, _, err := r.run(nil, "rev-list", "--count", rev)
273 if err != nil {
274 return 0, nil // unborrn branch
275 }
276 n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
277 return n, nil
278 }
279
280 // NewBlobSizes returns the size of every blob reachable from new but not from
281 // old. Used by the pre-receive hook to enforce object size limits without
282 // walking the entire history.
283 func (r *Repo) NewBlobSizes(old, new string) (map[string]int64, error) {
284 args := []string{"rev-list", "--objects", new}
285 // A brand-new ref reports the zero SHA as its old value; passing that to
286 // --not would make git error out and silently skip the size check.
287 if old != "" && old != ZeroSHA {
288 args = append(args, "--not", old)
289 }
290 out, errs, err := r.run(nil, args...)
291 if err != nil {
292 return nil, fmt.Errorf("rev-list: %w: %s", err, errs)
293 }
294 var shas []string
295 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
296 if line == "" {
297 continue
298 }
299 sha := line
300 if i := strings.IndexByte(line, ' '); i > 0 {
301 sha = line[:i]
302 }
303 shas = append(shas, sha)
304 }
305 if len(shas) == 0 {
306 return map[string]int64{}, nil
307 }
308 var in bytes.Buffer
309 for _, s := range shas {
310 in.WriteString(s + "\n")
311 }
312 bo, errs2, err := r.run(in.Bytes(), "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)")
313 if err != nil {
314 return nil, fmt.Errorf("cat-file --batch-check: %w: %s", err, errs2)
315 }
316 sizes := map[string]int64{}
317 for _, line := range strings.Split(strings.TrimSpace(string(bo)), "\n") {
318 f := strings.Fields(line)
319 if len(f) != 3 || f[1] != "blob" {
320 continue
321 }
322 n, err := strconv.ParseInt(f[2], 10, 64)
323 if err != nil {
324 continue
325 }
326 sizes[f[0]] = n
327 }
328 return sizes, nil
329 }