coverage~bigbes/sr-ht-spec64cae3afhooks/proto.go

Coverage
93.8% 60/64 statements
Δ
+0.0
Blob
c80be02
Uncovered L100-L103L268-L269
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 }