Git backed by object storage because you can't stop me
git object-storage kefka
10

Configure Feed

Select the types of activity you want to include in your feed.

feat(http): route smart-HTTP through the auth.Authorizer; drop allowPush field

Replace the allowPush bool gate in resolve with a call to d.authz.Authorize,
extracting HTTP Basic credentials via credFromRequest. Remove the now-redundant
allowPush field from the daemon struct and all daemon literals in tests. Add
TestSmartHTTPAnonymousReadWhilePushDisabled to verify reads succeed when writes
are denied.

Xe Iaso (May 28, 2026, 11:07 PM EDT) 796639fe 8d677e13

+80 -30
-1
cmd/objgitd/example_hook_test.go
··· 40 40 fs: fs, 41 41 loader: transport.NewFilesystemLoader(fs, false), 42 42 authz: auth.AllowAnonymous{AllowWrite: true}, 43 - allowPush: true, 44 43 allowHooks: true, 45 44 hookTimeout: 30 * time.Second, 46 45 }
+3 -4
cmd/objgitd/git_protocol.go
··· 55 55 56 56 // daemon serves the git:// (TCP) protocol out of a billy filesystem. 57 57 type daemon struct { 58 - fs billy.Filesystem 59 - loader transport.Loader 60 - authz auth.Authorizer 61 - allowPush bool 58 + fs billy.Filesystem 59 + loader transport.Loader 60 + authz auth.Authorizer 62 61 63 62 // allowHooks gates running .objgit/hooks/receive-pack after a push. 64 63 allowHooks bool
+6 -8
cmd/objgitd/git_protocol_test.go
··· 25 25 26 26 fs := memfs.New() 27 27 d := &daemon{ 28 - fs: fs, 29 - loader: transport.NewFilesystemLoader(fs, false), 30 - authz: auth.AllowAnonymous{AllowWrite: true}, 31 - allowPush: true, 28 + fs: fs, 29 + loader: transport.NewFilesystemLoader(fs, false), 30 + authz: auth.AllowAnonymous{AllowWrite: true}, 32 31 } 33 32 34 33 ctx, cancel := context.WithCancel(context.Background()) ··· 83 82 84 83 fs := memfs.New() 85 84 d := &daemon{ 86 - fs: fs, 87 - loader: transport.NewFilesystemLoader(fs, false), 88 - authz: auth.AllowAnonymous{AllowWrite: false}, 89 - allowPush: false, 85 + fs: fs, 86 + loader: transport.NewFilesystemLoader(fs, false), 87 + authz: auth.AllowAnonymous{AllowWrite: false}, 90 88 } 91 89 92 90 ctx, cancel := context.WithCancel(context.Background())
-2
cmd/objgitd/hooks_test.go
··· 109 109 fs: fs, 110 110 loader: transport.NewFilesystemLoader(fs, false), 111 111 authz: auth.AllowAnonymous{AllowWrite: true}, 112 - allowPush: true, 113 112 allowHooks: true, 114 113 hookTimeout: 30 * time.Second, 115 114 } ··· 187 186 fs: fs, 188 187 loader: transport.NewFilesystemLoader(fs, false), 189 188 authz: auth.AllowAnonymous{AllowWrite: true}, 190 - allowPush: true, 191 189 allowHooks: true, 192 190 hookTimeout: 30 * time.Second, 193 191 }
+34 -11
cmd/objgitd/http.go
··· 13 13 "github.com/go-git/go-git/v6/plumbing/transport" 14 14 "github.com/go-git/go-git/v6/storage" 15 15 "github.com/go-git/go-git/v6/utils/ioutil" 16 + "tangled.org/xeiaso.net/objgit/internal/auth" 16 17 ) 17 18 18 19 // ServeHTTP speaks the git smart-HTTP protocol. It dispatches on the URL suffix ··· 44 45 return 45 46 } 46 47 47 - st, ok := d.resolve(w, service, repoPath) 48 + st, ok := d.resolve(w, r, service, repoPath) 48 49 if !ok { 49 50 return 50 51 } ··· 86 87 // handleRPC serves a stateless negotiation round: 87 88 // POST /{repo}/git-(upload|receive)-pack. 88 89 func (d *daemon) handleRPC(w http.ResponseWriter, r *http.Request, service, repoPath string) { 89 - st, ok := d.resolve(w, service, repoPath) 90 + st, ok := d.resolve(w, r, service, repoPath) 90 91 if !ok { 91 92 return 92 93 } ··· 136 137 } 137 138 } 138 139 139 - // resolve loads the storer for an HTTP request, applying the same rules as the 140 - // git:// handler: anonymous read, push gated by allowPush, and create-on-first- 141 - // push. It writes an HTTP error and returns ok=false when the request cannot 142 - // proceed. 143 - func (d *daemon) resolve(w http.ResponseWriter, service, repoPath string) (storage.Storer, bool) { 140 + // resolve loads the storer for an HTTP request, authorizing via the daemon's 141 + // Authorizer before touching the repository. It writes an HTTP error and 142 + // returns ok=false when the request cannot proceed. 143 + func (d *daemon) resolve(w http.ResponseWriter, r *http.Request, service, repoPath string) (storage.Storer, bool) { 144 + switch d.authz.Authorize(r.Context(), auth.Request{ 145 + Repo: repoPath, 146 + Operation: operationFor(service), 147 + Cred: credFromRequest(r), 148 + Transport: "http", 149 + }) { 150 + case auth.Allow: 151 + // authorized; fall through to repo resolution 152 + case auth.Unauthenticated: 153 + w.Header().Set("WWW-Authenticate", `Basic realm="objgit"`) 154 + http.Error(w, "authentication required", http.StatusUnauthorized) 155 + return nil, false 156 + default: // auth.Deny 157 + http.Error(w, "access denied", http.StatusForbidden) 158 + return nil, false 159 + } 160 + 144 161 if service == transport.ReceivePackService { 145 - if !d.allowPush { 146 - http.Error(w, "push is disabled on this server", http.StatusForbidden) 147 - return nil, false 148 - } 149 162 st, err := d.loadOrInit(repoPath) 150 163 if err != nil { 151 164 slog.Error("opening repository for push", "path", repoPath, "err", err) ··· 167 180 } 168 181 return st, true 169 182 } 183 + 184 + // credFromRequest extracts an auth credential from an HTTP request: HTTP Basic 185 + // if present, otherwise anonymous. It does not validate — the Authorizer owns 186 + // the user store. 187 + func credFromRequest(r *http.Request) auth.Credential { 188 + if u, p, ok := r.BasicAuth(); ok { 189 + return auth.BasicAuth{Username: u, Password: p} 190 + } 191 + return auth.Anonymous{} 192 + }
+37 -3
cmd/objgitd/http_test.go
··· 10 10 "github.com/go-git/go-billy/v6" 11 11 "github.com/go-git/go-billy/v6/memfs" 12 12 "github.com/go-git/go-git/v6/plumbing/transport" 13 + "tangled.org/xeiaso.net/objgit/internal/auth" 13 14 ) 14 15 15 16 // TestSmartHTTP drives a real git client against the smart-HTTP handler over an ··· 103 104 t.Helper() 104 105 fs := memfs.New() 105 106 d := &daemon{ 106 - fs: fs, 107 - loader: transport.NewFilesystemLoader(fs, false), 108 - allowPush: allowPush, 107 + fs: fs, 108 + loader: transport.NewFilesystemLoader(fs, false), 109 + authz: auth.AllowAnonymous{AllowWrite: allowPush}, 109 110 } 110 111 ts := httptest.NewServer(d) 111 112 t.Cleanup(ts.Close) 112 113 return ts, fs 114 + } 115 + 116 + // TestSmartHTTPAnonymousReadWhilePushDisabled verifies that with push disabled, 117 + // anonymous clone of an existing repo still succeeds — reads are always allowed 118 + // by the default authorizer, only writes are gated. 119 + func TestSmartHTTPAnonymousReadWhilePushDisabled(t *testing.T) { 120 + if _, err := exec.LookPath("git"); err != nil { 121 + t.Skip("git not installed") 122 + } 123 + 124 + // Seed a repo via a push-enabled server over a shared filesystem. 125 + fs := memfs.New() 126 + seed := httptest.NewServer(&daemon{fs: fs, loader: transport.NewFilesystemLoader(fs, false), authz: auth.AllowAnonymous{AllowWrite: true}}) 127 + defer seed.Close() 128 + 129 + work := seedRepo(t) 130 + srcHead := strings.TrimSpace(runGit(t, work, "rev-parse", "HEAD")) 131 + if out, err := tryGit(work, "push", seed.URL+"/test.git", "main"); err != nil { 132 + t.Fatalf("seed push failed: %v\n%s", err, out) 133 + } 134 + 135 + // Serve the same filesystem with push disabled and clone from it. 136 + ro := httptest.NewServer(&daemon{fs: fs, loader: transport.NewFilesystemLoader(fs, false), authz: auth.AllowAnonymous{AllowWrite: false}}) 137 + defer ro.Close() 138 + 139 + dst := t.TempDir() 140 + if out, err := tryGit(dst, "clone", ro.URL+"/test.git", "cloned"); err != nil { 141 + t.Fatalf("anonymous clone should succeed with push disabled: %v\n%s", err, out) 142 + } 143 + gotHead := strings.TrimSpace(runGit(t, filepath.Join(dst, "cloned"), "rev-parse", "HEAD")) 144 + if gotHead != srcHead { 145 + t.Errorf("cloned HEAD %q != seeded HEAD %q", gotHead, srcHead) 146 + } 113 147 } 114 148 115 149 // seedRepo creates a local git repository with one commit and returns its path.
-1
cmd/objgitd/main.go
··· 75 75 fs: fsys, 76 76 loader: transport.NewFilesystemLoader(fsys, false), 77 77 authz: auth.AllowAnonymous{AllowWrite: *allowPush}, 78 - allowPush: *allowPush, 79 78 allowHooks: *allowHooks, 80 79 hookTimeout: *hookTimeout, 81 80 }