~bigbes/sr-ht-spec

ref: 7f779fef12194d49b9ce97ad4e2a80af1c3d6358 sr-ht-spec/hooks/hook.go -rw-r--r-- 11.7 KiB
7f779fef — Eugene Blikh feat(web): review queue — inbox + policy-merged digest (Phase 4) 26 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
package hooks

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

// Mode is which git hook this process is acting as.
type Mode string

const (
	ModePreReceive  Mode = "pre-receive"
	ModeUpdate      Mode = "update"
	ModePostReceive Mode = "post-receive"
)

// Modes is every hook this package installs, in the order git runs them.
func Modes() []Mode { return []Mode{ModePreReceive, ModeUpdate, ModePostReceive} }

// Exit codes. git treats any non-zero exit from pre-receive or update as a
// refusal; it ignores post-receive's entirely.
const (
	exitOK      = 0
	exitRefused = 1
	exitUsage   = 2
)

// ModeFromArgs decides whether this process is a git hook, and which one.
//
// Two spellings are accepted, and they are the same mechanism seen from two
// sides. Install writes each hook as a symlink to the specsrht binary, so git
// execs it with argv[0] naming the hook — that is the production path, and it
// is why there is no generated shell stub to keep in sync with this package.
// The explicit "specsrht hook <name>" form exists so an operator can run the
// same code by hand against a repository, which is otherwise impossible to do
// without creating a symlink.
//
// It returns the mode, the hook's own arguments, and whether this is a hook
// invocation at all.
func ModeFromArgs(args []string) (Mode, []string, bool) {
	if len(args) == 0 {
		return "", nil, false
	}
	if m, ok := modeNamed(filepath.Base(args[0])); ok {
		return m, args[1:], true
	}
	if len(args) >= 3 && args[1] == "hook" {
		if m, ok := modeNamed(args[2]); ok {
			return m, args[3:], true
		}
	}
	return "", nil, false
}

func modeNamed(s string) (Mode, bool) {
	for _, m := range Modes() {
		if string(m) == s {
			return m, true
		}
	}
	return "", false
}

// Runtime is everything Run touches outside its own package, so a test can
// drive a hook without a real push, a real environment or a real repository.
type Runtime struct {
	// Args is the full argument vector, argv[0] included.
	Args []string

	// Env, Stdin, Stderr, Getwd and EvalSymlinks default to the process's own
	// when nil.
	Env          Lookup
	Stdin        io.Reader
	Stderr       io.Writer
	Getwd        func() (string, error)
	EvalSymlinks func(string) (string, error)

	// PushID correlates the hooks of one push. It defaults to the pid of the
	// receive-pack process this hook is a child of.
	PushID func() string

	// DialTimeout and Timeout override the client's defaults.
	DialTimeout time.Duration
	Timeout     time.Duration
}

func (rt Runtime) withDefaults() Runtime {
	if rt.Env == nil {
		rt.Env = os.LookupEnv
	}
	if rt.Stdin == nil {
		rt.Stdin = os.Stdin
	}
	if rt.Stderr == nil {
		rt.Stderr = os.Stderr
	}
	if rt.Getwd == nil {
		rt.Getwd = os.Getwd
	}
	if rt.EvalSymlinks == nil {
		rt.EvalSymlinks = filepath.EvalSymlinks
	}
	if rt.PushID == nil {
		rt.PushID = func() string { return strconv.Itoa(os.Getppid()) }
	}
	return rt
}

// Run executes this process as a git hook and returns the exit code.
//
// It never returns an error: a hook communicates by writing to standard error
// — which git forwards to the pushing client, prefixed with "remote: " — and
// by its exit status. Everything it has to say is therefore said here, in full
// sentences, because this text is the entire user interface of a failed push.
func Run(rt Runtime) int {
	rt = rt.withDefaults()

	mode, args, ok := ModeFromArgs(rt.Args)
	if !ok {
		fmt.Fprintf(rt.Stderr, "not a git hook invocation; run this binary as %s, %s or %s\n",
			ModePreReceive, ModeUpdate, ModePostReceive)
		return exitUsage
	}

	ctx := context.Background()
	switch mode {
	case ModePreReceive:
		return runPreReceive(ctx, rt)
	case ModeUpdate:
		return runUpdate(ctx, rt, args)
	case ModePostReceive:
		return runPostReceive(ctx, rt)
	default:
		fmt.Fprintf(rt.Stderr, "unhandled hook %q\n", mode)
		return exitUsage
	}
}

// context assembles the facts every call needs: which repository, which
// socket, which credential, which push.
type hookContext struct {
	repo   string
	socket string
	cred   Credential
	push   string
	client Client
}

func (rt Runtime) hookContext() (hookContext, error) {
	repo, err := RepoDir(rt.Env, rt.Getwd, rt.EvalSymlinks)
	if err != nil {
		return hookContext{}, err
	}
	cred, err := CredentialFromEnv(rt.Env)
	if err != nil {
		return hookContext{}, err
	}
	socket := ResolveSocket(rt.Env, repo)
	return hookContext{
		repo:   repo,
		socket: socket,
		cred:   cred,
		push:   rt.PushID(),
		client: Client{Socket: socket, DialTimeout: rt.DialTimeout, Timeout: rt.Timeout},
	}, nil
}

// runPreReceive forwards the push options — the only hook git gives them to —
// and the full list of proposed updates, so the update calls that follow know
// whether validation was waived. It rejects nothing on content; it rejects on
// not being able to talk to the daemon, because failing here costs the pusher
// one message instead of one per ref.
func runPreReceive(ctx context.Context, rt Runtime) int {
	updates, err := readRefUpdates(rt.Stdin)
	if err != nil {
		writeMisconfigured(rt.Stderr, err)
		return exitRefused
	}
	if len(updates) == 0 {
		// git does not run pre-receive with an empty command list; if it ever
		// does, there is nothing to record and nothing to refuse.
		return exitOK
	}

	opts, err := PushOptions(rt.Env)
	if err != nil {
		writeMisconfigured(rt.Stderr, err)
		return exitRefused
	}

	hc, err := rt.hookContext()
	if err != nil {
		writeMisconfigured(rt.Stderr, err)
		return exitRefused
	}

	resp, err := hc.client.Call(ctx, Request{
		Version:    ProtocolVersion,
		Method:     MethodPushOptions,
		Repo:       hc.repo,
		Push:       hc.push,
		Credential: hc.cred,
		Options:    opts,
		Updates:    updates,
	})
	if err != nil {
		writeUnreachable(rt.Stderr, hc, "", err)
		return exitRefused
	}
	return report(rt.Stderr, hc, "", resp)
}

// runUpdate is the rejecting hook: the refs rule and content validation for
// one ref, before that ref moves. It is the earliest point at which the daemon
// can read what is being pushed — during pre-receive the objects are still in
// receive-pack's quarantine and invisible to any other process.
func runUpdate(ctx context.Context, rt Runtime, args []string) int {
	if len(args) != 3 {
		writeMisconfigured(rt.Stderr, fmt.Errorf(
			"the update hook takes <ref> <old> <new>, got %d argument(s)", len(args)))
		return exitRefused
	}
	update := RefUpdate{Ref: args[0], Old: args[1], New: args[2]}

	hc, err := rt.hookContext()
	if err != nil {
		writeMisconfigured(rt.Stderr, err)
		return exitRefused
	}

	resp, err := hc.client.Call(ctx, Request{
		Version:    ProtocolVersion,
		Method:     MethodValidateRef,
		Repo:       hc.repo,
		Push:       hc.push,
		Credential: hc.cred,
		Updates:    []RefUpdate{update},
	})
	if err != nil {
		writeUnreachable(rt.Stderr, hc, update.Ref, err)
		return exitRefused
	}
	return report(rt.Stderr, hc, update.Ref, resp)
}

// runPostReceive tells the daemon the push landed. It cannot reject anything —
// git has already moved the refs and ignores this exit status — so a failure
// is a warning, and the reconciler is the backstop that repairs the index rev
// stamp this call was supposed to advance.
func runPostReceive(ctx context.Context, rt Runtime) int {
	updates, err := readRefUpdates(rt.Stdin)
	if err != nil {
		writeNotNotified(rt.Stderr, err)
		return exitOK
	}
	if len(updates) == 0 {
		return exitOK
	}

	hc, err := rt.hookContext()
	if err != nil {
		writeNotNotified(rt.Stderr, err)
		return exitOK
	}

	resp, err := hc.client.Call(ctx, Request{
		Version:    ProtocolVersion,
		Method:     MethodPushed,
		Repo:       hc.repo,
		Push:       hc.push,
		Credential: hc.cred,
		Updates:    updates,
	})
	switch {
	case err != nil:
		writeNotNotified(rt.Stderr, err)
	case resp.Rejected:
		writeNotNotified(rt.Stderr, fmt.Errorf("the daemon refused the notification: %s",
			strings.TrimSpace(resp.Message)))
	case resp.Error != "":
		writeNotNotified(rt.Stderr, fmt.Errorf("the daemon failed to record the push: %s", resp.Error))
	}
	return exitOK
}

// report turns a well-formed response into an exit code and, when it is not an
// acceptance, the text the pusher reads.
func report(w io.Writer, hc hookContext, ref string, resp Response) int {
	switch {
	case resp.OK:
		return exitOK
	case resp.Rejected:
		// The daemon composed this for a terminal; print it as written rather
		// than wrapping it in a second frame.
		msg := strings.TrimRight(resp.Message, "\n")
		fmt.Fprintf(w, "%s\n", msg)
		return exitRefused
	default:
		writeUnreachable(w, hc, ref, fmt.Errorf("%s", resp.Error))
		return exitRefused
	}
}

// readRefUpdates parses the "<old> <new> <ref>" lines git feeds pre-receive and
// post-receive on standard input.
//
// Standard input is read to the end even on a malformed line: git writes the
// whole list before waiting, and a hook that exits early enough leaves it
// writing into a closed pipe.
func readRefUpdates(r io.Reader) ([]RefUpdate, error) {
	var (
		updates []RefUpdate
		bad     error
	)
	sc := bufio.NewScanner(io.LimitReader(r, maxMessageBytes))
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" {
			continue
		}
		fields := strings.Fields(line)
		if len(fields) != 3 {
			if bad == nil {
				bad = fmt.Errorf("git sent %q, which is not \"<old> <new> <ref>\"", line)
			}
			continue
		}
		updates = append(updates, RefUpdate{Old: fields[0], New: fields[1], Ref: fields[2]})
	}
	if err := sc.Err(); err != nil {
		return nil, fmt.Errorf("read the ref list git sent on standard input: %w", err)
	}
	if bad != nil {
		return nil, bad
	}
	return updates, nil
}

// writeMisconfigured reports a problem with how the hook itself is wired: no
// principal in the environment, an unreadable repository path, a nonsensical
// argument vector. None of it is the pusher's fault and none of it is fixed by
// changing what they pushed, so the message says so.
func writeMisconfigured(w io.Writer, err error) {
	fmt.Fprintf(w, "spec.sr.ht refused this push: its receive hook is misconfigured.\n\n")
	fmt.Fprintf(w, "  %v\n\n", err)
	fmt.Fprintf(w, "This is a server-side wiring problem, not a problem with what you\n")
	fmt.Fprintf(w, "pushed. Nothing was written.\n")
}

// writeUnreachable is the fail-closed message: the daemon could not be reached,
// or could not answer, so the push is refused unvalidated rather than accepted
// unvalidated.
func writeUnreachable(w io.Writer, hc hookContext, ref string, cause error) {
	fmt.Fprintf(w, "spec.sr.ht could not validate this push, so it was refused.\n\n")
	fmt.Fprintf(w, "  repository: %s\n", hc.repo)
	if ref != "" {
		fmt.Fprintf(w, "  ref:        %s\n", ref)
	}
	fmt.Fprintf(w, "  daemon:     %s\n\n", hc.socket)
	fmt.Fprintf(w, "  %v\n\n", cause)
	fmt.Fprintf(w, "Nothing was written; the ref still points where it did.\n\n")
	fmt.Fprintf(w, "spec.sr.ht refuses a push it cannot validate rather than accepting it\n")
	fmt.Fprintf(w, "unchecked: a refused push costs you one command, an unvalidated one\n")
	fmt.Fprintf(w, "corrupts the document registry silently and surfaces weeks later.\n")
	fmt.Fprintf(w, "--push-option=%s does not help here — it waives frontmatter\n", OptionSkipValidation)
	fmt.Fprintf(w, "and document-id checks, not the daemon that performs them.\n\n")
	fmt.Fprintf(w, "Start the spec.sr.ht daemon and push again.\n")
}

// writeNotNotified is post-receive's only failure mode. The refs are already
// updated and cannot be taken back, so this warns and names the backstop.
func writeNotNotified(w io.Writer, cause error) {
	fmt.Fprintf(w, "warning: spec.sr.ht was not told that this push landed.\n")
	fmt.Fprintf(w, "warning:   %v\n", cause)
	fmt.Fprintf(w, "warning: the refs are updated and your content is safe, but this space's\n")
	fmt.Fprintf(w, "warning: search index is now stale. The reconciler repairs it at the next\n")
	fmt.Fprintf(w, "warning: daemon start and on its periodic pass.\n")
}