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