| 1 |
|
package hooks |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"bufio" |
| 5 |
|
"context" |
| 6 |
|
"fmt" |
| 7 |
|
"io" |
| 8 |
|
"os" |
| 9 |
|
"path/filepath" |
| 10 |
|
"strconv" |
| 11 |
|
"strings" |
| 12 |
|
"time" |
| 13 |
|
) |
| 14 |
|
|
| 15 |
|
// Mode is which git hook this process is acting as. |
| 16 |
|
type Mode string |
| 17 |
|
|
| 18 |
|
const ( |
| 19 |
|
ModePreReceive Mode = "pre-receive" |
| 20 |
|
ModeUpdate Mode = "update" |
| 21 |
|
ModePostReceive Mode = "post-receive" |
| 22 |
|
) |
| 23 |
|
|
| 24 |
|
// Modes is every hook this package installs, in the order git runs them. |
| 25 |
82 |
func Modes() []Mode { return []Mode{ModePreReceive, ModeUpdate, ModePostReceive} } |
| 26 |
|
|
| 27 |
|
// Exit codes. git treats any non-zero exit from pre-receive or update as a |
| 28 |
|
// refusal; it ignores post-receive's entirely. |
| 29 |
|
const ( |
| 30 |
|
exitOK = 0 |
| 31 |
|
exitRefused = 1 |
| 32 |
|
exitUsage = 2 |
| 33 |
|
) |
| 34 |
|
|
| 35 |
|
// ModeFromArgs decides whether this process is a git hook, and which one. |
| 36 |
|
// |
| 37 |
|
// Two spellings are accepted, and they are the same mechanism seen from two |
| 38 |
|
// sides. Install writes each hook as a symlink to the specsrht binary, so git |
| 39 |
|
// execs it with argv[0] naming the hook — that is the production path, and it |
| 40 |
|
// is why there is no generated shell stub to keep in sync with this package. |
| 41 |
|
// The explicit "specsrht hook <name>" form exists so an operator can run the |
| 42 |
|
// same code by hand against a repository, which is otherwise impossible to do |
| 43 |
|
// without creating a symlink. |
| 44 |
|
// |
| 45 |
|
// It returns the mode, the hook's own arguments, and whether this is a hook |
| 46 |
|
// invocation at all. |
| 47 |
70 |
func ModeFromArgs(args []string) (Mode, []string, bool) { |
| 48 |
70 |
if len(args) == 0 { |
| 49 |
1 |
return "", nil, false |
| 50 |
1 |
} |
| 51 |
69 |
if m, ok := modeNamed(filepath.Base(args[0])); ok { |
| 52 |
63 |
return m, args[1:], true |
| 53 |
63 |
} |
| 54 |
6 |
if len(args) >= 3 && args[1] == "hook" { |
| 55 |
1 |
if m, ok := modeNamed(args[2]); ok { |
| 56 |
1 |
return m, args[3:], true |
| 57 |
1 |
} |
| 58 |
|
} |
| 59 |
5 |
return "", nil, false |
| 60 |
|
} |
| 61 |
|
|
| 62 |
70 |
func modeNamed(s string) (Mode, bool) { |
| 63 |
126 |
for _, m := range Modes() { |
| 64 |
126 |
if string(m) == s { |
| 65 |
64 |
return m, true |
| 66 |
64 |
} |
| 67 |
|
} |
| 68 |
6 |
return "", false |
| 69 |
|
} |
| 70 |
|
|
| 71 |
|
// Runtime is everything Run touches outside its own package, so a test can |
| 72 |
|
// drive a hook without a real push, a real environment or a real repository. |
| 73 |
|
type Runtime struct { |
| 74 |
|
// Args is the full argument vector, argv[0] included. |
| 75 |
|
Args []string |
| 76 |
|
|
| 77 |
|
// Env, Stdin, Stderr, Getwd and EvalSymlinks default to the process's own |
| 78 |
|
// when nil. |
| 79 |
|
Env Lookup |
| 80 |
|
Stdin io.Reader |
| 81 |
|
Stderr io.Writer |
| 82 |
|
Getwd func() (string, error) |
| 83 |
|
EvalSymlinks func(string) (string, error) |
| 84 |
|
|
| 85 |
|
// PushID correlates the hooks of one push. It defaults to the pid of the |
| 86 |
|
// receive-pack process this hook is a child of. |
| 87 |
|
PushID func() string |
| 88 |
|
|
| 89 |
|
// DialTimeout and Timeout override the client's defaults. |
| 90 |
|
DialTimeout time.Duration |
| 91 |
|
Timeout time.Duration |
| 92 |
|
} |
| 93 |
|
|
| 94 |
37 |
func (rt Runtime) withDefaults() Runtime { |
| 95 |
37 |
if rt.Env == nil { |
| 96 |
25 |
rt.Env = os.LookupEnv |
| 97 |
25 |
} |
| 98 |
37 |
if rt.Stdin == nil { |
| 99 |
25 |
rt.Stdin = os.Stdin |
| 100 |
25 |
} |
| 101 |
37 |
if rt.Stderr == nil { |
| 102 |
24 |
rt.Stderr = os.Stderr |
| 103 |
24 |
} |
| 104 |
37 |
if rt.Getwd == nil { |
| 105 |
25 |
rt.Getwd = os.Getwd |
| 106 |
25 |
} |
| 107 |
37 |
if rt.EvalSymlinks == nil { |
| 108 |
25 |
rt.EvalSymlinks = filepath.EvalSymlinks |
| 109 |
25 |
} |
| 110 |
37 |
if rt.PushID == nil { |
| 111 |
25 |
rt.PushID = func() string { return strconv.Itoa(os.Getppid()) } |
| 112 |
|
} |
| 113 |
37 |
return rt |
| 114 |
|
} |
| 115 |
|
|
| 116 |
|
// Run executes this process as a git hook and returns the exit code. |
| 117 |
|
// |
| 118 |
|
// It never returns an error: a hook communicates by writing to standard error |
| 119 |
|
// — which git forwards to the pushing client, prefixed with "remote: " — and |
| 120 |
|
// by its exit status. Everything it has to say is therefore said here, in full |
| 121 |
|
// sentences, because this text is the entire user interface of a failed push. |
| 122 |
37 |
func Run(rt Runtime) int { |
| 123 |
37 |
rt = rt.withDefaults() |
| 124 |
37 |
|
| 125 |
37 |
mode, args, ok := ModeFromArgs(rt.Args) |
| 126 |
37 |
if !ok { |
| 127 |
1 |
fmt.Fprintf(rt.Stderr, "not a git hook invocation; run this binary as %s, %s or %s\n", |
| 128 |
1 |
ModePreReceive, ModeUpdate, ModePostReceive) |
| 129 |
1 |
return exitUsage |
| 130 |
1 |
} |
| 131 |
|
|
| 132 |
36 |
ctx := context.Background() |
| 133 |
36 |
switch mode { |
| 134 |
17 |
case ModePreReceive: |
| 135 |
17 |
return runPreReceive(ctx, rt) |
| 136 |
14 |
case ModeUpdate: |
| 137 |
14 |
return runUpdate(ctx, rt, args) |
| 138 |
5 |
case ModePostReceive: |
| 139 |
5 |
return runPostReceive(ctx, rt) |
| 140 |
0 |
default: |
| 141 |
0 |
fmt.Fprintf(rt.Stderr, "unhandled hook %q\n", mode) |
| 142 |
0 |
return exitUsage |
| 143 |
|
} |
| 144 |
|
} |
| 145 |
|
|
| 146 |
|
// context assembles the facts every call needs: which repository, which |
| 147 |
|
// socket, which credential, which push. |
| 148 |
|
type hookContext struct { |
| 149 |
|
repo string |
| 150 |
|
socket string |
| 151 |
|
cred Credential |
| 152 |
|
push string |
| 153 |
|
client Client |
| 154 |
|
} |
| 155 |
|
|
| 156 |
34 |
func (rt Runtime) hookContext() (hookContext, error) { |
| 157 |
34 |
repo, err := RepoDir(rt.Env, rt.Getwd, rt.EvalSymlinks) |
| 158 |
34 |
if err != nil { |
| 159 |
0 |
return hookContext{}, err |
| 160 |
0 |
} |
| 161 |
34 |
cred, err := CredentialFromEnv(rt.Env) |
| 162 |
34 |
if err != nil { |
| 163 |
1 |
return hookContext{}, err |
| 164 |
1 |
} |
| 165 |
33 |
socket := ResolveSocket(rt.Env, repo) |
| 166 |
33 |
return hookContext{ |
| 167 |
33 |
repo: repo, |
| 168 |
33 |
socket: socket, |
| 169 |
33 |
cred: cred, |
| 170 |
33 |
push: rt.PushID(), |
| 171 |
33 |
client: Client{Socket: socket, DialTimeout: rt.DialTimeout, Timeout: rt.Timeout}, |
| 172 |
33 |
}, nil |
| 173 |
|
} |
| 174 |
|
|
| 175 |
|
// runPreReceive forwards the push options — the only hook git gives them to — |
| 176 |
|
// and the full list of proposed updates, so the update calls that follow know |
| 177 |
|
// whether validation was waived. It rejects nothing on content; it rejects on |
| 178 |
|
// not being able to talk to the daemon, because failing here costs the pusher |
| 179 |
|
// one message instead of one per ref. |
| 180 |
17 |
func runPreReceive(ctx context.Context, rt Runtime) int { |
| 181 |
17 |
updates, err := readRefUpdates(rt.Stdin) |
| 182 |
17 |
if err != nil { |
| 183 |
1 |
writeMisconfigured(rt.Stderr, err) |
| 184 |
1 |
return exitRefused |
| 185 |
1 |
} |
| 186 |
16 |
if len(updates) == 0 { |
| 187 |
0 |
// git does not run pre-receive with an empty command list; if it ever |
| 188 |
0 |
// does, there is nothing to record and nothing to refuse. |
| 189 |
0 |
return exitOK |
| 190 |
0 |
} |
| 191 |
|
|
| 192 |
16 |
opts, err := PushOptions(rt.Env) |
| 193 |
16 |
if err != nil { |
| 194 |
0 |
writeMisconfigured(rt.Stderr, err) |
| 195 |
0 |
return exitRefused |
| 196 |
0 |
} |
| 197 |
|
|
| 198 |
16 |
hc, err := rt.hookContext() |
| 199 |
16 |
if err != nil { |
| 200 |
0 |
writeMisconfigured(rt.Stderr, err) |
| 201 |
0 |
return exitRefused |
| 202 |
0 |
} |
| 203 |
|
|
| 204 |
16 |
resp, err := hc.client.Call(ctx, Request{ |
| 205 |
16 |
Version: ProtocolVersion, |
| 206 |
16 |
Method: MethodPushOptions, |
| 207 |
16 |
Repo: hc.repo, |
| 208 |
16 |
Push: hc.push, |
| 209 |
16 |
Credential: hc.cred, |
| 210 |
16 |
Options: opts, |
| 211 |
16 |
Updates: updates, |
| 212 |
16 |
}) |
| 213 |
16 |
if err != nil { |
| 214 |
2 |
writeUnreachable(rt.Stderr, hc, "", err) |
| 215 |
2 |
return exitRefused |
| 216 |
2 |
} |
| 217 |
14 |
return report(rt.Stderr, hc, "", resp) |
| 218 |
|
} |
| 219 |
|
|
| 220 |
|
// runUpdate is the rejecting hook: the refs rule and content validation for |
| 221 |
|
// one ref, before that ref moves. It is the earliest point at which the daemon |
| 222 |
|
// can read what is being pushed — during pre-receive the objects are still in |
| 223 |
|
// receive-pack's quarantine and invisible to any other process. |
| 224 |
14 |
func runUpdate(ctx context.Context, rt Runtime, args []string) int { |
| 225 |
14 |
if len(args) != 3 { |
| 226 |
1 |
writeMisconfigured(rt.Stderr, fmt.Errorf( |
| 227 |
1 |
"the update hook takes <ref> <old> <new>, got %d argument(s)", len(args))) |
| 228 |
1 |
return exitRefused |
| 229 |
1 |
} |
| 230 |
13 |
update := RefUpdate{Ref: args[0], Old: args[1], New: args[2]} |
| 231 |
13 |
|
| 232 |
13 |
hc, err := rt.hookContext() |
| 233 |
13 |
if err != nil { |
| 234 |
1 |
writeMisconfigured(rt.Stderr, err) |
| 235 |
1 |
return exitRefused |
| 236 |
1 |
} |
| 237 |
|
|
| 238 |
12 |
resp, err := hc.client.Call(ctx, Request{ |
| 239 |
12 |
Version: ProtocolVersion, |
| 240 |
12 |
Method: MethodValidateRef, |
| 241 |
12 |
Repo: hc.repo, |
| 242 |
12 |
Push: hc.push, |
| 243 |
12 |
Credential: hc.cred, |
| 244 |
12 |
Updates: []RefUpdate{update}, |
| 245 |
12 |
}) |
| 246 |
12 |
if err != nil { |
| 247 |
1 |
writeUnreachable(rt.Stderr, hc, update.Ref, err) |
| 248 |
1 |
return exitRefused |
| 249 |
1 |
} |
| 250 |
11 |
return report(rt.Stderr, hc, update.Ref, resp) |
| 251 |
|
} |
| 252 |
|
|
| 253 |
|
// runPostReceive tells the daemon the push landed. It cannot reject anything — |
| 254 |
|
// git has already moved the refs and ignores this exit status — so a failure |
| 255 |
|
// is a warning, and the reconciler is the backstop that repairs the index rev |
| 256 |
|
// stamp this call was supposed to advance. |
| 257 |
5 |
func runPostReceive(ctx context.Context, rt Runtime) int { |
| 258 |
5 |
updates, err := readRefUpdates(rt.Stdin) |
| 259 |
5 |
if err != nil { |
| 260 |
0 |
writeNotNotified(rt.Stderr, err) |
| 261 |
0 |
return exitOK |
| 262 |
0 |
} |
| 263 |
5 |
if len(updates) == 0 { |
| 264 |
0 |
return exitOK |
| 265 |
0 |
} |
| 266 |
|
|
| 267 |
5 |
hc, err := rt.hookContext() |
| 268 |
5 |
if err != nil { |
| 269 |
0 |
writeNotNotified(rt.Stderr, err) |
| 270 |
0 |
return exitOK |
| 271 |
0 |
} |
| 272 |
|
|
| 273 |
5 |
resp, err := hc.client.Call(ctx, Request{ |
| 274 |
5 |
Version: ProtocolVersion, |
| 275 |
5 |
Method: MethodPushed, |
| 276 |
5 |
Repo: hc.repo, |
| 277 |
5 |
Push: hc.push, |
| 278 |
5 |
Credential: hc.cred, |
| 279 |
5 |
Updates: updates, |
| 280 |
5 |
}) |
| 281 |
5 |
switch { |
| 282 |
1 |
case err != nil: |
| 283 |
1 |
writeNotNotified(rt.Stderr, err) |
| 284 |
0 |
case resp.Rejected: |
| 285 |
0 |
writeNotNotified(rt.Stderr, fmt.Errorf("the daemon refused the notification: %s", |
| 286 |
0 |
strings.TrimSpace(resp.Message))) |
| 287 |
0 |
case resp.Error != "": |
| 288 |
0 |
writeNotNotified(rt.Stderr, fmt.Errorf("the daemon failed to record the push: %s", resp.Error)) |
| 289 |
|
} |
| 290 |
5 |
return exitOK |
| 291 |
|
} |
| 292 |
|
|
| 293 |
|
// report turns a well-formed response into an exit code and, when it is not an |
| 294 |
|
// acceptance, the text the pusher reads. |
| 295 |
25 |
func report(w io.Writer, hc hookContext, ref string, resp Response) int { |
| 296 |
25 |
switch { |
| 297 |
17 |
case resp.OK: |
| 298 |
17 |
return exitOK |
| 299 |
8 |
case resp.Rejected: |
| 300 |
8 |
// The daemon composed this for a terminal; print it as written rather |
| 301 |
8 |
// than wrapping it in a second frame. |
| 302 |
8 |
msg := strings.TrimRight(resp.Message, "\n") |
| 303 |
8 |
fmt.Fprintf(w, "%s\n", msg) |
| 304 |
8 |
return exitRefused |
| 305 |
0 |
default: |
| 306 |
0 |
writeUnreachable(w, hc, ref, fmt.Errorf("%s", resp.Error)) |
| 307 |
0 |
return exitRefused |
| 308 |
|
} |
| 309 |
|
} |
| 310 |
|
|
| 311 |
|
// readRefUpdates parses the "<old> <new> <ref>" lines git feeds pre-receive and |
| 312 |
|
// post-receive on standard input. |
| 313 |
|
// |
| 314 |
|
// Standard input is read to the end even on a malformed line: git writes the |
| 315 |
|
// whole list before waiting, and a hook that exits early enough leaves it |
| 316 |
|
// writing into a closed pipe. |
| 317 |
22 |
func readRefUpdates(r io.Reader) ([]RefUpdate, error) { |
| 318 |
22 |
var ( |
| 319 |
22 |
updates []RefUpdate |
| 320 |
22 |
bad error |
| 321 |
22 |
) |
| 322 |
22 |
sc := bufio.NewScanner(io.LimitReader(r, maxMessageBytes)) |
| 323 |
22 |
for sc.Scan() { |
| 324 |
22 |
line := strings.TrimSpace(sc.Text()) |
| 325 |
22 |
if line == "" { |
| 326 |
0 |
continue |
| 327 |
|
} |
| 328 |
22 |
fields := strings.Fields(line) |
| 329 |
22 |
if len(fields) != 3 { |
| 330 |
1 |
if bad == nil { |
| 331 |
1 |
bad = fmt.Errorf("git sent %q, which is not \"<old> <new> <ref>\"", line) |
| 332 |
1 |
} |
| 333 |
1 |
continue |
| 334 |
|
} |
| 335 |
21 |
updates = append(updates, RefUpdate{Old: fields[0], New: fields[1], Ref: fields[2]}) |
| 336 |
|
} |
| 337 |
22 |
if err := sc.Err(); err != nil { |
| 338 |
0 |
return nil, fmt.Errorf("read the ref list git sent on standard input: %w", err) |
| 339 |
0 |
} |
| 340 |
22 |
if bad != nil { |
| 341 |
1 |
return nil, bad |
| 342 |
1 |
} |
| 343 |
21 |
return updates, nil |
| 344 |
|
} |
| 345 |
|
|
| 346 |
|
// writeMisconfigured reports a problem with how the hook itself is wired: no |
| 347 |
|
// principal in the environment, an unreadable repository path, a nonsensical |
| 348 |
|
// argument vector. None of it is the pusher's fault and none of it is fixed by |
| 349 |
|
// changing what they pushed, so the message says so. |
| 350 |
3 |
func writeMisconfigured(w io.Writer, err error) { |
| 351 |
3 |
fmt.Fprintf(w, "spec.sr.ht refused this push: its receive hook is misconfigured.\n\n") |
| 352 |
3 |
fmt.Fprintf(w, " %v\n\n", err) |
| 353 |
3 |
fmt.Fprintf(w, "This is a server-side wiring problem, not a problem with what you\n") |
| 354 |
3 |
fmt.Fprintf(w, "pushed. Nothing was written.\n") |
| 355 |
3 |
} |
| 356 |
|
|
| 357 |
|
// writeUnreachable is the fail-closed message: the daemon could not be reached, |
| 358 |
|
// or could not answer, so the push is refused unvalidated rather than accepted |
| 359 |
|
// unvalidated. |
| 360 |
3 |
func writeUnreachable(w io.Writer, hc hookContext, ref string, cause error) { |
| 361 |
3 |
fmt.Fprintf(w, "spec.sr.ht could not validate this push, so it was refused.\n\n") |
| 362 |
3 |
fmt.Fprintf(w, " repository: %s\n", hc.repo) |
| 363 |
3 |
if ref != "" { |
| 364 |
1 |
fmt.Fprintf(w, " ref: %s\n", ref) |
| 365 |
1 |
} |
| 366 |
3 |
fmt.Fprintf(w, " daemon: %s\n\n", hc.socket) |
| 367 |
3 |
fmt.Fprintf(w, " %v\n\n", cause) |
| 368 |
3 |
fmt.Fprintf(w, "Nothing was written; the ref still points where it did.\n\n") |
| 369 |
3 |
fmt.Fprintf(w, "spec.sr.ht refuses a push it cannot validate rather than accepting it\n") |
| 370 |
3 |
fmt.Fprintf(w, "unchecked: a refused push costs you one command, an unvalidated one\n") |
| 371 |
3 |
fmt.Fprintf(w, "corrupts the document registry silently and surfaces weeks later.\n") |
| 372 |
3 |
fmt.Fprintf(w, "--push-option=%s does not help here — it waives frontmatter\n", OptionSkipValidation) |
| 373 |
3 |
fmt.Fprintf(w, "and document-id checks, not the daemon that performs them.\n\n") |
| 374 |
3 |
fmt.Fprintf(w, "Start the spec.sr.ht daemon and push again.\n") |
| 375 |
|
} |
| 376 |
|
|
| 377 |
|
// writeNotNotified is post-receive's only failure mode. The refs are already |
| 378 |
|
// updated and cannot be taken back, so this warns and names the backstop. |
| 379 |
1 |
func writeNotNotified(w io.Writer, cause error) { |
| 380 |
1 |
fmt.Fprintf(w, "warning: spec.sr.ht was not told that this push landed.\n") |
| 381 |
1 |
fmt.Fprintf(w, "warning: %v\n", cause) |
| 382 |
1 |
fmt.Fprintf(w, "warning: the refs are updated and your content is safe, but this space's\n") |
| 383 |
1 |
fmt.Fprintf(w, "warning: search index is now stale. The reconciler repairs it at the next\n") |
| 384 |
1 |
fmt.Fprintf(w, "warning: daemon start and on its periodic pass.\n") |
| 385 |
1 |
} |