tenseleyflow/shithub / c8058f1

Browse files

storage: bare repos use --shared=group + add repair backfill (SR2 #287)

Pre-fix: storage.RepoFS.InitBare ran 'git init --bare' without
--shared=group, so objects/ wound up 0755 with no group-write.
shithubd-web (runs as 'shithub' user) created repos; SSH-git
dispatched git-receive-pack as the 'git' user (in the 'shithub'
group). 'git' had read-execute on objects/ but not write, so push
failed with 'unable to create temporary object directory'.
git-upload-pack worked because read was sufficient.

Fix at the source:
- InitBare now runs 'git init --bare --shared=group --initial-branch=trunk'.
Persists core.sharedRepository=group in config; produces 2775
dirs (group write + setgid) and 0664 files. Parent dir gets 2750
so the setgid propagates from byte zero.
- CloneBareShared (fork path) prepends '-c core.sharedRepository=group'
so the cloned repo carries the contract. NB: 'git clone --shared'
alone is the alternates flag, NOT the perms flag — same word, two
meanings.
- RepairSharedPerms backfills existing repos: sets the config flag,
walks the tree, chmods g+w on files and g+w+s on dirs. Idempotent.
- 'shithubd storage repair-shared-perms' subcommand walks every
<prefix>/<owner>/<name>.git under storage.repos_root and applies
the repair. One-time use after deploying this binary on shithub-
prod (the live droplet has 1 repo created pre-fix that needs it).

Tests:
- TestInitBare_SharedGroupContract: asserts core.sharedRepository
config value + group-write bit on objects/.
- TestRepairSharedPerms_FixesPreFixRepo: builds a deliberately
pre-fix repo, calls Repair, asserts post-conditions match the
contract InitBare produces from byte zero.

Closes nothing yet — operator runs the new subcommand on the
droplet after deploy. Audit script (deploy/audit/check-droplet-
drift.sh) will pick up the binary swap and reflect drift if not.
Authored by espadonne
SHA
c8058f1d873fa7b3c3ae04ad98ae2e01d80bad99
Parents
4b9dfc2
Tree
c609637

3 changed files

StatusFile+-
M cmd/shithubd/storage.go 81 0
M internal/infra/storage/reposfs.go 74 5
M internal/infra/storage/reposfs_test.go 113 0
cmd/shithubd/storage.gomodified
@@ -66,8 +66,89 @@ smoke tests and as a sanity check from the operator's terminal.`,
6666
 	},
6767
 }
6868
 
69
+var storageRepairSharedPermsCmd = &cobra.Command{
70
+	Use:   "repair-shared-perms",
71
+	Short: "Bring existing bare repos to core.sharedRepository=group + g+w mode bits",
72
+	Long: `One-time backfill for repos created before SR2 #287 landed.
73
+
74
+Pre-fix, bare repos were created with 'git init --bare' (no
75
+--shared=group), so objects/ wound up 0755. The SSH-git push path
76
+(git-receive-pack runs as the 'git' user, repos owned by 'shithub')
77
+hit "unable to create temporary object directory" because the group
78
+write bit was missing.
79
+
80
+This subcommand walks every <prefix>/<owner>/<name>.git directory
81
+under storage.repos_root and:
82
+  - sets core.sharedRepository=group in each repo's config
83
+  - chmods every file g+w
84
+  - chmods every directory g+w + g+s so future writes inherit group
85
+
86
+Idempotent. Safe to re-run. Reports a per-repo summary.`,
87
+	RunE: func(cmd *cobra.Command, _ []string) error {
88
+		cfg, err := config.Load(nil)
89
+		if err != nil {
90
+			return err
91
+		}
92
+		root := cfg.Storage.ReposRoot
93
+		if root == "" {
94
+			return errors.New("storage.repos_root not configured")
95
+		}
96
+		fs, err := storage.NewRepoFS(root)
97
+		if err != nil {
98
+			return fmt.Errorf("repofs: %w", err)
99
+		}
100
+		ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
101
+		defer cancel()
102
+		out := cmd.OutOrStdout()
103
+		var ok, fail int
104
+		// Walk the canonical layout: <root>/<2-letter-prefix>/<owner>/<name>.git
105
+		entries, err := os.ReadDir(root)
106
+		if err != nil {
107
+			return fmt.Errorf("read repos_root: %w", err)
108
+		}
109
+		for _, prefix := range entries {
110
+			if !prefix.IsDir() {
111
+				continue
112
+			}
113
+			ownerEntries, err := os.ReadDir(root + "/" + prefix.Name())
114
+			if err != nil {
115
+				continue
116
+			}
117
+			for _, owner := range ownerEntries {
118
+				if !owner.IsDir() {
119
+					continue
120
+				}
121
+				ownerDir := root + "/" + prefix.Name() + "/" + owner.Name()
122
+				repos, err := os.ReadDir(ownerDir)
123
+				if err != nil {
124
+					continue
125
+				}
126
+				for _, repo := range repos {
127
+					if !repo.IsDir() || !strings.HasSuffix(repo.Name(), ".git") {
128
+						continue
129
+					}
130
+					path := ownerDir + "/" + repo.Name()
131
+					if err := fs.RepairSharedPerms(ctx, path); err != nil {
132
+						_, _ = fmt.Fprintf(out, "FAIL %s: %v\n", path, err)
133
+						fail++
134
+						continue
135
+					}
136
+					_, _ = fmt.Fprintf(out, "ok   %s\n", path)
137
+					ok++
138
+				}
139
+			}
140
+		}
141
+		_, _ = fmt.Fprintf(out, "\nrepaired %d repo(s); %d failure(s)\n", ok, fail)
142
+		if fail > 0 {
143
+			return fmt.Errorf("repair-shared-perms: %d failures", fail)
144
+		}
145
+		return nil
146
+	},
147
+}
148
+
69149
 func init() {
70150
 	storageCmd.AddCommand(storageCheckCmd)
151
+	storageCmd.AddCommand(storageRepairSharedPermsCmd)
71152
 }
72153
 
73154
 func checkReposRoot(root string) error {
internal/infra/storage/reposfs.gomodified
@@ -151,6 +151,20 @@ func (r *RepoFS) Exists(path string) (bool, error) {
151151
 //
152152
 // The parent directory tree is created on demand. ErrAlreadyExists is
153153
 // returned if path is non-empty.
154
+//
155
+// The repo is initialized with `--shared=group`, which:
156
+//
157
+//   - persists `core.sharedRepository=group` in config
158
+//   - sets the setgid bit on directories (2775)
159
+//   - keeps group-writable mode bits on files (0664)
160
+//
161
+// Both shithubd-web (web pushes via the HTTPS handler, runs as the
162
+// `shithub` user) and the SSH `git` user (the AuthorizedKeysCommand
163
+// dispatches into a process running as `git`, which is in the
164
+// `shithub` group) write to the same bare repo on disk. Without
165
+// `--shared=group`, git-receive-pack via SSH fails with
166
+// "unable to create temporary object directory" because objects/
167
+// is 0755 and group write isn't set.
154168
 func (r *RepoFS) InitBare(ctx context.Context, path string) error {
155169
 	if err := r.containedInRoot(path); err != nil {
156170
 		return err
@@ -158,15 +172,15 @@ func (r *RepoFS) InitBare(ctx context.Context, path string) error {
158172
 	if entries, err := os.ReadDir(path); err == nil && len(entries) > 0 {
159173
 		return fmt.Errorf("%w: %s", ErrAlreadyExists, path)
160174
 	}
161
-	if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
175
+	if err := os.MkdirAll(filepath.Dir(path), 0o2750); err != nil {
162176
 		return fmt.Errorf("storage: repofs: mkdir parent: %w", err)
163177
 	}
164
-	if err := os.MkdirAll(path, 0o750); err != nil {
178
+	if err := os.MkdirAll(path, 0o2750); err != nil {
165179
 		return fmt.Errorf("storage: repofs: mkdir target: %w", err)
166180
 	}
167181
 	// G204: path is constructed via RepoPath (strict whitelist) and verified
168182
 	// to live under r.root. Caller cannot inject arbitrary args.
169
-	cmd := exec.CommandContext(ctx, "git", "init", "--bare", "--initial-branch=trunk", path) //nolint:gosec
183
+	cmd := exec.CommandContext(ctx, "git", "init", "--bare", "--shared=group", "--initial-branch=trunk", path) //nolint:gosec
170184
 	out, err := cmd.CombinedOutput()
171185
 	if err != nil {
172186
 		return fmt.Errorf("storage: repofs: git init --bare: %w (output: %s)", err, strings.TrimSpace(string(out)))
@@ -197,11 +211,18 @@ func (r *RepoFS) CloneBareShared(ctx context.Context, src, dst string) error {
197211
 	if entries, err := os.ReadDir(dst); err == nil && len(entries) > 0 {
198212
 		return fmt.Errorf("%w: %s", ErrAlreadyExists, dst)
199213
 	}
200
-	if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
214
+	if err := os.MkdirAll(filepath.Dir(dst), 0o2750); err != nil {
201215
 		return fmt.Errorf("storage: repofs: mkdir parent: %w", err)
202216
 	}
217
+	// `git clone --shared` (here: object-alternates flag, NOT a perms
218
+	// flag — same name, different sense than init's --shared=group).
219
+	// To get group-writable perms we set core.sharedRepository=group
220
+	// via -c so the cloned config has it from byte zero. Without this,
221
+	// SSH-git push to a fork hits the same EACCES on objects/ that
222
+	// PR for SR2 #287 fixed for `git init --bare` (see InitBare).
223
+	//
203224
 	// G204: src/dst are RepoPath-derived, both verified under r.root.
204
-	cmd := exec.CommandContext(ctx, "git", "clone", "--bare", "--shared", src, dst) //nolint:gosec
225
+	cmd := exec.CommandContext(ctx, "git", "-c", "core.sharedRepository=group", "clone", "--bare", "--shared", src, dst) //nolint:gosec
205226
 	out, err := cmd.CombinedOutput()
206227
 	if err != nil {
207228
 		// Best-effort cleanup; if removal fails too, surface the
@@ -212,6 +233,54 @@ func (r *RepoFS) CloneBareShared(ctx context.Context, src, dst string) error {
212233
 	return nil
213234
 }
214235
 
236
+// RepairSharedPerms brings an existing bare repo to the
237
+// `--shared=group` contract InitBare now produces from byte zero
238
+// (SR2 #287). Idempotent: a repo already at the contract is left
239
+// alone except for explicitly setting the config (cheap).
240
+//
241
+// Steps:
242
+//  1. `git config core.sharedRepository=group`
243
+//  2. `chmod -R g+w` and `find -type d -exec chmod g+s` so future
244
+//     writes inherit the group on creation.
245
+//
246
+// Group ownership itself is NOT changed — the shipped invariant is
247
+// that all repos are owned by the `shithub` group already (the
248
+// shithub user creates them). If a repo's group is wrong, that's a
249
+// separate provisioning bug; this method's job is only the bits.
250
+func (r *RepoFS) RepairSharedPerms(ctx context.Context, path string) error {
251
+	if err := r.containedInRoot(path); err != nil {
252
+		return err
253
+	}
254
+	if _, err := os.Stat(path); err != nil {
255
+		return fmt.Errorf("storage: repofs: stat %s: %w", path, err)
256
+	}
257
+	// Persist the contract in config.
258
+	cfg := exec.CommandContext(ctx, "git", "-C", path, "config", "core.sharedRepository", "group") //nolint:gosec
259
+	if out, err := cfg.CombinedOutput(); err != nil {
260
+		return fmt.Errorf("storage: repofs: git config sharedRepository: %w (output: %s)", err, strings.TrimSpace(string(out)))
261
+	}
262
+	// Walk the tree once: directories get +g+s, files get +g+w.
263
+	// We use filepath.Walk over an exec.Command(find ...) so the
264
+	// behavior is identical across Linux and macOS test harnesses.
265
+	if err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
266
+		if err != nil {
267
+			return err
268
+		}
269
+		mode := info.Mode()
270
+		newMode := mode | 0o060 // group rw
271
+		if info.IsDir() {
272
+			newMode |= os.ModeSetgid // g+s
273
+		}
274
+		if newMode == mode {
275
+			return nil
276
+		}
277
+		return os.Chmod(p, newMode)
278
+	}); err != nil {
279
+		return fmt.Errorf("storage: repofs: walk chmod: %w", err)
280
+	}
281
+	return nil
282
+}
283
+
215284
 // SetPreciousObjects marks a bare repo's objects as not-prunable. The
216285
 // canonical foot-gun for forks is source-repo `git gc` removing
217286
 // objects that forks reach via alternates; setting this on the source
internal/infra/storage/reposfs_test.gomodified
@@ -170,6 +170,119 @@ func TestInitBare_HEADIsTrunk(t *testing.T) {
170170
 	}
171171
 }
172172
 
173
+// TestInitBare_SharedGroupContract pins SR2 #287:
174
+// `git init --bare --shared=group` MUST be used so two users
175
+// (shithubd-web's `shithub` user and the SSH dispatcher's `git`
176
+// user, both in the `shithub` group) can write to objects/.
177
+//
178
+// Pre-fix the SSH-git push path failed with "unable to create
179
+// temporary object directory" because objects/ was 0755 with no
180
+// group-write bit.
181
+//
182
+// We assert the persisted config + the directory mode bits the
183
+// flag produces.
184
+func TestInitBare_SharedGroupContract(t *testing.T) {
185
+	t.Parallel()
186
+	if _, err := exec.LookPath("git"); err != nil {
187
+		t.Skip("git not in PATH")
188
+	}
189
+	r, _ := mustNewRepoFS(t)
190
+	path, err := r.RepoPath("alice", "sharedgrouptest")
191
+	if err != nil {
192
+		t.Fatalf("RepoPath: %v", err)
193
+	}
194
+	if err := r.InitBare(context.Background(), path); err != nil {
195
+		t.Fatalf("InitBare: %v", err)
196
+	}
197
+
198
+	// 1) config has core.sharedRepository=group. git stores this as
199
+	// the integer "1" internally (0=false, 1=group, 2=all, …);
200
+	// either form satisfies the contract.
201
+	out, err := exec.Command("git", "--git-dir", path, "config", "--get", "core.sharedRepository").Output() //nolint:gosec
202
+	if err != nil {
203
+		t.Fatalf("git config: %v", err)
204
+	}
205
+	got := strings.TrimSpace(string(out))
206
+	if got != "group" && got != "1" {
207
+		t.Fatalf("core.sharedRepository = %q, want \"group\" or \"1\"", got)
208
+	}
209
+
210
+	// 2) objects/ dir has group-write set (mode bit 0o020).
211
+	objects := path + "/objects"
212
+	st, err := os.Stat(objects)
213
+	if err != nil {
214
+		t.Fatalf("stat objects: %v", err)
215
+	}
216
+	mode := st.Mode().Perm()
217
+	if mode&0o020 == 0 {
218
+		t.Fatalf("objects/ mode = %#o; group-write bit (0o020) missing — SSH push will EACCES", mode)
219
+	}
220
+}
221
+
222
+// TestRepairSharedPerms_FixesPreFixRepo pins the backfill path:
223
+// a repo created without --shared=group (the pre-SR2 #287 layout)
224
+// gets brought to the contract by RepairSharedPerms — config flag
225
+// set, group-write bit on objects/, setgid on dirs.
226
+func TestRepairSharedPerms_FixesPreFixRepo(t *testing.T) {
227
+	t.Parallel()
228
+	if _, err := exec.LookPath("git"); err != nil {
229
+		t.Skip("git not in PATH")
230
+	}
231
+	r, root := mustNewRepoFS(t)
232
+	path, err := r.RepoPath("alice", "repairtest")
233
+	if err != nil {
234
+		t.Fatalf("RepoPath: %v", err)
235
+	}
236
+	// Create the parent dir + a deliberately pre-fix bare repo
237
+	// (NO --shared=group). This simulates a live repo from before
238
+	// the fix landed.
239
+	if err := os.MkdirAll(path, 0o750); err != nil {
240
+		t.Fatalf("mkdir: %v", err)
241
+	}
242
+	if out, err := exec.Command("git", "init", "--bare", "--initial-branch=trunk", path).CombinedOutput(); err != nil {
243
+		t.Fatalf("pre-fix init: %v: %s", err, out)
244
+	}
245
+	// Sanity: the pre-fix objects/ should NOT have group-write.
246
+	objects := path + "/objects"
247
+	st, err := os.Stat(objects)
248
+	if err != nil {
249
+		t.Fatalf("stat: %v", err)
250
+	}
251
+	if st.Mode().Perm()&0o020 != 0 {
252
+		t.Skipf("pre-fix init produced 0%o; test environment differs (umask?). Skipping.", st.Mode().Perm())
253
+	}
254
+
255
+	// Run the repair.
256
+	if err := r.RepairSharedPerms(context.Background(), path); err != nil {
257
+		t.Fatalf("RepairSharedPerms: %v", err)
258
+	}
259
+
260
+	// Post-condition: config has the flag.
261
+	out, err := exec.Command("git", "--git-dir", path, "config", "--get", "core.sharedRepository").Output() //nolint:gosec
262
+	if err != nil {
263
+		t.Fatalf("git config: %v", err)
264
+	}
265
+	got := strings.TrimSpace(string(out))
266
+	if got != "group" && got != "1" {
267
+		t.Fatalf("core.sharedRepository = %q, want \"group\" or \"1\"", got)
268
+	}
269
+	// objects/ has g+w.
270
+	st, err = os.Stat(objects)
271
+	if err != nil {
272
+		t.Fatalf("stat after repair: %v", err)
273
+	}
274
+	mode := st.Mode().Perm()
275
+	if mode&0o020 == 0 {
276
+		t.Fatalf("after repair, objects/ mode = %#o; group-write missing", mode)
277
+	}
278
+	// objects/ has setgid.
279
+	if st.Mode()&os.ModeSetgid == 0 {
280
+		t.Fatalf("after repair, objects/ missing setgid bit; new files won't inherit group")
281
+	}
282
+
283
+	_ = root
284
+}
285
+
173286
 func TestInitBare_RefusesNonEmpty(t *testing.T) {
174287
 	t.Parallel()
175288
 	if _, err := exec.LookPath("git"); err != nil {