package hooks
import (
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
)
// ProtocolVersion is the wire version. The hook and the daemon are the same
// binary in every supported deployment, so a mismatch means a repository's
// hook symlinks point at a different build than the running daemon — a
// half-finished upgrade. It is refused loudly rather than negotiated: there is
// no old version to be compatible with, and guessing at an unknown peer's
// semantics on the write path is exactly the wrong trade.
const ProtocolVersion = 1
// maxMessageBytes caps one request or response. A push with thousands of refs
// would exceed it and be rejected, which is the right answer — nothing in this
// model pushes thousands of refs, and an unbounded read on a socket any local
// process can connect to is a way to kill the daemon.
const maxMessageBytes = 1 << 20
// Method names one of the three calls the hooks make. Each corresponds to
// exactly one git hook; see the package documentation for why the work splits
// this way.
type Method string
const (
// MethodPushOptions is `pre-receive`: here are the push options and every
// ref this push proposes to update. The daemon records them for the
// `update` calls that follow. It validates nothing — during pre-receive
// the pushed objects are still in receive-pack's quarantine and are not
// readable by the daemon.
MethodPushOptions Method = "push-options"
// MethodValidateRef is `update`: may this one ref move, and is what it
// moves to valid? This is the call that rejects a push.
MethodValidateRef Method = "validate-ref"
// MethodPushed is `post-receive`: these refs moved. The daemon reindexes
// and advances the space's index rev stamp. Its answer cannot stop
// anything; git has already updated the refs.
MethodPushed Method = "pushed"
)
// PrincipalKind is who the forced-command wrapper says is pushing. It is
// deliberately not gitx.PrincipalKind: this is a wire value whose spelling is
// part of a compatibility contract, and the daemon maps it onto an
// authn.Principal after resolving the credential rather than trusting it.
type PrincipalKind string
const (
// PrincipalOwner is the instance owner, authenticated by sshd against
// their SSH key before the forced command ran.
PrincipalOwner PrincipalKind = "owner"
// PrincipalAgent is an agent presenting a token, which the daemon
// validates against the database on every push.
PrincipalAgent PrincipalKind = "agent"
)
// Credential is the identity half of a request: what the hook's environment
// claims, plus whatever secret backs the claim. Token is never logged.
type Credential struct {
Kind PrincipalKind `json:"kind"`
// Token is the agent's secret, required for PrincipalAgent and empty
// otherwise. The daemon hashes and looks it up; the hook does not parse it.
Token string `json:"token,omitempty"`
// Agent and Session are the provenance fields an agent write must carry.
// They are forwarded unvalidated: the write plane is where they are
// demanded, and a push is not an agent write.
Agent string `json:"agent,omitempty"`
Session string `json:"session,omitempty"`
}
// RefUpdate is one proposed or completed ref move, exactly as git spells it on
// the hook's command line or standard input. The object names stay hex strings
// all the way to service.PushRequest, so an unparseable one is a rejection
// rather than something that silently becomes the zero hash — which the refs
// rule would read as a branch creation.
type RefUpdate struct {
Ref string `json:"ref"`
Old string `json:"old"`
New string `json:"new"`
}
func (u RefUpdate) String() string {
return fmt.Sprintf("%s %s..%s", u.Ref, shortOID(u.Old), shortOID(u.New))
}
func shortOID(s string) string {
if len(s) > 8 {
return s[:8]
}
if s == "" {
return "-"
}
return s
}
// Request is one call from a hook to the daemon.
type Request struct {
Version int `json:"version"`
Method Method `json:"method"`
// Repo is the absolute path of the bare repository the hook is running in,
// with symlinks resolved. The daemon turns it into a space by matching it
// against its own repos root, so a hook cannot name a repository the
// daemon does not own.
Repo string `json:"repo"`
// Push correlates the hooks of one push. It is the pid of the receive-pack
// process every hook of a push is a child of — stable across pre-receive,
// every update, and post-receive, and unique for as long as that process
// lives.
Push string `json:"push"`
Credential Credential `json:"credential"`
// Options carries the push options, MethodPushOptions only. A nil slice
// means the push-options phase was not negotiated at all, which is not the
// same as an empty one; neither carries skip-validation, so nothing
// downstream needs to tell them apart.
Options []string `json:"options,omitempty"`
// Updates is the ref updates this call is about: every ref of the push for
// MethodPushOptions and MethodPushed, exactly one for MethodValidateRef.
Updates []RefUpdate `json:"updates"`
}
// Validate reports whether a request is well formed, before anything acts on
// it. Everything here is a bug in the caller rather than a policy question, so
// the daemon answers these with Response.Error rather than a rejection.
func (r Request) Validate() error {
if r.Version != ProtocolVersion {
return fmt.Errorf("unsupported protocol version %d (this daemon speaks %d); "+
"the repository's hooks and the running daemon are different builds",
r.Version, ProtocolVersion)
}
switch r.Method {
case MethodPushOptions, MethodValidateRef, MethodPushed:
default:
return fmt.Errorf("unknown method %q", r.Method)
}
if r.Repo == "" {
return errors.New("no repository path")
}
if !filepath.IsAbs(r.Repo) {
return fmt.Errorf("repository path %q is not absolute", r.Repo)
}
if r.Push == "" {
return errors.New("no push correlation id")
}
switch r.Credential.Kind {
case PrincipalOwner:
if r.Credential.Token != "" {
return errors.New("an owner credential must not carry a token")
}
case PrincipalAgent:
if r.Credential.Token == "" {
return errors.New("an agent credential must carry a token")
}
default:
return fmt.Errorf("unknown principal kind %q, want %q or %q",
r.Credential.Kind, PrincipalOwner, PrincipalAgent)
}
if len(r.Updates) == 0 {
return errors.New("no ref updates")
}
if r.Method == MethodValidateRef && len(r.Updates) != 1 {
return fmt.Errorf("%s carries %d ref updates, want exactly 1",
MethodValidateRef, len(r.Updates))
}
for i, u := range r.Updates {
if u.Ref == "" {
return fmt.Errorf("ref update %d has no ref name", i)
}
if u.Old == "" || u.New == "" {
return fmt.Errorf("ref update %d (%s) is missing an object name; "+
"git spells an absent one as forty zeroes, never as the empty string", i, u.Ref)
}
}
return nil
}
// Response is the daemon's answer.
//
// The three outcomes are kept apart because they mean different things to the
// person pushing. OK is "proceed". Rejected is policy — the push broke a rule,
// Message is written for their terminal, and re-pushing the same thing will
// fail the same way. Error is infrastructure — Postgres down, a repository the
// daemon does not own, a malformed request — and the same push may well
// succeed once it is fixed. Both non-OK cases stop the push; only the wording
// differs, and only because a rule you broke and a service that broke are not
// the same problem.
type Response struct {
Version int `json:"version"`
OK bool `json:"ok"`
Rejected bool `json:"rejected,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
func okResponse() Response { return Response{Version: ProtocolVersion, OK: true} }
func rejectedResponse(message string) Response {
return Response{Version: ProtocolVersion, Rejected: true, Message: message}
}
func errorResponse(format string, args ...any) Response {
return Response{Version: ProtocolVersion, Error: fmt.Sprintf(format, args...)}
}
// Validate reports whether a response can be acted on. A response that is
// neither an acceptance nor a refusal with something to say is treated as an
// unreachable daemon, which is to say: a rejection.
func (r Response) Validate() error {
if r.Version != ProtocolVersion {
return fmt.Errorf("daemon answered protocol version %d, this hook speaks %d; "+
"the repository's hooks and the running daemon are different builds",
r.Version, ProtocolVersion)
}
switch {
case r.OK && (r.Rejected || r.Error != ""):
return errors.New("daemon answered ok and not-ok at once")
case r.OK:
return nil
case r.Rejected && strings.TrimSpace(r.Message) == "":
return errors.New("daemon rejected the push without saying why")
case r.Rejected:
return nil
case strings.TrimSpace(r.Error) == "":
return errors.New("daemon refused the push without saying why")
default:
return nil
}
}
// WriteRequest sends one request, newline terminated.
func WriteRequest(w io.Writer, req Request) error { return writeJSON(w, req) }
// ReadRequest reads one request. Anything past maxMessageBytes is an error, not
// a truncation.
func ReadRequest(r io.Reader) (Request, error) {
var req Request
err := readJSON(r, &req)
return req, err
}
// WriteResponse sends one response, newline terminated.
func WriteResponse(w io.Writer, resp Response) error { return writeJSON(w, resp) }
// ReadResponse reads one response.
func ReadResponse(r io.Reader) (Response, error) {
var resp Response
err := readJSON(r, &resp)
return resp, err
}
func writeJSON(w io.Writer, v any) error {
buf, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("hooks: encode %T: %w", v, err)
}
if len(buf)+1 > maxMessageBytes {
return fmt.Errorf("hooks: encoded %T is %d bytes, over the %d byte limit",
v, len(buf)+1, maxMessageBytes)
}
if _, err := w.Write(append(buf, '\n')); err != nil {
return fmt.Errorf("hooks: write %T: %w", v, err)
}
return nil
}
// readJSON decodes one message, tolerating fields it does not know.
//
// DisallowUnknownFields would be the stricter choice and it is deliberately not
// used: a peer from a different build is caught by the version check, which
// says so in one sentence, and refusing to decode it first would replace that
// sentence with "json: unknown field". The version number is the compatibility
// contract; the field set is not.
func readJSON(r io.Reader, v any) error {
dec := json.NewDecoder(io.LimitReader(r, maxMessageBytes))
if err := dec.Decode(v); err != nil {
return fmt.Errorf("hooks: decode %T: %w", v, err)
}
return nil
}