~bigbes/sr-ht-spec

ref: 7f779fef12194d49b9ce97ad4e2a80af1c3d6358 sr-ht-spec/hooks/proto.go -rw-r--r-- 10.4 KiB
7f779fef — Eugene Blikh feat(web): review queue — inbox + policy-merged digest (Phase 4) 26 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
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
}