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.

docs/roadmap.md +28−6

@@ -60,7 +60,7 @@ is to kill bad assumptions while they're cheap.
60 60 Everything the roadmap assumed survived contact except one thing: an unsharded
61 61 metadata ref, which is now off the table permanently.
62 62
63 ### Phase 1 — It's a git server
63 ### Phase 1 — It's a git server — ✅ complete
64 64
65 65 The unglamorous foundation. If this phase isn't solid, nothing above it is trustworthy.
66 66
@@ -79,11 +79,29 @@ The unglamorous foundation. If this phase isn't solid, nothing above it is trust
79 79 - `vole index rebuild` implemented early and exercised by tests, even though the index has
80 80 little to hold yet
81 81
82 **Exit criteria:** authenticated clone/push/fetch works against a fresh instance; an
83 unauthorized push is rejected by the hook with an actionable message; `vole index rebuild`
84 reproduces state after deleting the index file; a rejected push leaves no partial ref update;
85 an aborted 1 GiB push leaves no ref and no spooled temp file; a second concurrent 1 GiB push
86 is queued at the semaphore instead of doubling memory.
82 **Exit criteria, and how each is verified:**
83
84 | Criterion | Verified by |
85 |---|---|
86 | Authenticated clone/push/fetch on a fresh instance | `TestPushThenClone` — real `git` against a real listener |
87 | Unauthorized push rejected by the hook with an actionable message | `TestReadOnlyPushRejectedAndRefUnmoved`, `TestAnonymousPushRejected` |
88 | `vole index rebuild` reproduces state after deleting the index | `TestIndexRebuildReproducesState` — deletes the DB, rebuilds via the CLI, compares identity and ACL sha to the ref |
89 | Rejected push leaves no partial ref update | asserted by comparing the ref before/after in `TestReadOnlyPushRejectedAndRefUnmoved` |
90 | Aborted push leaves no ref and no spooled temp file | `TestAbandonedChunkedUploadLeavesNoSpoolFile`, `TestDisconnectedClientSkipsBackend`, plus a manual 256 MiB kill mid-upload: no ref, empty spool, no orphans |
91 | Second concurrent push is queued, not doubled | `TestConcurrentPushesQueueRatherThanFail` with `push_concurrency = 1`; both pushes land, neither 503s |
92
93 **Deviations from the plan, and why:**
94
95 - **`pre-receive` only, no `update` hook.** One enforcement point with
96 all-or-nothing semantics is easier to reason about than two that can disagree,
97 and it is what makes "a rejected push moves no refs" true by construction.
98 - **`refs/vole/*` is rejected for client pushes.** Not in the original plan, and
99 essential: the ACL itself lives on `refs/vole/acl`, so permitting client
100 writes there would let any writer grant themselves admin.
101 - **`push_concurrency` defaults to 1.** Serialising pushes is the honest
102 response to the spike's 1.13 GB-per-GiB-push measurement on a single box.
103 - **Invalid credentials answer 401, not 403.** Found during a manual smoke test:
104 403 both leaked that a repo exists and told the client not to retry.
87 105
88 106 ### Phase 2 — Metadata layer and issues
89 107
@@ -245,6 +263,10 @@ Filled in as phases land. Append; don't rewrite.
245 263 | 2026-10-22 | Smart HTTP via `git http-backend` subprocess | Spike B: hook stderr propagates, aborts leave no ref and no orphans, 150 MiB/s on compressible data |
246 264 | 2026-10-22 | Push size cap + concurrency semaphore in Phase 1 | Spike B: 1 GiB push peaked at 1.13 GB git-child RSS |
247 265 | 2026-10-22 | Static sites served from trees, no export step | Spike C: cold 1.2 ms p50; 170k req/s warm with an 18.5 MiB cache |
266 | 2026-10-22 | `pre-receive` is the only policy hook | One enforcement point; all-or-nothing, so a refusal can never half-apply |
267 | 2026-10-22 | Clients may not push `refs/vole/*` | The ACL lives there; allowing it would let a writer self-grant admin |
268 | 2026-10-22 | SQLite via `modernc.org/sqlite` (two files: auth, index) | CGO-free single binary; the index file is disposable, auth is not |
269 | 2026-10-22 | Bad credentials return 401 with a challenge | 403 leaked repo existence and suppressed client retry |
248 270
249 271 ## Open questions
250 272