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 }