| 1 |
|
package hooks |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"encoding/json" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"io" |
| 8 |
|
"path/filepath" |
| 9 |
|
"strings" |
| 10 |
|
) |
| 11 |
|
|
| 12 |
|
// ProtocolVersion is the wire version. The hook and the daemon are the same |
| 13 |
|
// binary in every supported deployment, so a mismatch means a repository's |
| 14 |
|
// hook symlinks point at a different build than the running daemon — a |
| 15 |
|
// half-finished upgrade. It is refused loudly rather than negotiated: there is |
| 16 |
|
// no old version to be compatible with, and guessing at an unknown peer's |
| 17 |
|
// semantics on the write path is exactly the wrong trade. |
| 18 |
|
const ProtocolVersion = 1 |
| 19 |
|
|
| 20 |
|
// maxMessageBytes caps one request or response. A push with thousands of refs |
| 21 |
|
// would exceed it and be rejected, which is the right answer — nothing in this |
| 22 |
|
// model pushes thousands of refs, and an unbounded read on a socket any local |
| 23 |
|
// process can connect to is a way to kill the daemon. |
| 24 |
|
const maxMessageBytes = 1 << 20 |
| 25 |
|
|
| 26 |
|
// Method names one of the three calls the hooks make. Each corresponds to |
| 27 |
|
// exactly one git hook; see the package documentation for why the work splits |
| 28 |
|
// this way. |
| 29 |
|
type Method string |
| 30 |
|
|
| 31 |
|
const ( |
| 32 |
|
// MethodPushOptions is `pre-receive`: here are the push options and every |
| 33 |
|
// ref this push proposes to update. The daemon records them for the |
| 34 |
|
// `update` calls that follow. It validates nothing — during pre-receive |
| 35 |
|
// the pushed objects are still in receive-pack's quarantine and are not |
| 36 |
|
// readable by the daemon. |
| 37 |
|
MethodPushOptions Method = "push-options" |
| 38 |
|
|
| 39 |
|
// MethodValidateRef is `update`: may this one ref move, and is what it |
| 40 |
|
// moves to valid? This is the call that rejects a push. |
| 41 |
|
MethodValidateRef Method = "validate-ref" |
| 42 |
|
|
| 43 |
|
// MethodPushed is `post-receive`: these refs moved. The daemon reindexes |
| 44 |
|
// and advances the space's index rev stamp. Its answer cannot stop |
| 45 |
|
// anything; git has already updated the refs. |
| 46 |
|
MethodPushed Method = "pushed" |
| 47 |
|
) |
| 48 |
|
|
| 49 |
|
// PrincipalKind is who the forced-command wrapper says is pushing. It is |
| 50 |
|
// deliberately not gitx.PrincipalKind: this is a wire value whose spelling is |
| 51 |
|
// part of a compatibility contract, and the daemon maps it onto an |
| 52 |
|
// authn.Principal after resolving the credential rather than trusting it. |
| 53 |
|
type PrincipalKind string |
| 54 |
|
|
| 55 |
|
const ( |
| 56 |
|
// PrincipalOwner is the instance owner, authenticated by sshd against |
| 57 |
|
// their SSH key before the forced command ran. |
| 58 |
|
PrincipalOwner PrincipalKind = "owner" |
| 59 |
|
|
| 60 |
|
// PrincipalAgent is an agent presenting a token, which the daemon |
| 61 |
|
// validates against the database on every push. |
| 62 |
|
PrincipalAgent PrincipalKind = "agent" |
| 63 |
|
) |
| 64 |
|
|
| 65 |
|
// Credential is the identity half of a request: what the hook's environment |
| 66 |
|
// claims, plus whatever secret backs the claim. Token is never logged. |
| 67 |
|
type Credential struct { |
| 68 |
|
Kind PrincipalKind `json:"kind"` |
| 69 |
|
|
| 70 |
|
// Token is the agent's secret, required for PrincipalAgent and empty |
| 71 |
|
// otherwise. The daemon hashes and looks it up; the hook does not parse it. |
| 72 |
|
Token string `json:"token,omitempty"` |
| 73 |
|
|
| 74 |
|
// Agent and Session are the provenance fields an agent write must carry. |
| 75 |
|
// They are forwarded unvalidated: the write plane is where they are |
| 76 |
|
// demanded, and a push is not an agent write. |
| 77 |
|
Agent string `json:"agent,omitempty"` |
| 78 |
|
Session string `json:"session,omitempty"` |
| 79 |
|
} |
| 80 |
|
|
| 81 |
|
// RefUpdate is one proposed or completed ref move, exactly as git spells it on |
| 82 |
|
// the hook's command line or standard input. The object names stay hex strings |
| 83 |
|
// all the way to service.PushRequest, so an unparseable one is a rejection |
| 84 |
|
// rather than something that silently becomes the zero hash — which the refs |
| 85 |
|
// rule would read as a branch creation. |
| 86 |
|
type RefUpdate struct { |
| 87 |
|
Ref string `json:"ref"` |
| 88 |
|
Old string `json:"old"` |
| 89 |
|
New string `json:"new"` |
| 90 |
|
} |
| 91 |
|
|
| 92 |
1 |
func (u RefUpdate) String() string { |
| 93 |
1 |
return fmt.Sprintf("%s %s..%s", u.Ref, shortOID(u.Old), shortOID(u.New)) |
| 94 |
1 |
} |
| 95 |
|
|
| 96 |
2 |
func shortOID(s string) string { |
| 97 |
2 |
if len(s) > 8 { |
| 98 |
2 |
return s[:8] |
| 99 |
2 |
} |
| 100 |
0 |
if s == "" { |
| 101 |
0 |
return "-" |
| 102 |
0 |
} |
| 103 |
0 |
return s |
| 104 |
|
} |
| 105 |
|
|
| 106 |
|
// Request is one call from a hook to the daemon. |
| 107 |
|
type Request struct { |
| 108 |
|
Version int `json:"version"` |
| 109 |
|
Method Method `json:"method"` |
| 110 |
|
|
| 111 |
|
// Repo is the absolute path of the bare repository the hook is running in, |
| 112 |
|
// with symlinks resolved. The daemon turns it into a space by matching it |
| 113 |
|
// against its own repos root, so a hook cannot name a repository the |
| 114 |
|
// daemon does not own. |
| 115 |
|
Repo string `json:"repo"` |
| 116 |
|
|
| 117 |
|
// Push correlates the hooks of one push. It is the pid of the receive-pack |
| 118 |
|
// process every hook of a push is a child of — stable across pre-receive, |
| 119 |
|
// every update, and post-receive, and unique for as long as that process |
| 120 |
|
// lives. |
| 121 |
|
Push string `json:"push"` |
| 122 |
|
|
| 123 |
|
Credential Credential `json:"credential"` |
| 124 |
|
|
| 125 |
|
// Options carries the push options, MethodPushOptions only. A nil slice |
| 126 |
|
// means the push-options phase was not negotiated at all, which is not the |
| 127 |
|
// same as an empty one; neither carries skip-validation, so nothing |
| 128 |
|
// downstream needs to tell them apart. |
| 129 |
|
Options []string `json:"options,omitempty"` |
| 130 |
|
|
| 131 |
|
// Updates is the ref updates this call is about: every ref of the push for |
| 132 |
|
// MethodPushOptions and MethodPushed, exactly one for MethodValidateRef. |
| 133 |
|
Updates []RefUpdate `json:"updates"` |
| 134 |
|
} |
| 135 |
|
|
| 136 |
|
// Validate reports whether a request is well formed, before anything acts on |
| 137 |
|
// it. Everything here is a bug in the caller rather than a policy question, so |
| 138 |
|
// the daemon answers these with Response.Error rather than a rejection. |
| 139 |
77 |
func (r Request) Validate() error { |
| 140 |
77 |
if r.Version != ProtocolVersion { |
| 141 |
2 |
return fmt.Errorf("unsupported protocol version %d (this daemon speaks %d); "+ |
| 142 |
2 |
"the repository's hooks and the running daemon are different builds", |
| 143 |
2 |
r.Version, ProtocolVersion) |
| 144 |
2 |
} |
| 145 |
75 |
switch r.Method { |
| 146 |
|
case MethodPushOptions, MethodValidateRef, MethodPushed: |
| 147 |
1 |
default: |
| 148 |
1 |
return fmt.Errorf("unknown method %q", r.Method) |
| 149 |
|
} |
| 150 |
74 |
if r.Repo == "" { |
| 151 |
1 |
return errors.New("no repository path") |
| 152 |
1 |
} |
| 153 |
73 |
if !filepath.IsAbs(r.Repo) { |
| 154 |
1 |
return fmt.Errorf("repository path %q is not absolute", r.Repo) |
| 155 |
1 |
} |
| 156 |
72 |
if r.Push == "" { |
| 157 |
1 |
return errors.New("no push correlation id") |
| 158 |
1 |
} |
| 159 |
71 |
switch r.Credential.Kind { |
| 160 |
54 |
case PrincipalOwner: |
| 161 |
54 |
if r.Credential.Token != "" { |
| 162 |
1 |
return errors.New("an owner credential must not carry a token") |
| 163 |
1 |
} |
| 164 |
16 |
case PrincipalAgent: |
| 165 |
16 |
if r.Credential.Token == "" { |
| 166 |
1 |
return errors.New("an agent credential must carry a token") |
| 167 |
1 |
} |
| 168 |
1 |
default: |
| 169 |
1 |
return fmt.Errorf("unknown principal kind %q, want %q or %q", |
| 170 |
1 |
r.Credential.Kind, PrincipalOwner, PrincipalAgent) |
| 171 |
|
} |
| 172 |
68 |
if len(r.Updates) == 0 { |
| 173 |
1 |
return errors.New("no ref updates") |
| 174 |
1 |
} |
| 175 |
67 |
if r.Method == MethodValidateRef && len(r.Updates) != 1 { |
| 176 |
1 |
return fmt.Errorf("%s carries %d ref updates, want exactly 1", |
| 177 |
1 |
MethodValidateRef, len(r.Updates)) |
| 178 |
1 |
} |
| 179 |
66 |
for i, u := range r.Updates { |
| 180 |
66 |
if u.Ref == "" { |
| 181 |
1 |
return fmt.Errorf("ref update %d has no ref name", i) |
| 182 |
1 |
} |
| 183 |
65 |
if u.Old == "" || u.New == "" { |
| 184 |
1 |
return fmt.Errorf("ref update %d (%s) is missing an object name; "+ |
| 185 |
1 |
"git spells an absent one as forty zeroes, never as the empty string", i, u.Ref) |
| 186 |
1 |
} |
| 187 |
|
} |
| 188 |
64 |
return nil |
| 189 |
|
} |
| 190 |
|
|
| 191 |
|
// Response is the daemon's answer. |
| 192 |
|
// |
| 193 |
|
// The three outcomes are kept apart because they mean different things to the |
| 194 |
|
// person pushing. OK is "proceed". Rejected is policy — the push broke a rule, |
| 195 |
|
// Message is written for their terminal, and re-pushing the same thing will |
| 196 |
|
// fail the same way. Error is infrastructure — Postgres down, a repository the |
| 197 |
|
// daemon does not own, a malformed request — and the same push may well |
| 198 |
|
// succeed once it is fixed. Both non-OK cases stop the push; only the wording |
| 199 |
|
// differs, and only because a rule you broke and a service that broke are not |
| 200 |
|
// the same problem. |
| 201 |
|
type Response struct { |
| 202 |
|
Version int `json:"version"` |
| 203 |
|
OK bool `json:"ok"` |
| 204 |
|
Rejected bool `json:"rejected,omitempty"` |
| 205 |
|
Message string `json:"message,omitempty"` |
| 206 |
|
Error string `json:"error,omitempty"` |
| 207 |
|
} |
| 208 |
|
|
| 209 |
36 |
func okResponse() Response { return Response{Version: ProtocolVersion, OK: true} } |
| 210 |
|
|
| 211 |
15 |
func rejectedResponse(message string) Response { |
| 212 |
15 |
return Response{Version: ProtocolVersion, Rejected: true, Message: message} |
| 213 |
15 |
} |
| 214 |
|
|
| 215 |
14 |
func errorResponse(format string, args ...any) Response { |
| 216 |
14 |
return Response{Version: ProtocolVersion, Error: fmt.Sprintf(format, args...)} |
| 217 |
14 |
} |
| 218 |
|
|
| 219 |
|
// Validate reports whether a response can be acted on. A response that is |
| 220 |
|
// neither an acceptance nor a refusal with something to say is treated as an |
| 221 |
|
// unreachable daemon, which is to say: a rejection. |
| 222 |
72 |
func (r Response) Validate() error { |
| 223 |
72 |
if r.Version != ProtocolVersion { |
| 224 |
1 |
return fmt.Errorf("daemon answered protocol version %d, this hook speaks %d; "+ |
| 225 |
1 |
"the repository's hooks and the running daemon are different builds", |
| 226 |
1 |
r.Version, ProtocolVersion) |
| 227 |
1 |
} |
| 228 |
71 |
switch { |
| 229 |
2 |
case r.OK && (r.Rejected || r.Error != ""): |
| 230 |
2 |
return errors.New("daemon answered ok and not-ok at once") |
| 231 |
37 |
case r.OK: |
| 232 |
37 |
return nil |
| 233 |
2 |
case r.Rejected && strings.TrimSpace(r.Message) == "": |
| 234 |
2 |
return errors.New("daemon rejected the push without saying why") |
| 235 |
16 |
case r.Rejected: |
| 236 |
16 |
return nil |
| 237 |
1 |
case strings.TrimSpace(r.Error) == "": |
| 238 |
1 |
return errors.New("daemon refused the push without saying why") |
| 239 |
13 |
default: |
| 240 |
13 |
return nil |
| 241 |
|
} |
| 242 |
|
} |
| 243 |
|
|
| 244 |
|
// WriteRequest sends one request, newline terminated. |
| 245 |
65 |
func WriteRequest(w io.Writer, req Request) error { return writeJSON(w, req) } |
| 246 |
|
|
| 247 |
|
// ReadRequest reads one request. Anything past maxMessageBytes is an error, not |
| 248 |
|
// a truncation. |
| 249 |
68 |
func ReadRequest(r io.Reader) (Request, error) { |
| 250 |
68 |
var req Request |
| 251 |
68 |
err := readJSON(r, &req) |
| 252 |
68 |
return req, err |
| 253 |
68 |
} |
| 254 |
|
|
| 255 |
|
// WriteResponse sends one response, newline terminated. |
| 256 |
65 |
func WriteResponse(w io.Writer, resp Response) error { return writeJSON(w, resp) } |
| 257 |
|
|
| 258 |
|
// ReadResponse reads one response. |
| 259 |
64 |
func ReadResponse(r io.Reader) (Response, error) { |
| 260 |
64 |
var resp Response |
| 261 |
64 |
err := readJSON(r, &resp) |
| 262 |
64 |
return resp, err |
| 263 |
64 |
} |
| 264 |
|
|
| 265 |
130 |
func writeJSON(w io.Writer, v any) error { |
| 266 |
130 |
buf, err := json.Marshal(v) |
| 267 |
130 |
if err != nil { |
| 268 |
0 |
return fmt.Errorf("hooks: encode %T: %w", v, err) |
| 269 |
0 |
} |
| 270 |
130 |
if len(buf)+1 > maxMessageBytes { |
| 271 |
1 |
return fmt.Errorf("hooks: encoded %T is %d bytes, over the %d byte limit", |
| 272 |
1 |
v, len(buf)+1, maxMessageBytes) |
| 273 |
1 |
} |
| 274 |
129 |
if _, err := w.Write(append(buf, '\n')); err != nil { |
| 275 |
1 |
return fmt.Errorf("hooks: write %T: %w", v, err) |
| 276 |
1 |
} |
| 277 |
128 |
return nil |
| 278 |
|
} |
| 279 |
|
|
| 280 |
|
// readJSON decodes one message, tolerating fields it does not know. |
| 281 |
|
// |
| 282 |
|
// DisallowUnknownFields would be the stricter choice and it is deliberately not |
| 283 |
|
// used: a peer from a different build is caught by the version check, which |
| 284 |
|
// says so in one sentence, and refusing to decode it first would replace that |
| 285 |
|
// sentence with "json: unknown field". The version number is the compatibility |
| 286 |
|
// contract; the field set is not. |
| 287 |
132 |
func readJSON(r io.Reader, v any) error { |
| 288 |
132 |
dec := json.NewDecoder(io.LimitReader(r, maxMessageBytes)) |
| 289 |
132 |
if err := dec.Decode(v); err != nil { |
| 290 |
3 |
return fmt.Errorf("hooks: decode %T: %w", v, err) |
| 291 |
3 |
} |
| 292 |
129 |
return nil |
| 293 |
|
} |