tenseleyflow/shithub / 12b7ca3

Browse files

repos/sigverify: tests covering full gh reason enum + tag form

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
12b7ca3da85e1b7763653597708d4ee992e31c1d
Parents
05bd39a
Tree
14e9b80

1 changed file

StatusFile+-
A internal/repos/sigverify/verify_test.go 533 0
internal/repos/sigverify/verify_test.goadded
@@ -0,0 +1,533 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package sigverify
4
+
5
+import (
6
+	"bytes"
7
+	"context"
8
+	"encoding/hex"
9
+	"fmt"
10
+	"os/exec"
11
+	"path/filepath"
12
+	"strings"
13
+	"testing"
14
+	"time"
15
+
16
+	"github.com/ProtonMail/go-crypto/openpgp"
17
+	"github.com/ProtonMail/go-crypto/openpgp/armor"
18
+	"github.com/ProtonMail/go-crypto/openpgp/packet"
19
+)
20
+
21
+// ─── fixture helpers ────────────────────────────────────────────────
22
+
23
+// newTestRepo creates a fresh bare git repo in a t.TempDir and returns
24
+// its gitDir.
25
+func newTestRepo(t *testing.T) string {
26
+	t.Helper()
27
+	dir := t.TempDir()
28
+	cmd := exec.Command("git", "init", "--quiet", "--bare", dir)
29
+	if err := cmd.Run(); err != nil {
30
+		t.Fatalf("git init: %v", err)
31
+	}
32
+	return dir
33
+}
34
+
35
+// hashObject pipes commitBody through `git hash-object -w -t commit
36
+// --stdin` and returns the resulting OID. The body is stored as a
37
+// commit object in the bare repo; we don't update any ref (the
38
+// orchestrator addresses objects by OID directly, no ref needed).
39
+func hashObject(t *testing.T, gitDir string, commitBody []byte) string {
40
+	t.Helper()
41
+	cmd := exec.Command("git", "-C", gitDir, "hash-object", "-w", "-t", "commit", "--stdin")
42
+	cmd.Stdin = bytes.NewReader(commitBody)
43
+	out, err := cmd.Output()
44
+	if err != nil {
45
+		t.Fatalf("hash-object: %v", err)
46
+	}
47
+	return strings.TrimSpace(string(out))
48
+}
49
+
50
+// signedCommit constructs a git commit object body signed by entity.
51
+// The tree OID is the well-known empty-tree OID; we don't care about
52
+// tree validity since verification only reads the commit object body.
53
+//
54
+// signedAt sets the signature creation time (useful for the expiry
55
+// test which needs a sig made before the key's expiry timestamp).
56
+func signedCommit(t *testing.T, entity *openpgp.Entity, message string, signedAt time.Time) []byte {
57
+	t.Helper()
58
+	const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
59
+	authorTime := signedAt.Unix()
60
+	body := fmt.Sprintf(
61
+		"tree %s\nauthor Alice <alice@shithub.test> %d +0000\ncommitter Alice <alice@shithub.test> %d +0000\n\n%s\n",
62
+		emptyTree, authorTime, authorTime, message,
63
+	)
64
+
65
+	var sigBuf bytes.Buffer
66
+	armorWriter, err := armor.Encode(&sigBuf, "PGP SIGNATURE", nil)
67
+	if err != nil {
68
+		t.Fatalf("armor.Encode: %v", err)
69
+	}
70
+	cfg := &packet.Config{Time: func() time.Time { return signedAt }}
71
+	if err := openpgp.DetachSign(armorWriter, entity, strings.NewReader(body), cfg); err != nil {
72
+		t.Fatalf("DetachSign: %v", err)
73
+	}
74
+	if err := armorWriter.Close(); err != nil {
75
+		t.Fatalf("armor close: %v", err)
76
+	}
77
+
78
+	// Reformat the signature block as a gpgsig header (each line
79
+	// after the first prefixed with a single space).
80
+	sigStr := strings.TrimRight(sigBuf.String(), "\n")
81
+	sigLines := strings.Split(sigStr, "\n")
82
+	indented := []string{"gpgsig " + sigLines[0]}
83
+	for _, line := range sigLines[1:] {
84
+		indented = append(indented, " "+line)
85
+	}
86
+	gpgsigHeader := strings.Join(indented, "\n")
87
+
88
+	// Insert the gpgsig header between the committer line and the
89
+	// blank-line + message.
90
+	signedBody := strings.Replace(
91
+		body,
92
+		fmt.Sprintf("committer Alice <alice@shithub.test> %d +0000\n\n", authorTime),
93
+		fmt.Sprintf("committer Alice <alice@shithub.test> %d +0000\n%s\n\n", authorTime, gpgsigHeader),
94
+		1,
95
+	)
96
+	return []byte(signedBody)
97
+}
98
+
99
+// unsignedCommit builds a commit object body with no gpgsig header.
100
+func unsignedCommit(t *testing.T, message string) []byte {
101
+	t.Helper()
102
+	const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
103
+	now := time.Now().Unix()
104
+	return []byte(fmt.Sprintf(
105
+		"tree %s\nauthor Alice <alice@shithub.test> %d +0000\ncommitter Alice <alice@shithub.test> %d +0000\n\n%s\n",
106
+		emptyTree, now, now, message,
107
+	))
108
+}
109
+
110
+// armoredPublic returns the entity's armored public-key block.
111
+func armoredPublic(t *testing.T, e *openpgp.Entity) string {
112
+	t.Helper()
113
+	var buf bytes.Buffer
114
+	w, err := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
115
+	if err != nil {
116
+		t.Fatalf("armor.Encode: %v", err)
117
+	}
118
+	if err := e.Serialize(w); err != nil {
119
+		t.Fatalf("Serialize: %v", err)
120
+	}
121
+	w.Close()
122
+	return buf.String()
123
+}
124
+
125
+// signingSubkey returns the first subkey of an entity that carries
126
+// the sign flag, falling back to the primary key if no subkey is
127
+// signing-capable.
128
+func signingSubkey(t *testing.T, e *openpgp.Entity) *packet.PublicKey {
129
+	t.Helper()
130
+	for i := range e.Subkeys {
131
+		sk := &e.Subkeys[i]
132
+		if sk.Sig != nil && sk.Sig.FlagSign {
133
+			return sk.PublicKey
134
+		}
135
+	}
136
+	return e.PrimaryKey
137
+}
138
+
139
+// fakeLookups is a minimal Lookups implementation for tests. The
140
+// orchestrator only calls SubkeyByFingerprint / GPGKeyByID /
141
+// UserEmailsByUserID, and we set each return value explicitly per
142
+// test.
143
+type fakeLookups struct {
144
+	subkey      Subkey
145
+	subkeyFound bool
146
+	gpgKey      GPGKey
147
+	gpgKeyFound bool
148
+	emails      []UserEmail
149
+	emailsErr   error
150
+	subkeyErr   error
151
+	gpgKeyErr   error
152
+}
153
+
154
+func (f *fakeLookups) SubkeyByFingerprint(_ context.Context, fp string) (Subkey, bool, error) {
155
+	if f.subkeyErr != nil {
156
+		return Subkey{}, false, f.subkeyErr
157
+	}
158
+	if !f.subkeyFound {
159
+		return Subkey{}, false, nil
160
+	}
161
+	if !strings.EqualFold(fp, f.subkey.Fingerprint) {
162
+		return Subkey{}, false, nil
163
+	}
164
+	return f.subkey, true, nil
165
+}
166
+
167
+func (f *fakeLookups) GPGKeyByID(_ context.Context, id int64) (GPGKey, bool, error) {
168
+	if f.gpgKeyErr != nil {
169
+		return GPGKey{}, false, f.gpgKeyErr
170
+	}
171
+	if !f.gpgKeyFound {
172
+		return GPGKey{}, false, nil
173
+	}
174
+	if f.gpgKey.ID != id {
175
+		return GPGKey{}, false, nil
176
+	}
177
+	return f.gpgKey, true, nil
178
+}
179
+
180
+func (f *fakeLookups) UserEmailsByUserID(_ context.Context, _ int64) ([]UserEmail, error) {
181
+	return f.emails, f.emailsErr
182
+}
183
+
184
+// lookupsForEntity wires the in-memory entity into a fakeLookups so
185
+// that SubkeyByFingerprint and GPGKeyByID return matching records.
186
+// signingFP is the fingerprint of the subkey that was used to sign;
187
+// some tests want to verify against a non-signing subkey (the
188
+// not_signing_key case), so the caller supplies which subkey.
189
+func lookupsForEntity(t *testing.T, entity *openpgp.Entity, signerPK *packet.PublicKey, canSign bool, expiresAt time.Time, emails []UserEmail) *fakeLookups {
190
+	t.Helper()
191
+	return &fakeLookups{
192
+		subkey: Subkey{
193
+			ID:          42,
194
+			GPGKeyID:    99,
195
+			Fingerprint: hex.EncodeToString(signerPK.Fingerprint),
196
+			KeyID:       fmt.Sprintf("%016x", signerPK.KeyId),
197
+			CanSign:     canSign,
198
+			ExpiresAt:   expiresAt,
199
+		},
200
+		subkeyFound: true,
201
+		gpgKey: GPGKey{
202
+			ID:          99,
203
+			UserID:      7,
204
+			Fingerprint: hex.EncodeToString(entity.PrimaryKey.Fingerprint),
205
+			KeyID:       fmt.Sprintf("%016x", entity.PrimaryKey.KeyId),
206
+			Armored:     armoredPublic(t, entity),
207
+		},
208
+		gpgKeyFound: true,
209
+		emails:      emails,
210
+	}
211
+}
212
+
213
+// ─── tests ──────────────────────────────────────────────────────────
214
+
215
+// makeEntity builds an ed25519 entity for tests. ProtonMail's default
216
+// is RSA-2048; we ask for EdDSA explicitly.
217
+func makeEntity(t *testing.T, email string) *openpgp.Entity {
218
+	t.Helper()
219
+	e, err := openpgp.NewEntity("shithub-test", "", email, &packet.Config{
220
+		Algorithm: packet.PubKeyAlgoEdDSA,
221
+	})
222
+	if err != nil {
223
+		t.Fatalf("NewEntity: %v", err)
224
+	}
225
+	return e
226
+}
227
+
228
+func TestVerify_Unsigned(t *testing.T) {
229
+	gitDir := newTestRepo(t)
230
+	oid := hashObject(t, gitDir, unsignedCommit(t, "hello world"))
231
+
232
+	got, err := Verify(context.Background(), gitDir, oid, &fakeLookups{})
233
+	if err != nil {
234
+		t.Fatalf("Verify: %v", err)
235
+	}
236
+	if got.Reason != ReasonUnsigned {
237
+		t.Errorf("reason: got %q, want unsigned", got.Reason)
238
+	}
239
+	if got.Verified {
240
+		t.Error("expected Verified=false on unsigned commit")
241
+	}
242
+}
243
+
244
+func TestVerify_Valid(t *testing.T) {
245
+	entity := makeEntity(t, "alice@shithub.test")
246
+	gitDir := newTestRepo(t)
247
+	body := signedCommit(t, entity, "valid sig", time.Now())
248
+	oid := hashObject(t, gitDir, body)
249
+
250
+	signer := signingSubkey(t, entity)
251
+	emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
252
+	lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
253
+
254
+	got, err := Verify(context.Background(), gitDir, oid, lookups)
255
+	if err != nil {
256
+		t.Fatalf("Verify: %v", err)
257
+	}
258
+	if got.Reason != ReasonValid {
259
+		t.Fatalf("reason: got %q (sig=%q), want valid", got.Reason, got.Signature)
260
+	}
261
+	if !got.Verified {
262
+		t.Error("expected Verified=true")
263
+	}
264
+	if got.SignerUserID != 7 {
265
+		t.Errorf("SignerUserID: got %d, want 7", got.SignerUserID)
266
+	}
267
+	if got.SignerEmail != "alice@shithub.test" {
268
+		t.Errorf("SignerEmail: got %q, want alice@shithub.test", got.SignerEmail)
269
+	}
270
+}
271
+
272
+func TestVerify_UnknownKey(t *testing.T) {
273
+	entity := makeEntity(t, "alice@shithub.test")
274
+	gitDir := newTestRepo(t)
275
+	body := signedCommit(t, entity, "unknown key sig", time.Now())
276
+	oid := hashObject(t, gitDir, body)
277
+
278
+	// Lookups returns no subkey for any fingerprint.
279
+	got, err := Verify(context.Background(), gitDir, oid, &fakeLookups{})
280
+	if err != nil {
281
+		t.Fatalf("Verify: %v", err)
282
+	}
283
+	if got.Reason != ReasonUnknownKey {
284
+		t.Errorf("reason: got %q, want unknown_key", got.Reason)
285
+	}
286
+}
287
+
288
+func TestVerify_BadEmail(t *testing.T) {
289
+	entity := makeEntity(t, "alice@shithub.test")
290
+	gitDir := newTestRepo(t)
291
+	body := signedCommit(t, entity, "bad email sig", time.Now())
292
+	oid := hashObject(t, gitDir, body)
293
+
294
+	signer := signingSubkey(t, entity)
295
+	// User registered some other email, never claimed alice@shithub.test.
296
+	emails := []UserEmail{{Email: "bob@shithub.test", Verified: true}}
297
+	lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
298
+
299
+	got, err := Verify(context.Background(), gitDir, oid, lookups)
300
+	if err != nil {
301
+		t.Fatalf("Verify: %v", err)
302
+	}
303
+	if got.Reason != ReasonBadEmail {
304
+		t.Errorf("reason: got %q, want bad_email", got.Reason)
305
+	}
306
+}
307
+
308
+func TestVerify_UnverifiedEmail(t *testing.T) {
309
+	entity := makeEntity(t, "alice@shithub.test")
310
+	gitDir := newTestRepo(t)
311
+	body := signedCommit(t, entity, "unverified email sig", time.Now())
312
+	oid := hashObject(t, gitDir, body)
313
+
314
+	signer := signingSubkey(t, entity)
315
+	// Email claimed but unverified.
316
+	emails := []UserEmail{{Email: "alice@shithub.test", Verified: false}}
317
+	lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
318
+
319
+	got, err := Verify(context.Background(), gitDir, oid, lookups)
320
+	if err != nil {
321
+		t.Fatalf("Verify: %v", err)
322
+	}
323
+	if got.Reason != ReasonUnverifiedEmail {
324
+		t.Errorf("reason: got %q, want unverified_email", got.Reason)
325
+	}
326
+}
327
+
328
+func TestVerify_NotSigningKey(t *testing.T) {
329
+	entity := makeEntity(t, "alice@shithub.test")
330
+	gitDir := newTestRepo(t)
331
+	body := signedCommit(t, entity, "not signing key", time.Now())
332
+	oid := hashObject(t, gitDir, body)
333
+
334
+	signer := signingSubkey(t, entity)
335
+	// Look up the same fingerprint but with CanSign=false.
336
+	emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
337
+	lookups := lookupsForEntity(t, entity, signer, false /* canSign */, time.Time{}, emails)
338
+
339
+	got, err := Verify(context.Background(), gitDir, oid, lookups)
340
+	if err != nil {
341
+		t.Fatalf("Verify: %v", err)
342
+	}
343
+	if got.Reason != ReasonNotSigningKey {
344
+		t.Errorf("reason: got %q, want not_signing_key", got.Reason)
345
+	}
346
+}
347
+
348
+func TestVerify_ExpiredKey(t *testing.T) {
349
+	entity := makeEntity(t, "alice@shithub.test")
350
+	gitDir := newTestRepo(t)
351
+	// Sign with a creation time of "now"; pretend the subkey expired
352
+	// an hour ago.
353
+	signedAt := time.Now()
354
+	body := signedCommit(t, entity, "expired key sig", signedAt)
355
+	oid := hashObject(t, gitDir, body)
356
+
357
+	signer := signingSubkey(t, entity)
358
+	expired := signedAt.Add(-time.Hour)
359
+	emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
360
+	lookups := lookupsForEntity(t, entity, signer, true, expired, emails)
361
+
362
+	got, err := Verify(context.Background(), gitDir, oid, lookups)
363
+	if err != nil {
364
+		t.Fatalf("Verify: %v", err)
365
+	}
366
+	if got.Reason != ReasonExpiredKey {
367
+		t.Errorf("reason: got %q, want expired_key", got.Reason)
368
+	}
369
+}
370
+
371
+func TestVerify_Invalid(t *testing.T) {
372
+	entity := makeEntity(t, "alice@shithub.test")
373
+	gitDir := newTestRepo(t)
374
+
375
+	// Build commit A (the one we'll write) and capture its gpgsig
376
+	// header. Build commit B with a DIFFERENT message; capture its
377
+	// gpgsig header. Then write commit A's body but with B's
378
+	// signature embedded — the armor still parses cleanly (no
379
+	// malformed_signature), but the cryptographic check fails
380
+	// because the signature was made over commit B's payload, not
381
+	// commit A's. This is the canonical "invalid" branch.
382
+	bodyA := signedCommit(t, entity, "commit A message", time.Now())
383
+	bodyB := signedCommit(t, entity, "commit B different message", time.Now())
384
+
385
+	sigA := extractGpgsig(t, bodyA)
386
+	sigB := extractGpgsig(t, bodyB)
387
+
388
+	mismatched := bytes.Replace(bodyA, sigA, sigB, 1)
389
+	oid := hashObject(t, gitDir, mismatched)
390
+
391
+	signer := signingSubkey(t, entity)
392
+	emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
393
+	lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
394
+
395
+	got, err := Verify(context.Background(), gitDir, oid, lookups)
396
+	if err != nil {
397
+		t.Fatalf("Verify: %v", err)
398
+	}
399
+	if got.Reason != ReasonInvalid {
400
+		t.Errorf("reason: got %q, want invalid", got.Reason)
401
+	}
402
+}
403
+
404
+// extractGpgsig returns the full gpgsig header block (header label +
405
+// continuation lines, trailing newline) from a commit body.
406
+func extractGpgsig(t *testing.T, body []byte) []byte {
407
+	t.Helper()
408
+	idx := bytes.Index(body, []byte("\ngpgsig "))
409
+	if idx < 0 {
410
+		t.Fatalf("commit body has no gpgsig header")
411
+	}
412
+	start := idx + 1 // skip the preceding newline
413
+	// Walk to the first non-continuation line (no leading space).
414
+	cursor := start
415
+	for cursor < len(body) {
416
+		nl := bytes.IndexByte(body[cursor:], '\n')
417
+		if nl < 0 {
418
+			break
419
+		}
420
+		lineEnd := cursor + nl + 1
421
+		// Peek at the next line's first byte; if it's not a
422
+		// continuation (" "), the header block ends here.
423
+		if lineEnd >= len(body) || body[lineEnd] != ' ' {
424
+			return body[start:lineEnd]
425
+		}
426
+		cursor = lineEnd
427
+	}
428
+	return body[start:]
429
+}
430
+
431
+func TestVerify_MalformedSignature(t *testing.T) {
432
+	entity := makeEntity(t, "alice@shithub.test")
433
+	gitDir := newTestRepo(t)
434
+	body := signedCommit(t, entity, "malformed", time.Now())
435
+
436
+	// Replace "-----BEGIN PGP SIGNATURE-----" with "-----BEGIN PGP
437
+	// XYZSIGNATURE-----" — the armor parser will refuse the unknown
438
+	// block type.
439
+	malformed := bytes.Replace(body, []byte("BEGIN PGP SIGNATURE"), []byte("BEGIN PGP XYZSIGNATURE"), 1)
440
+	malformed = bytes.Replace(malformed, []byte("END PGP SIGNATURE"), []byte("END PGP XYZSIGNATURE"), 1)
441
+	oid := hashObject(t, gitDir, malformed)
442
+
443
+	signer := signingSubkey(t, entity)
444
+	emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
445
+	lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
446
+
447
+	got, err := Verify(context.Background(), gitDir, oid, lookups)
448
+	if err != nil {
449
+		t.Fatalf("Verify: %v", err)
450
+	}
451
+	if got.Reason != ReasonMalformedSignature {
452
+		t.Errorf("reason: got %q, want malformed_signature", got.Reason)
453
+	}
454
+}
455
+
456
+// TestVerify_GitCatFileError exercises the error-return path
457
+// (gitDir unreadable / OID nonexistent → orchestrator returns an
458
+// error rather than a Result).
459
+func TestVerify_GitCatFileError(t *testing.T) {
460
+	gitDir := newTestRepo(t)
461
+	_, err := Verify(context.Background(), gitDir, strings.Repeat("0", 40), &fakeLookups{})
462
+	if err == nil {
463
+		t.Error("expected error for nonexistent OID; got nil")
464
+	}
465
+}
466
+
467
+// TestVerifyTag walks the same valid-sig path against a tag object.
468
+// We construct the tag object body with the inline trailing-signature
469
+// form (legacy git tag -s convention).
470
+func TestVerifyTag_ValidInline(t *testing.T) {
471
+	entity := makeEntity(t, "alice@shithub.test")
472
+	gitDir := newTestRepo(t)
473
+
474
+	// Build the tag object payload (everything except the signature)
475
+	// and sign it.
476
+	const emptyCommit = "0000000000000000000000000000000000000000"
477
+	tagPayload := fmt.Sprintf(
478
+		"object %s\ntype commit\ntag v1.0.0\ntagger Alice <alice@shithub.test> %d +0000\n\nrelease notes\n",
479
+		emptyCommit, time.Now().Unix(),
480
+	)
481
+	var sigBuf bytes.Buffer
482
+	armorWriter, err := armor.Encode(&sigBuf, "PGP SIGNATURE", nil)
483
+	if err != nil {
484
+		t.Fatalf("armor.Encode: %v", err)
485
+	}
486
+	if err := openpgp.DetachSign(armorWriter, entity, strings.NewReader(tagPayload), nil); err != nil {
487
+		t.Fatalf("DetachSign: %v", err)
488
+	}
489
+	armorWriter.Close()
490
+
491
+	tagBody := []byte(tagPayload + sigBuf.String())
492
+
493
+	cmd := exec.Command("git", "-C", gitDir, "hash-object", "-w", "-t", "tag", "--stdin")
494
+	cmd.Stdin = bytes.NewReader(tagBody)
495
+	out, err := cmd.Output()
496
+	if err != nil {
497
+		t.Fatalf("hash-object tag: %v", err)
498
+	}
499
+	oid := strings.TrimSpace(string(out))
500
+
501
+	signer := signingSubkey(t, entity)
502
+	emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
503
+	lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
504
+
505
+	got, err := VerifyTag(context.Background(), gitDir, oid, lookups)
506
+	if err != nil {
507
+		t.Fatalf("VerifyTag: %v", err)
508
+	}
509
+	if got.Reason != ReasonValid {
510
+		t.Errorf("reason: got %q, want valid", got.Reason)
511
+	}
512
+}
513
+
514
+// ─── splitSignedObject unit tests ──────────────────────────────────
515
+
516
+func TestSplitSignedObject_Unsigned(t *testing.T) {
517
+	body := []byte("tree abc\nauthor X <x@x> 0 +0000\ncommitter X <x@x> 0 +0000\n\nmessage\n")
518
+	payload, sig, signed := splitSignedObject(body)
519
+	if signed {
520
+		t.Error("expected signed=false")
521
+	}
522
+	if !bytes.Equal(payload, body) {
523
+		t.Errorf("payload mismatch")
524
+	}
525
+	if sig != "" {
526
+		t.Errorf("sig: got %q, want empty", sig)
527
+	}
528
+}
529
+
530
+// ─── helpers ────────────────────────────────────────────────────────
531
+
532
+// suppress unused-import warning for filepath if it's not referenced.
533
+var _ = filepath.Join