tenseleyflow/shithub / fe9e5c4

Browse files

ssh-dispatch: inherit os.Environ() so hooks see SHITHUB_DATABASE_URL

Pre-fix: buildSSHEnv built a minimal explicit env list with just
SHITHUB_USER_ID/USERNAME/REPO_ID/REPO_FULL_NAME/PROTOCOL/REMOTE_IP/
REQUEST_ID/PATH + the safe.directory GIT_CONFIG_* triple.

When git-receive-pack forked the pre-receive hook ('shithubd hook
pre-receive', via the shim in internal/git/hooks/install.go), the
hook called config.Load(nil) and exited with 'DB URL not set'
because SHITHUB_DATABASE_URL wasn't in the env. User saw:

remote: shithub: server error: DB URL not set
remote: shithubd: DB URL not set
! [remote rejected] trunk -> trunk (pre-receive hook declined)

The HTTPS-git path didn't hit this because shithubd-web's systemd
unit sources /etc/shithub/web.env via EnvironmentFile= and
receive-pack inherits the env directly.

Fix: base the SSH env on os.Environ() so the SHITHUB_* config keys
the git-shell-commands wrapper sourced from web.env propagate
through receive-pack into the hook. Push-event metadata
(SHITHUB_USER_ID/REPO_ID/...) is appended after the inheritance
so any stale value in the parent env is shadowed by the dispatcher's
real value (last-wins on duplicate env keys).

PATH no longer needs an explicit copy; it's part of os.Environ().

Tests:
- TestBuildSSHEnv_InheritsParentEnv pins SHITHUB_DATABASE_URL +
SHITHUB_STORAGE__REPOS_ROOT propagation.
- TestBuildSSHEnv_ExplicitOverridesInheritance pins last-wins
semantics on the SHITHUB_REQUEST_ID collision case.

Both run without a DB so they fire on every CI invocation, not
just the dbtest-equipped ones.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
fe9e5c46639af1b0c1f122aa7f68b880d5712eec
Parents
9f6ec52
Tree
f2ad1d0

2 changed files

StatusFile+-
M internal/git/protocol/ssh_dispatch.go 29 14
A internal/git/protocol/ssh_dispatch_env_test.go 92 0
internal/git/protocol/ssh_dispatch.gomodified
@@ -192,21 +192,35 @@ func FriendlyMessageFor(err error, requestID string) string {
192192
 	return "shithub: internal error (request_id=" + requestID + ")"
193193
 }
194194
 
195
-// buildSSHEnv assembles the SHITHUB_* env vars that S14's hooks read.
196
-// The shape matches the HTTP path so receive-pack hooks see identical
197
-// vars regardless of transport.
195
+// buildSSHEnv assembles the env we exec git-{upload,receive}-pack
196
+// with. The shape matches the HTTP path so receive-pack hooks see
197
+// identical vars regardless of transport.
198
+//
199
+// We INHERIT os.Environ() rather than building from scratch — the
200
+// receive-pack process forks the pre-receive / post-receive hooks
201
+// (`shithubd hook ...`), which call config.Load() and need the
202
+// SHITHUB_* config keys (DATABASE_URL, REPOS_ROOT, etc.) sourced
203
+// from /etc/shithub/web.env via the git-shell-commands wrapper.
204
+//
205
+// Pre-fix this function returned a minimal explicit list, so the
206
+// hook's loadHookCtx exited with "DB URL not set" the moment a
207
+// push tried to commit anything. The HTTPS-git path didn't see
208
+// this because shithubd-web's systemd unit sources web.env and
209
+// receive-pack inherits it directly.
210
+//
211
+// Push-event metadata (SHITHUB_USER_ID/REPO_ID/...) is appended
212
+// AFTER inherited env so the explicit values win on collision.
198213
 func buildSSHEnv(user usersdb.User, ownerName string, repo reposdb.Repo, remoteIP, requestID string) []string {
199
-	return []string{
200
-		"SHITHUB_USER_ID=" + strconv.FormatInt(user.ID, 10),
201
-		"SHITHUB_USERNAME=" + user.Username,
202
-		"SHITHUB_REPO_ID=" + strconv.FormatInt(repo.ID, 10),
203
-		"SHITHUB_REPO_FULL_NAME=" + ownerName + "/" + repo.Name,
214
+	env := os.Environ()
215
+	env = append(
216
+		env,
217
+		"SHITHUB_USER_ID="+strconv.FormatInt(user.ID, 10),
218
+		"SHITHUB_USERNAME="+user.Username,
219
+		"SHITHUB_REPO_ID="+strconv.FormatInt(repo.ID, 10),
220
+		"SHITHUB_REPO_FULL_NAME="+ownerName+"/"+repo.Name,
204221
 		"SHITHUB_PROTOCOL=ssh",
205
-		"SHITHUB_REMOTE_IP=" + remoteIP,
206
-		"SHITHUB_REQUEST_ID=" + requestID,
207
-		// PATH must be inherited so the exec'd git binary can find its
208
-		// sub-helpers (git-pack-objects, git-index-pack, etc.).
209
-		"PATH=" + os.Getenv("PATH"),
222
+		"SHITHUB_REMOTE_IP="+remoteIP,
223
+		"SHITHUB_REQUEST_ID="+requestID,
210224
 		// safe.directory: when sshd runs ssh-shell as the `git` user
211225
 		// but the bare repo dir is owned by `shithub`, git's
212226
 		// dubious-ownership check rejects the invocation. We're
@@ -217,7 +231,8 @@ func buildSSHEnv(user usersdb.User, ownerName string, repo reposdb.Repo, remoteI
217231
 		"GIT_CONFIG_COUNT=1",
218232
 		"GIT_CONFIG_KEY_0=safe.directory",
219233
 		"GIT_CONFIG_VALUE_0=*",
220
-	}
234
+	)
235
+	return env
221236
 }
222237
 
223238
 // newRequestID returns a 16-byte hex token suitable for log
internal/git/protocol/ssh_dispatch_env_test.goadded
@@ -0,0 +1,92 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package protocol
4
+
5
+import (
6
+	"strings"
7
+	"testing"
8
+
9
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
10
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
11
+)
12
+
13
+// TestBuildSSHEnv_InheritsParentEnv pins the contract that
14
+// buildSSHEnv extends os.Environ() so SHITHUB_DATABASE_URL (and any
15
+// other config var sourced by the git-shell-commands wrapper from
16
+// /etc/shithub/web.env) propagates to the exec'd git binary, which
17
+// in turn forks pre-receive / post-receive hooks that need it.
18
+//
19
+// Pre-fix: the function returned a minimal explicit list, so a
20
+// `git push` over SSH made it through auth + dispatch + perms but
21
+// crashed at the pre-receive hook with "DB URL not set". The HTTPS
22
+// path didn't see this because shithubd-web's systemd unit sources
23
+// web.env and receive-pack inherits it directly.
24
+//
25
+// This test does NOT need a database — buildSSHEnv has no side
26
+// effects beyond constructing an env slice.
27
+func TestBuildSSHEnv_InheritsParentEnv(t *testing.T) {
28
+	t.Setenv("SHITHUB_DATABASE_URL", "postgres://test-marker/x")
29
+	t.Setenv("SHITHUB_STORAGE__REPOS_ROOT", "/tmp/test-marker-repos")
30
+
31
+	user := usersdb.User{ID: 7, Username: "alice"}
32
+	repo := reposdb.Repo{ID: 42, Name: "rcal"}
33
+	env := buildSSHEnv(user, "tenseleyflow", repo, "127.0.0.1", "req-deadbeef")
34
+	blob := strings.Join(env, "\n")
35
+
36
+	want := []string{
37
+		// Inherited config (the regression):
38
+		"SHITHUB_DATABASE_URL=postgres://test-marker/x",
39
+		"SHITHUB_STORAGE__REPOS_ROOT=/tmp/test-marker-repos",
40
+		// Push-event metadata appended after inheritance:
41
+		"SHITHUB_USER_ID=7",
42
+		"SHITHUB_USERNAME=alice",
43
+		"SHITHUB_REPO_ID=42",
44
+		"SHITHUB_REPO_FULL_NAME=tenseleyflow/rcal",
45
+		"SHITHUB_PROTOCOL=ssh",
46
+		"SHITHUB_REMOTE_IP=127.0.0.1",
47
+		"SHITHUB_REQUEST_ID=req-deadbeef",
48
+		// safe.directory plumbing intact:
49
+		"GIT_CONFIG_COUNT=1",
50
+		"GIT_CONFIG_KEY_0=safe.directory",
51
+		"GIT_CONFIG_VALUE_0=*",
52
+	}
53
+	for _, w := range want {
54
+		if !strings.Contains(blob, w) {
55
+			t.Errorf("buildSSHEnv missing %q in:\n%s", w, blob)
56
+		}
57
+	}
58
+}
59
+
60
+// TestBuildSSHEnv_ExplicitOverridesInheritance pins that push-event
61
+// metadata wins over an inherited variable of the same name. This
62
+// matters if the wrapping process happened to set, e.g.,
63
+// SHITHUB_REQUEST_ID — the dispatcher's value (per-push, generated
64
+// here) should take precedence so logs aren't cross-contaminated.
65
+func TestBuildSSHEnv_ExplicitOverridesInheritance(t *testing.T) {
66
+	// Inject a stale SHITHUB_REQUEST_ID into the parent — buildSSHEnv
67
+	// must append the real one AFTER os.Environ so Go's last-wins
68
+	// behavior on duplicate env keys gives us the real value.
69
+	t.Setenv("SHITHUB_REQUEST_ID", "stale-from-parent")
70
+
71
+	user := usersdb.User{ID: 7, Username: "alice"}
72
+	repo := reposdb.Repo{ID: 42, Name: "rcal"}
73
+	env := buildSSHEnv(user, "tenseleyflow", repo, "127.0.0.1", "real-req-id")
74
+
75
+	// Find both the stale and real entries; the real one MUST come
76
+	// after the stale one (last-wins semantics in os/exec).
77
+	staleAt, realAt := -1, -1
78
+	for i, kv := range env {
79
+		switch kv {
80
+		case "SHITHUB_REQUEST_ID=stale-from-parent":
81
+			staleAt = i
82
+		case "SHITHUB_REQUEST_ID=real-req-id":
83
+			realAt = i
84
+		}
85
+	}
86
+	if realAt < 0 {
87
+		t.Fatalf("buildSSHEnv missing the real SHITHUB_REQUEST_ID")
88
+	}
89
+	if staleAt >= 0 && realAt < staleAt {
90
+		t.Fatalf("real SHITHUB_REQUEST_ID at idx %d but stale at %d — explicit must win", realAt, staleAt)
91
+	}
92
+}