Repository navigation
Expand file tree
/
Copy pathheaders_test.go
More file actions
380 lines (360 loc) · 17 KB
/
Copy pathheaders_test.go
File metadata and controls
380 lines (360 loc) · 17 KB
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
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
package forgeapi_test
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/textproto"
"slices"
"testing"
"github.com/cplieger/forgeapi"
"github.com/cplieger/forgeapi/gitea"
)
// The three writer sets whose UNION is [forgeapi.ReservedHeaders], split by who
// writes each name and by whether this tree can be observed writing it yet.
//
// Nothing derives them from the source, because proving that NO code anywhere in a
// module writes a header is an unbounded claim over Go's syntax. The observation
// below reads an OUTPUT instead, which cannot have a blind spot: a header reaching
// the server is in the arriving set by construction.
var (
// libraryHeadersOnEveryRequest are the names this library's own request
// builder writes on every request it makes, the credential injection among
// them, so deleting that injection moves the observed set.
libraryHeadersOnEveryRequest = []string{"Accept", "Authorization", "User-Agent"}
// libraryHeadersOnARequestWithABody is what it adds where the request
// carries one.
libraryHeadersOnARequestWithABody = []string{"Content-Type"}
// libraryHeadersOneFamilySendsUnderItsOwnPrecondition is the API-version pin,
// which the GitHub family sends and this observation cannot reach: it rides
// every request on a connection whose instance has ECHOED the version this
// library pins, and nothing else, so an observation of the first request to a
// server that has echoed nothing correctly sees none. It is observable, and
// observed, at that family's own grain, in both arms: github's
// TestTheVersionPinRidesEveryRequestOnceTheInstanceEchoedIt reads it arriving on
// the second request and absent from the first, and
// TestTheVersionPinIsWithheldFromAnInstanceThatNamedAnotherVersion reads it
// withheld from an instance answering under another version.
libraryHeadersOneFamilySendsUnderItsOwnPrecondition = []string{"X-GitHub-Api-Version"}
// libraryHeadersOnARevalidatingRead are the two request validators, which the
// read cache writes on a read whose earlier answer it holds: one of them per
// read, the entity tag where the instance sent one and the date otherwise.
libraryHeadersOnARevalidatingRead = []string{
"If-Modified-Since",
"If-None-Match",
}
)
// stackHeadersThatArrive and stackHeadersThatNeverStick are the other half of
// [forgeapi.ReservedHeaders]: what Go's HTTP stack controls itself, whatever the
// request carries. Both are facts about net/http rather than about this module.
//
// That half is THREE PLUS TWO rather than a count of five, and the split is measured
// on go1.27 by the test below. Accept-Encoding, Content-Length and User-Agent ARRIVE
// as header-map entries, so the observation holds them directly. The other three
// never arrive as headers at all, and all three are named because the reason differs
// for one of them: Host and Transfer-Encoding reach a server as r.Host and
// r.TransferEncoding, which the test witnesses positively, while Connection reaches
// it as neither and is reserved by its stated reason alone, a hop-by-hop name the
// stack owns. A caller setting any of the three would be silently ignored rather
// than honoured, which is what [forgeapi.CodeHeaderReserved] exists to refuse.
var (
stackHeadersThatArrive = []string{"Accept-Encoding", "Content-Length", "User-Agent"}
stackHeadersThatNeverStick = []string{"Connection", "Host", "Transfer-Encoding"}
)
// headersBothHalvesWrite is the OVERLAP between the library's names and the stack's,
// because one name is written by both: net/http supplies a default User-Agent on a
// request that carries none, and this library writes its own. So the slices are
// overlapping writer sets whose union is the reserved list, never a partition of it.
//
// It is a variable rather than a remark because canonical() compacts: without an
// assertion of its own the overlap collapses before the union check, and a name
// deleted from one half is then a green run. Measured on the tree that carried only
// the union check: deleting User-Agent from the library's half left the suite green.
var headersBothHalvesWrite = []string{"User-Agent"}
// probeCredential is the credential source the observation drives the client with.
// Injection is mandatory, and the token is what the arriving Authorization header is
// built from, so a builder that stops injecting it moves the observed set.
type probeCredential struct{}
func (probeCredential) Token(context.Context) (string, error) { return "observation-placeholder", nil }
func (probeCredential) Kind() forgeapi.CredKind { return forgeapi.CredKindStaticPAT }
func (probeCredential) State() forgeapi.CredState { return forgeapi.CredValid }
// TestReservedHeadersHoldTheHeadersThatArrive drives real requests through THIS
// LIBRARY's own request builder to an httptest server and holds
// [forgeapi.ReservedHeaders] to the header set that ARRIVES, which is the one
// instrument that can hold both halves of that list at once.
//
// What it proves, stated as a union rather than as a partition because one header is
// written by both halves. The reserved list is exactly the union of the writer sets
// this file names, with no third name and none missing; the overlap between them is
// exactly headersBothHalvesWrite, which is what holds each half's membership once
// canonical() has compacted the union. The set that arrives at the instance is
// exactly the library's own names for that request shape plus the stack's, so
// deleting the credential injection, the accept header or the consumer-header loop
// from the builder fails this test rather than passing quietly. Two of the three
// names that never arrive as headers reach the server through their own fields,
// which is what makes "a caller setting this would be ignored" a measured claim.
//
// What it still cannot prove: the API-version pin, whose variable names the two
// cases in the github package that observe it arriving and being withheld, cannot
// arrive HERE, because its precondition is a version echo this server never sends.
// The validators arrive on a second read through a server that sent one.
func TestReservedHeadersHoldTheHeadersThatArrive(t *testing.T) {
reserved := canonical(forgeapi.ReservedHeaders())
union := canonical(slices.Concat(
libraryHeadersOnEveryRequest,
libraryHeadersOnARequestWithABody,
libraryHeadersOneFamilySendsUnderItsOwnPrecondition,
libraryHeadersOnARevalidatingRead,
stackHeadersThatArrive,
stackHeadersThatNeverStick,
))
if !slices.Equal(reserved, union) {
t.Errorf("ReservedHeaders() = %v, want %v: the reserved list is the UNION of the writer sets this file names, every entry written by this library or by Go's HTTP stack or by both", reserved, union)
}
if overlap, want := headersInBothHalves(), canonical(headersBothHalvesWrite); !slices.Equal(overlap, want) {
t.Errorf("the names this library and the stack both write = %v, want %v: the union above compacts, so without this a name leaving one half changes nothing there", overlap, want)
}
for _, test := range []struct {
name string
// validator is what the server answers with, so a second read revalidates.
validator string
call func(context.Context, *gitea.Client) error
// wantArriving is the canonical header set the server must see, in
// full: an extra name is a header nothing on the reserved list
// accounts for, and a missing one is a name the builder stopped
// sending. Both cases DERIVE it from the slices above rather than
// restating them beside themselves.
wantArriving []string
}{
{
name: "a_read_carries_no_body",
call: func(ctx context.Context, c *gitea.Client) error {
_, err := c.Whoami(ctx)
return err
},
wantArriving: slices.Concat(
libraryHeadersOnEveryRequest,
headersWithout(stackHeadersThatArrive, "Content-Length"),
),
},
{
name: "a_read_revalidating_an_entity_tag", validator: "ETag",
call: whoamiTwice,
wantArriving: slices.Concat(
libraryHeadersOnEveryRequest,
[]string{"If-None-Match"},
headersWithout(stackHeadersThatArrive, "Content-Length"),
),
},
{
name: "a_read_revalidating_a_date", validator: "Last-Modified",
call: whoamiTwice,
wantArriving: slices.Concat(
libraryHeadersOnEveryRequest,
[]string{"If-Modified-Since"},
headersWithout(stackHeadersThatArrive, "Content-Length"),
),
},
{
name: "a_mutation_carries_one",
call: func(ctx context.Context, c *gitea.Client) error {
_, err := c.CreateIssue(ctx, repoRefForObservation(), forgeapi.NewIssue{Title: "t"})
return err
},
wantArriving: slices.Concat(
libraryHeadersOnEveryRequest,
libraryHeadersOnARequestWithABody,
stackHeadersThatArrive,
),
},
} {
t.Run(test.name, func(t *testing.T) {
var (
arrived []string
host string
transfer []string
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
names := make([]string, 0, len(r.Header))
for name := range r.Header {
names = append(names, name)
}
arrived = canonical(names)
host = r.Host
transfer = r.TransferEncoding
w.Header().Set("Content-Type", "application/json")
switch test.validator {
case "ETag":
w.Header().Set("ETag", `"observed"`)
case "Last-Modified":
w.Header().Set("Last-Modified", "Wed, 25 Oct 2023 19:17:59 GMT")
}
if _, err := io.WriteString(w, `{"login":"observed"}`); err != nil {
t.Errorf("Setup: writing the response: %v", err)
}
}))
defer srv.Close()
client, err := gitea.New(forgeapi.Connection{WebBaseURL: srv.URL},
forgeapi.WithWireTransport(srv.Client().Transport),
forgeapi.WithCredentialSource(probeCredential{}),
forgeapi.WithPlaintextHTTP(true),
forgeapi.WithPrivateAddresses(true),
forgeapi.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
)
if err != nil {
t.Fatalf("Setup: gitea.New(%q): %v", srv.URL, err)
}
if err := test.call(t.Context(), client); err != nil {
t.Fatalf("Setup: driving one request through the library's own builder: %v", err)
}
if !slices.Equal(arrived, canonical(test.wantArriving)) {
t.Errorf("headers arriving at the instance = %v, want %v: this is the derived set the reserved list is held to, so a difference is either a name to add to that list or a builder that stopped writing one", arrived, canonical(test.wantArriving))
}
for _, name := range arrived {
if !slices.Contains(reserved, name) {
t.Errorf("header %q arrives at the instance and ReservedHeaders() does not hold it: a consumer could set it through Connection.Headers and have it silently overridden, which is what the denylist exists to refuse", name)
}
}
for _, name := range stackHeadersThatNeverStick {
if key := textproto.CanonicalMIMEHeaderKey(name); slices.Contains(arrived, key) {
t.Errorf("header %q arrives as a header-map entry, want it not to: it is on the reserved list because the stack consumes it rather than sending it, and a name that does arrive is held by the equality above instead", name)
}
}
if host == "" {
t.Error("r.Host is empty, want the request's authority: Host is on the reserved list because it reaches a server through this field rather than as a header, and a caller setting the header would be ignored")
}
if slices.Contains(transfer, "chunked") {
t.Errorf("r.TransferEncoding = %v, want no chunked entry: this library holds a request body rather than streaming it, so the length is always known", transfer)
}
})
}
}
// TestAConsumerHeaderArrivesBesideTheLibrarysOwn holds the other half of the
// denylist's purpose: a name the list does NOT hold passes through to the instance
// the connection names, which is what makes the list a denylist rather than an
// allowlist.
func TestAConsumerHeaderArrivesBesideTheLibrarysOwn(t *testing.T) {
var got http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Clone()
w.Header().Set("Content-Type", "application/json")
if _, err := io.WriteString(w, `{"login":"observed"}`); err != nil {
t.Errorf("Setup: writing the response: %v", err)
}
}))
defer srv.Close()
conn := forgeapi.Connection{
WebBaseURL: srv.URL,
Headers: []forgeapi.Header{{Name: "X-Gateway-Secret", Value: "gateway-literal"}},
}
client, err := gitea.New(conn,
forgeapi.WithWireTransport(srv.Client().Transport),
forgeapi.WithCredentialSource(probeCredential{}),
forgeapi.WithPlaintextHTTP(true),
forgeapi.WithPrivateAddresses(true),
forgeapi.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
)
if err != nil {
t.Fatalf("Setup: gitea.New(%q): %v", srv.URL, err)
}
if _, err := client.Whoami(t.Context()); err != nil {
t.Fatalf("Setup: Whoami: %v", err)
}
if want := "gateway-literal"; got.Get("X-Gateway-Secret") != want {
t.Errorf("X-Gateway-Secret arriving at the instance = %q, want %q: a name the denylist does not hold passes through", got.Get("X-Gateway-Secret"), want)
}
if got.Get("Authorization") == "" {
t.Error("Authorization arriving at the instance is empty, want the credential: the consumer's headers are applied after it and must not displace it")
}
}
// TestAConnectionMutatedAfterConstructionDoesNotChangeWhatIsSent holds the header
// list to being COPIED rather than referenced, the way the CA and key material is: a
// caller keeps the backing array of the slice it passed, and an entry rewritten there
// after the constructor validated it would otherwise reach the wire under a name the
// constructor refuses, applied after the credential and so able to replace it.
func TestAConnectionMutatedAfterConstructionDoesNotChangeWhatIsSent(t *testing.T) {
var got http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Clone()
w.Header().Set("Content-Type", "application/json")
if _, err := io.WriteString(w, `{"login":"observed"}`); err != nil {
t.Errorf("Setup: writing the response: %v", err)
}
}))
defer srv.Close()
headers := []forgeapi.Header{{Name: "X-Allowed", Value: "allowed"}}
client, err := gitea.New(forgeapi.Connection{WebBaseURL: srv.URL, Headers: headers},
forgeapi.WithWireTransport(srv.Client().Transport),
forgeapi.WithCredentialSource(probeCredential{}),
forgeapi.WithPlaintextHTTP(true),
forgeapi.WithPrivateAddresses(true),
forgeapi.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
)
if err != nil {
t.Fatalf("Setup: gitea.New(%q): %v", srv.URL, err)
}
headers[0] = forgeapi.Header{Name: "Authorization", Value: "token replacement"}
if _, err := client.Whoami(t.Context()); err != nil {
t.Fatalf("Setup: Whoami: %v", err)
}
if got.Get("Authorization") == "token replacement" {
t.Error("Authorization arriving at the instance is the value written into the caller's slice after construction, want the credential's own: the connection's header list is validated once, so it is copied")
}
if got.Get("X-Allowed") != "allowed" {
t.Errorf("X-Allowed arriving at the instance = %q, want %q: the copy is taken at construction", got.Get("X-Allowed"), "allowed")
}
}
// repoRefForObservation is a reference the selector validator accepts, so the
// mutation under observation reaches the request builder rather than a local
// refusal.
func repoRefForObservation() forgeapi.RepoRef {
ref := forgeapi.RepoRef{Family: forgeapi.FamilyGitea, Selector: "example/example", DisplayPath: "example/example"}
ref.ID = ref.Encode()
return ref
}
// headersInBothHalves returns the canonical names this library's own slices share
// with the stack's, which is the overlap the union check compacts away.
func headersInBothHalves() []string {
library := canonical(slices.Concat(
libraryHeadersOnEveryRequest,
libraryHeadersOnARequestWithABody,
libraryHeadersOneFamilySendsUnderItsOwnPrecondition,
))
stack := canonical(slices.Concat(stackHeadersThatArrive, stackHeadersThatNeverStick))
var both []string
for _, name := range library {
if slices.Contains(stack, name) {
both = append(both, name)
}
}
return both
}
// headersWithout returns names with one member dropped, compared in canonical case,
// so an expectation can be derived from a slice above rather than restated: a read
// carries no body, so the one stack name a body produces is not in its set.
func headersWithout(names []string, drop string) []string {
key := textproto.CanonicalMIMEHeaderKey(drop)
return slices.DeleteFunc(slices.Clone(names), func(name string) bool {
return textproto.CanonicalMIMEHeaderKey(name) == key
})
}
// canonical returns names in canonical MIME header case, sorted and deduplicated,
// which is the form HTTP compares them in and the form a set comparison needs.
func canonical(names []string) []string {
keys := make([]string, 0, len(names))
for _, name := range names {
keys = append(keys, textproto.CanonicalMIMEHeaderKey(name))
}
slices.Sort(keys)
return slices.Compact(keys)
}
// whoamiTwice is a read and its repeat, which a server that sent a validator sees
// revalidated.
func whoamiTwice(ctx context.Context, c *gitea.Client) error {
if _, err := c.Whoami(ctx); err != nil {
return err
}
_, err := c.Whoami(ctx)
return err
}