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.

test(ssh): add end-to-end git-over-SSH clone/push tests

Add comprehensive SSH integration tests covering:
- push creates repo on demand and clone round-trips match HEAD
- push rejected when disabled, repo not created
- clone of missing repository fails
- receive-pack hooks fire asynchronously after push response

Tests drive a real git client over ssh:// against an in-process
SSH server backed by memfs, generating ephemeral ed25519 client
keys and bypassing host key checking for CI environments. Helpers:
gitSSHEnv() configures GIT_SSH_COMMAND and isolation env vars,
gitWithEnv() runs git with custom env (callers can inspect errors),
startSSHServer() creates an in-memory daemon on an ephemeral port.

Assisted-by: Claude Sonnet 4.6 via Claude Code

Xe Iaso (May 28, 2026, 11:32 PM EDT) d2ced24b cbe152be

+212
+212
cmd/objgitd/ssh_test.go
··· 3 3 import ( 4 4 "bytes" 5 5 "io" 6 + "log/slog" 7 + "net" 8 + "os" 9 + "os/exec" 10 + "path/filepath" 11 + "strings" 6 12 "testing" 13 + "time" 7 14 15 + "github.com/go-git/go-billy/v6" 8 16 "github.com/go-git/go-billy/v6/memfs" 9 17 "github.com/go-git/go-git/v6/plumbing/transport" 18 + "tangled.org/xeiaso.net/objgit/internal/auth" 10 19 ) 11 20 12 21 func TestGitServiceFor(t *testing.T) { ··· 101 110 t.Error("host key file changed on the second call; it must not be rewritten") 102 111 } 103 112 } 113 + 114 + // checkSSHBinaries skips t if git, ssh, or ssh-keygen are not on PATH. 115 + func checkSSHBinaries(t *testing.T) { 116 + t.Helper() 117 + for _, bin := range []string{"git", "ssh", "ssh-keygen"} { 118 + if _, err := exec.LookPath(bin); err != nil { 119 + t.Skipf("%s not installed", bin) 120 + } 121 + } 122 + } 123 + 124 + // startSSHServer creates an in-memory daemon and starts the SSH server on an 125 + // ephemeral port. It returns the listening address and the backing filesystem. 126 + func startSSHServer(t *testing.T, allowPush, allowHooks bool) (string, billy.Filesystem) { 127 + t.Helper() 128 + fs := memfs.New() 129 + d := &daemon{ 130 + fs: fs, 131 + loader: transport.NewFilesystemLoader(fs, false), 132 + authz: auth.AllowAnonymous{AllowWrite: allowPush}, 133 + allowHooks: allowHooks, 134 + hookTimeout: 30 * time.Second, 135 + } 136 + srv, err := newSSHServer(d, "") 137 + if err != nil { 138 + t.Fatalf("newSSHServer: %v", err) 139 + } 140 + ln, err := net.Listen("tcp", "127.0.0.1:0") 141 + if err != nil { 142 + t.Fatalf("listen: %v", err) 143 + } 144 + go srv.Serve(ln) //nolint:errcheck // returns when ln closes 145 + t.Cleanup(func() { srv.Close() }) 146 + return ln.Addr().String(), fs 147 + } 148 + 149 + // gitSSHEnv generates a throw-away ed25519 client key and returns an env slice 150 + // with GIT_SSH_COMMAND pointing at ssh using that key with host-key checking 151 + // disabled. The server accepts any public key. 152 + func gitSSHEnv(t *testing.T) []string { 153 + t.Helper() 154 + keyDir := t.TempDir() 155 + key := filepath.Join(keyDir, "id_ed25519") 156 + if out, err := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", key).CombinedOutput(); err != nil { 157 + t.Fatalf("ssh-keygen: %v\n%s", err, out) 158 + } 159 + sshCmd := "ssh -i " + key + 160 + " -o IdentitiesOnly=yes" + 161 + " -o StrictHostKeyChecking=no" + 162 + " -o UserKnownHostsFile=/dev/null" 163 + return append(os.Environ(), 164 + "GIT_SSH_COMMAND="+sshCmd, 165 + "GIT_TERMINAL_PROMPT=0", 166 + "GIT_CONFIG_GLOBAL=/dev/null", 167 + "GIT_CONFIG_SYSTEM=/dev/null", 168 + ) 169 + } 170 + 171 + // gitWithEnv runs git with a custom environment, returning combined output and 172 + // the error (if any). It does NOT call t.Fatal so callers can inspect failures. 173 + func gitWithEnv(dir string, env []string, args ...string) (string, error) { 174 + cmd := exec.Command("git", args...) 175 + cmd.Dir = dir 176 + cmd.Env = env 177 + out, err := cmd.CombinedOutput() 178 + return string(out), err 179 + } 180 + 181 + // TestSSH drives a real git client over ssh:// against an in-process SSH server 182 + // backed by memfs, covering push/clone round-trips, create-on-demand, push 183 + // rejection, and clone of a missing repository. 184 + func TestSSH(t *testing.T) { 185 + checkSSHBinaries(t) 186 + 187 + for _, tt := range []struct { 188 + name string 189 + allowPush bool 190 + doPush bool 191 + wantPushErr bool 192 + wantCloneErr bool 193 + }{ 194 + { 195 + name: "push creates repo and clone round-trips", 196 + allowPush: true, 197 + doPush: true, 198 + }, 199 + { 200 + name: "push rejected when disabled", 201 + allowPush: false, 202 + doPush: true, 203 + wantPushErr: true, 204 + wantCloneErr: true, 205 + }, 206 + { 207 + name: "clone of missing repo fails", 208 + allowPush: true, 209 + doPush: false, 210 + wantCloneErr: true, 211 + }, 212 + } { 213 + t.Run(tt.name, func(t *testing.T) { 214 + addr, fs := startSSHServer(t, tt.allowPush, false) 215 + env := gitSSHEnv(t) 216 + remote := "ssh://git@" + addr + "/test.git" 217 + 218 + // Confirm no repo exists before any push. 219 + _, preStatErr := fs.Stat("test.git/config") 220 + if preStatErr == nil { 221 + t.Fatal("test.git must not exist before any push") 222 + } 223 + 224 + var srcHead string 225 + if tt.doPush { 226 + work := seedRepo(t) 227 + srcHead = strings.TrimSpace(runGit(t, work, "rev-parse", "HEAD")) 228 + 229 + out, err := gitWithEnv(work, env, "push", remote, "main") 230 + if tt.wantPushErr { 231 + if err == nil { 232 + t.Fatalf("expected push to be rejected, got success:\n%s", out) 233 + } 234 + } else if err != nil { 235 + t.Fatalf("push failed: %v\n%s", err, out) 236 + } 237 + } 238 + 239 + // The bare repo must exist iff a push was expected to land. 240 + _, statErr := fs.Stat("test.git/config") 241 + pushLanded := tt.doPush && !tt.wantPushErr 242 + if pushLanded && statErr != nil { 243 + t.Fatalf("expected repo to be created on push, but config missing: %v", statErr) 244 + } 245 + if !pushLanded && statErr == nil { 246 + t.Fatal("repository must not exist when push did not land") 247 + } 248 + 249 + dst := t.TempDir() 250 + out, err := gitWithEnv(dst, env, "clone", remote, "cloned") 251 + if tt.wantCloneErr { 252 + if err == nil { 253 + t.Fatalf("expected clone to fail, got success:\n%s", out) 254 + } 255 + return 256 + } 257 + if err != nil { 258 + t.Fatalf("clone failed: %v\n%s", err, out) 259 + } 260 + 261 + gotHead := strings.TrimSpace(runGit(t, filepath.Join(dst, "cloned"), "rev-parse", "HEAD")) 262 + if gotHead != srcHead { 263 + t.Logf("want: %s", srcHead) 264 + t.Logf("got: %s", gotHead) 265 + t.Error("cloned HEAD does not match pushed HEAD") 266 + } 267 + }) 268 + } 269 + } 270 + 271 + // TestSSHHookFires pushes a repo carrying .objgit/hooks/receive-pack over SSH 272 + // and asserts the hook runs asynchronously after the push response. 273 + func TestSSHHookFires(t *testing.T) { 274 + checkSSHBinaries(t) 275 + 276 + var logBuf syncBuffer 277 + prev := slog.Default() 278 + slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))) 279 + defer slog.SetDefault(prev) 280 + 281 + addr, _ := startSSHServer(t, true, true) 282 + env := gitSSHEnv(t) 283 + remote := "ssh://git@" + addr + "/hooked.git" 284 + 285 + work := t.TempDir() 286 + runGit(t, work, "init", "-b", "main") 287 + runGit(t, work, "config", "user.email", "test@example.com") 288 + runGit(t, work, "config", "user.name", "Test") 289 + 290 + hook := strings.Join([]string{ 291 + "cat README.md", 292 + "echo hook_ran", 293 + }, "\n") + "\n" 294 + writeFile(t, filepath.Join(work, "README.md"), "hello from ssh repo\n") 295 + writeFile(t, filepath.Join(work, ".objgit", "hooks", "receive-pack"), hook) 296 + runGit(t, work, "add", ".") 297 + runGit(t, work, "commit", "-m", "with hook") 298 + 299 + if out, err := gitWithEnv(work, env, "push", remote, "main"); err != nil { 300 + t.Fatalf("push failed: %v\n%s", err, out) 301 + } 302 + 303 + // The hook runs asynchronously; wait for the terminal log line. 304 + waitForLog(t, &logBuf, "hook: finished", 30*time.Second) 305 + 306 + logs := logBuf.String() 307 + if !strings.Contains(logs, "hook: running") { 308 + t.Fatalf("hook did not run; logs:\n%s", logs) 309 + } 310 + for _, want := range []string{"hello from ssh repo", "hook_ran"} { 311 + if !strings.Contains(logs, want) { 312 + t.Errorf("hook output missing %q; logs:\n%s", want, logs) 313 + } 314 + } 315 + }