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 " 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 , 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 " " 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 \" \"", 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") }