Lewis: May this revision serve well! lewis@tangled.org
+823
-15
Diff
round #0
+39
api/tangled/repodescribeRepo.go
+39
api/tangled/repodescribeRepo.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.describeRepo
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoDescribeRepoNSID = "sh.tangled.repo.describeRepo"
15
+
)
16
+
17
+
// RepoDescribeRepo_Output is the output of a sh.tangled.repo.describeRepo call.
18
+
type RepoDescribeRepo_Output struct {
19
+
// ownerDid: DID of the current owner according to the knot.
20
+
OwnerDid string `json:"ownerDid" cborgen:"ownerDid"`
21
+
RepoDid string `json:"repoDid" cborgen:"repoDid"`
22
+
// rkey: Current rkey of the sh.tangled.repo record tracked by this knot
23
+
Rkey string `json:"rkey" cborgen:"rkey"`
24
+
}
25
+
26
+
// RepoDescribeRepo calls the XRPC method "sh.tangled.repo.describeRepo".
27
+
//
28
+
// repoDid: DID of the git repo as minted by the knot
29
+
func RepoDescribeRepo(ctx context.Context, c util.LexClient, repoDid string) (*RepoDescribeRepo_Output, error) {
30
+
var out RepoDescribeRepo_Output
31
+
32
+
params := map[string]interface{}{}
33
+
params["repoDid"] = repoDid
34
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.describeRepo", params, nil, &out); err != nil {
35
+
return nil, err
36
+
}
37
+
38
+
return &out, nil
39
+
}
+2
appview/ingester.go
+2
appview/ingester.go
···
29
29
"tangled.org/core/appview/db"
30
30
"tangled.org/core/appview/models"
31
31
"tangled.org/core/appview/notify"
32
+
"tangled.org/core/appview/repoverify"
32
33
"tangled.org/core/appview/serververify"
33
34
"tangled.org/core/appview/validator"
34
35
"tangled.org/core/idresolver"
···
45
46
Logger *slog.Logger
46
47
Validator *validator.Validator
47
48
Notifier notify.Notifier
49
+
Verifier repoverify.Verifier
48
50
}
49
51
50
52
type processFunc func(ctx context.Context, e *jmodels.Event) error
+61
-1
appview/ingester_repo.go
+61
-1
appview/ingester_repo.go
···
6
6
"encoding/json"
7
7
"errors"
8
8
"fmt"
9
+
"log/slog"
9
10
"slices"
11
+
"strings"
10
12
11
13
"github.com/bluesky-social/indigo/atproto/syntax"
12
14
jmodels "github.com/bluesky-social/jetstream/pkg/models"
13
15
"tangled.org/core/api/tangled"
14
16
"tangled.org/core/appview/db"
15
17
"tangled.org/core/appview/models"
18
+
"tangled.org/core/appview/repoverify"
16
19
"tangled.org/core/orm"
17
20
)
18
21
···
47
50
}
48
51
repoDid := *record.RepoDid
49
52
50
-
_, err := db.GetRepo(i.Db,
53
+
proceed, err := i.verifyOwnership(ctx, l, repoDid, e.Did, record.Knot)
54
+
if err != nil {
55
+
return err
56
+
}
57
+
if !proceed {
58
+
return nil
59
+
}
60
+
61
+
_, err = db.GetRepo(i.Db,
51
62
orm.FilterEq("did", e.Did),
52
63
orm.FilterEq("rkey", e.Commit.RKey),
53
64
)
···
165
176
return nil
166
177
}
167
178
179
+
proceed, err := i.verifyOwnership(ctx, l, *record.RepoDid, e.Did, record.Knot)
180
+
if err != nil {
181
+
return err
182
+
}
183
+
if !proceed {
184
+
return nil
185
+
}
186
+
168
187
current, err := db.GetRepo(i.Db,
169
188
orm.FilterEq("did", e.Did),
170
189
orm.FilterEq("rkey", e.Commit.RKey),
···
177
196
return fmt.Errorf("failed to fetch repo for ingest: %w", err)
178
197
}
179
198
199
+
if current.RepoDid != "" && current.RepoDid != *record.RepoDid {
200
+
l.Warn("rejecting repo update: repoDid is immutable",
201
+
"currentRepoDid", current.RepoDid,
202
+
"recordRepoDid", *record.RepoDid,
203
+
)
204
+
return nil
205
+
}
206
+
180
207
desired := repoFromRecord(current, &record)
181
208
182
209
if current.Source != desired.Source {
···
330
357
}
331
358
return *s
332
359
}
360
+
361
+
func (i *Ingester) verifyOwnership(ctx context.Context, l *slog.Logger, repoDid, eventDid, recordKnot string) (bool, error) {
362
+
if i.Verifier == nil {
363
+
return false, fmt.Errorf("ingester has no repo ownership verifier configured")
364
+
}
365
+
rd, err := repoverify.NewRepoDid(repoDid)
366
+
if err != nil {
367
+
l.Warn("rejecting repo event: invalid repoDid on record", "repoDid", repoDid, "err", err)
368
+
return false, nil
369
+
}
370
+
result, err := i.Verifier(ctx, rd)
371
+
if err != nil {
372
+
return false, fmt.Errorf("verify repo ownership: %w", err)
373
+
}
374
+
if result.OwnerDid.String() != eventDid {
375
+
l.Warn("rejecting repo event: owner mismatch",
376
+
"repoDid", repoDid,
377
+
"claimedOwner", eventDid,
378
+
"knotOwner", result.OwnerDid.String(),
379
+
"knot", result.KnotURL.String(),
380
+
)
381
+
return false, nil
382
+
}
383
+
if !strings.EqualFold(recordKnot, result.KnotURL.Host) {
384
+
l.Warn("rejecting repo event: record knot does not match DID-doc endpoint",
385
+
"repoDid", repoDid,
386
+
"recordKnot", recordKnot,
387
+
"canonicalKnot", result.KnotURL.Host,
388
+
)
389
+
return false, nil
390
+
}
391
+
return true, nil
392
+
}
+311
-14
appview/ingester_repo_test.go
+311
-14
appview/ingester_repo_test.go
···
7
7
"errors"
8
8
"io"
9
9
"log/slog"
10
+
"net/url"
10
11
"path/filepath"
11
12
"testing"
12
13
···
16
17
"tangled.org/core/appview/db"
17
18
"tangled.org/core/appview/models"
18
19
"tangled.org/core/appview/notify"
20
+
"tangled.org/core/appview/repoverify"
19
21
"tangled.org/core/orm"
20
22
)
21
23
24
+
func mustKnotURL(t *testing.T, raw string) *url.URL {
25
+
t.Helper()
26
+
u, err := repoverify.ParseKnotEndpoint(raw, true)
27
+
if err != nil {
28
+
t.Fatalf("ParseKnotEndpoint(%q): %v", raw, err)
29
+
}
30
+
return u
31
+
}
32
+
33
+
func acceptOwner(t *testing.T, e *jmodels.Event) repoverify.Verifier {
34
+
t.Helper()
35
+
knot := mustKnotURL(t, "https://knot.example")
36
+
return func(_ context.Context, repoDid repoverify.RepoDid) (repoverify.Result, error) {
37
+
return repoverify.Result{
38
+
RepoDid: repoDid,
39
+
OwnerDid: repoverify.OwnerDid(e.Did),
40
+
KnotURL: knot,
41
+
}, nil
42
+
}
43
+
}
44
+
45
+
func stubVerifier(result repoverify.Result, err error) repoverify.Verifier {
46
+
return func(_ context.Context, _ repoverify.RepoDid) (repoverify.Result, error) {
47
+
return result, err
48
+
}
49
+
}
50
+
22
51
type spyNotifier struct {
23
52
notify.BaseNotifier
24
53
creates int
···
50
79
return ing, spy
51
80
}
52
81
82
+
func withVerifier(ing *Ingester, v repoverify.Verifier) *Ingester {
83
+
ing.Verifier = v
84
+
return ing
85
+
}
86
+
87
+
func ingestAcceptingOwner(t *testing.T, ing *Ingester, e *jmodels.Event) error {
88
+
t.Helper()
89
+
ing.Verifier = acceptOwner(t, e)
90
+
return ing.ingestRepo(context.Background(), e)
91
+
}
92
+
53
93
func seedRepoRow(t *testing.T, ing *Ingester, did, knot, name, rkey, repoDid string) *models.Repo {
54
94
t.Helper()
55
95
tx, err := ing.Db.Begin()
···
126
166
RepoDid: ptr("did:plc:repo1"),
127
167
})
128
168
129
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
169
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
130
170
t.Fatalf("ingestRepo: %v", err)
131
171
}
132
172
···
155
195
RepoDid: ptr("did:plc:repo1"),
156
196
})
157
197
158
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
198
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
159
199
t.Fatalf("ingestRepo: %v", err)
160
200
}
161
201
if spy.creates != 0 {
···
173
213
RepoDid: ptr("did:plc:repo1"),
174
214
})
175
215
176
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
216
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
177
217
t.Fatalf("ingestRepo: %v", err)
178
218
}
179
219
···
217
257
Name: ptr("myrepo"),
218
258
})
219
259
220
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
260
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
221
261
t.Fatalf("ingestRepo: %v", err)
222
262
}
223
263
if spy.creates != 0 {
···
238
278
RepoDid: ptr("did:plc:repo1"),
239
279
})
240
280
241
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
281
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
242
282
t.Fatalf("ingestRepo: %v", err)
243
283
}
244
284
···
264
304
RepoDid: ptr("did:plc:repo1"),
265
305
})
266
306
267
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
307
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
268
308
t.Fatalf("ingestRepo: %v", err)
269
309
}
270
310
···
287
327
RepoDid: ptr("did:plc:repo1"),
288
328
})
289
329
290
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
330
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
291
331
t.Fatalf("ingestRepo: %v", err)
292
332
}
293
333
···
315
355
e = makeDeleteEvent("did:plc:nobody", "ghost")
316
356
}
317
357
318
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
358
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
319
359
t.Fatalf("ingestRepo: %v", err)
320
360
}
321
361
})
···
331
371
Name: ptr("bar"),
332
372
})
333
373
334
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
374
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
335
375
t.Fatalf("ingestRepo: %v", err)
336
376
}
337
377
···
346
386
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "foo", "foo", "did:plc:repo1")
347
387
348
388
e := makeDeleteEvent("did:plc:akshay", "foo")
349
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
389
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
350
390
t.Fatalf("ingestRepo: %v", err)
351
391
}
352
392
···
373
413
},
374
414
}
375
415
376
-
if err := ing.ingestRepo(context.Background(), e); err == nil {
416
+
if err := ingestAcceptingOwner(t, ing, e); err == nil {
377
417
t.Errorf("ingestRepo with malformed record: err = nil, want error")
378
418
}
379
419
}
···
394
434
Name: ptr("NewName"),
395
435
RepoDid: ptr("did:plc:repo1"),
396
436
})
397
-
if err := ing.ingestRepo(context.Background(), createEvt); err != nil {
437
+
if err := ingestAcceptingOwner(t, ing, createEvt); err != nil {
398
438
t.Fatalf("ingest create: %v", err)
399
439
}
400
440
401
441
deleteEvt := makeDeleteEvent("did:plc:akshay", "oldname")
402
-
if err := ing.ingestRepo(context.Background(), deleteEvt); err != nil {
442
+
if err := ingestAcceptingOwner(t, ing, deleteEvt); err != nil {
403
443
t.Fatalf("ingest delete: %v", err)
404
444
}
405
445
···
446
486
RepoDid: ptr("did:plc:repo1"),
447
487
})
448
488
449
-
if err := ing.ingestRepo(context.Background(), e); err != nil {
489
+
if err := ingestAcceptingOwner(t, ing, e); err != nil {
450
490
t.Fatalf("ingestRepo: %v", err)
451
491
}
452
492
···
455
495
t.Errorf("name should fall back to rkey: got %q, want %q", r.Name, "myrepo")
456
496
}
457
497
}
498
+
499
+
func TestIngestRepo_CreateSquatRejected(t *testing.T) {
500
+
ing, spy := newTestIngester(t)
501
+
502
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "squatrepo", tangled.Repo{
503
+
Knot: "knot.example",
504
+
RepoDid: ptr("did:plc:akshays-repo"),
505
+
})
506
+
507
+
withVerifier(ing, stubVerifier(repoverify.Result{
508
+
RepoDid: "did:plc:akshays-repo",
509
+
OwnerDid: "did:plc:akshay",
510
+
KnotURL: mustKnotURL(t, "https://knot.example"),
511
+
}, nil))
512
+
513
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
514
+
t.Fatalf("ingestRepo: %v", err)
515
+
}
516
+
517
+
if _, err := db.GetRepo(ing.Db,
518
+
orm.FilterEq("did", "did:plc:boltless"),
519
+
orm.FilterEq("rkey", "squatrepo"),
520
+
); !errors.Is(err, sql.ErrNoRows) {
521
+
t.Fatalf("boltless's squat row should not exist, got err=%v", err)
522
+
}
523
+
if spy.creates != 0 {
524
+
t.Errorf("NewRepo called %d times despite rejection", spy.creates)
525
+
}
526
+
}
527
+
528
+
func TestIngestRepo_CreateHijackExistingRepoRejected(t *testing.T) {
529
+
ing, spy := newTestIngester(t)
530
+
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo")
531
+
532
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "takeover", tangled.Repo{
533
+
Knot: "knot.example",
534
+
RepoDid: ptr("did:plc:akshays-repo"),
535
+
})
536
+
537
+
withVerifier(ing, stubVerifier(repoverify.Result{
538
+
RepoDid: "did:plc:akshays-repo",
539
+
OwnerDid: "did:plc:akshay",
540
+
KnotURL: mustKnotURL(t, "https://knot.example"),
541
+
}, nil))
542
+
543
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
544
+
t.Fatalf("ingestRepo: %v", err)
545
+
}
546
+
547
+
akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey")
548
+
if akshay.Did != "did:plc:akshay" || akshay.Rkey != "akshayskey" {
549
+
t.Errorf("akshay's row mutated: %+v", akshay)
550
+
}
551
+
if spy.renames != 0 {
552
+
t.Errorf("RenameRepo called %d times despite rejection", spy.renames)
553
+
}
554
+
}
555
+
556
+
func TestIngestRepo_CreateRenameIgnoresRkeyDrift(t *testing.T) {
557
+
ing, spy := newTestIngester(t)
558
+
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "oldname", "oldrkey", "did:plc:akshays-repo")
559
+
560
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "newrkey", tangled.Repo{
561
+
Knot: "knot.example",
562
+
Name: ptr("newname"),
563
+
RepoDid: ptr("did:plc:akshays-repo"),
564
+
})
565
+
566
+
withVerifier(ing, stubVerifier(repoverify.Result{
567
+
RepoDid: "did:plc:akshays-repo",
568
+
OwnerDid: "did:plc:akshay",
569
+
KnotURL: mustKnotURL(t, "https://knot.example"),
570
+
}, nil))
571
+
572
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
573
+
t.Fatalf("ingestRepo: %v", err)
574
+
}
575
+
576
+
r := loadRepo(t, ing, "did:plc:akshay", "newrkey")
577
+
if r.Name != "newname" {
578
+
t.Errorf("rename did not apply despite matching owner: name=%q", r.Name)
579
+
}
580
+
if spy.renames != 1 {
581
+
t.Errorf("RenameRepo called %d times, want 1", spy.renames)
582
+
}
583
+
}
584
+
585
+
func TestIngestRepo_CreateVerifierTransientErrorPropagates(t *testing.T) {
586
+
ing, spy := newTestIngester(t)
587
+
588
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{
589
+
Knot: "knot.example",
590
+
RepoDid: ptr("did:plc:akshays-repo"),
591
+
})
592
+
593
+
withVerifier(ing, stubVerifier(repoverify.Result{}, errors.New("knot unreachable")))
594
+
595
+
err := ing.ingestRepo(context.Background(), e)
596
+
if err == nil {
597
+
t.Fatalf("expected error on transient verifier failure, got nil")
598
+
}
599
+
if spy.creates != 0 {
600
+
t.Errorf("NewRepo called %d times despite verifier error", spy.creates)
601
+
}
602
+
}
603
+
604
+
func TestIngestRepo_UpdateRejectsOwnerMismatch(t *testing.T) {
605
+
ing, _ := newTestIngester(t)
606
+
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo")
607
+
608
+
e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:boltless", "akshayskey", tangled.Repo{
609
+
Knot: "knot.example",
610
+
Description: ptr("boltless hijacks metadata"),
611
+
RepoDid: ptr("did:plc:akshays-repo"),
612
+
})
613
+
614
+
withVerifier(ing, stubVerifier(repoverify.Result{
615
+
RepoDid: "did:plc:akshays-repo",
616
+
OwnerDid: "did:plc:akshay",
617
+
KnotURL: mustKnotURL(t, "https://knot.example"),
618
+
}, nil))
619
+
620
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
621
+
t.Fatalf("ingestRepo: %v", err)
622
+
}
623
+
624
+
akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey")
625
+
if akshay.Description == "boltless hijacks metadata" {
626
+
t.Errorf("update by non-owner applied: %+v", akshay)
627
+
}
628
+
}
629
+
630
+
func TestIngestRepo_CreateInvalidRepoDidRejected(t *testing.T) {
631
+
ing, spy := newTestIngester(t)
632
+
633
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{
634
+
Knot: "knot.example",
635
+
RepoDid: ptr("did:plc:"),
636
+
})
637
+
638
+
verifierCalled := false
639
+
withVerifier(ing, func(_ context.Context, _ repoverify.RepoDid) (repoverify.Result, error) {
640
+
verifierCalled = true
641
+
return repoverify.Result{}, nil
642
+
})
643
+
644
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
645
+
t.Fatalf("ingestRepo: %v", err)
646
+
}
647
+
if verifierCalled {
648
+
t.Errorf("verifier was called with an invalid repoDid")
649
+
}
650
+
if spy.creates != 0 {
651
+
t.Errorf("NewRepo called %d times despite invalid repoDid", spy.creates)
652
+
}
653
+
}
654
+
655
+
func TestIngestRepo_NilVerifierFailsClosed(t *testing.T) {
656
+
ing, spy := newTestIngester(t)
657
+
658
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{
659
+
Knot: "knot.example",
660
+
RepoDid: ptr("did:plc:akshays-repo"),
661
+
})
662
+
663
+
err := ing.ingestRepo(context.Background(), e)
664
+
if err == nil {
665
+
t.Fatalf("expected error when Verifier is nil, got nil")
666
+
}
667
+
if spy.creates != 0 {
668
+
t.Errorf("NewRepo called %d times despite nil verifier", spy.creates)
669
+
}
670
+
}
671
+
672
+
func TestIngestRepo_CreateRejectsKnotMismatch(t *testing.T) {
673
+
ing, spy := newTestIngester(t)
674
+
675
+
e := makeEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "myrepo", tangled.Repo{
676
+
Knot: "evil.example",
677
+
RepoDid: ptr("did:plc:akshays-repo"),
678
+
})
679
+
680
+
withVerifier(ing, stubVerifier(repoverify.Result{
681
+
RepoDid: "did:plc:akshays-repo",
682
+
OwnerDid: "did:plc:akshay",
683
+
KnotURL: mustKnotURL(t, "https://knot.example"),
684
+
}, nil))
685
+
686
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
687
+
t.Fatalf("ingestRepo: %v", err)
688
+
}
689
+
if _, err := db.GetRepo(ing.Db,
690
+
orm.FilterEq("did", "did:plc:akshay"),
691
+
orm.FilterEq("rkey", "myrepo"),
692
+
); !errors.Is(err, sql.ErrNoRows) {
693
+
t.Fatalf("row should not be created for spoofed knot, err=%v", err)
694
+
}
695
+
if spy.creates != 0 {
696
+
t.Errorf("NewRepo called %d times despite knot mismatch", spy.creates)
697
+
}
698
+
}
699
+
700
+
func TestIngestRepo_UpdateRejectsKnotMismatch(t *testing.T) {
701
+
ing, _ := newTestIngester(t)
702
+
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo")
703
+
704
+
e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{
705
+
Knot: "evil.example",
706
+
Description: ptr("redirected clone target"),
707
+
RepoDid: ptr("did:plc:akshays-repo"),
708
+
})
709
+
710
+
withVerifier(ing, stubVerifier(repoverify.Result{
711
+
RepoDid: "did:plc:akshays-repo",
712
+
OwnerDid: "did:plc:akshay",
713
+
KnotURL: mustKnotURL(t, "https://knot.example"),
714
+
}, nil))
715
+
716
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
717
+
t.Fatalf("ingestRepo: %v", err)
718
+
}
719
+
akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey")
720
+
if akshay.Description == "redirected clone target" {
721
+
t.Errorf("update with spoofed knot applied: %+v", akshay)
722
+
}
723
+
if akshay.Knot != "knot.example" {
724
+
t.Errorf("row knot mutated to %q, want knot.example", akshay.Knot)
725
+
}
726
+
}
727
+
728
+
func TestIngestRepo_UpdateRejectsRepoDidMutation(t *testing.T) {
729
+
ing, _ := newTestIngester(t)
730
+
seedRepoRow(t, ing, "did:plc:akshay", "knot.example", "myrepo", "akshayskey", "did:plc:akshays-repo")
731
+
732
+
e := makeEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "akshayskey", tangled.Repo{
733
+
Knot: "knot.example",
734
+
Description: ptr("sneaky repoDid swap"),
735
+
RepoDid: ptr("did:plc:other-repo"),
736
+
})
737
+
738
+
withVerifier(ing, stubVerifier(repoverify.Result{
739
+
RepoDid: "did:plc:other-repo",
740
+
OwnerDid: "did:plc:akshay",
741
+
KnotURL: mustKnotURL(t, "https://knot.example"),
742
+
}, nil))
743
+
744
+
if err := ing.ingestRepo(context.Background(), e); err != nil {
745
+
t.Fatalf("ingestRepo: %v", err)
746
+
}
747
+
akshay := loadRepo(t, ing, "did:plc:akshay", "akshayskey")
748
+
if akshay.RepoDid != "did:plc:akshays-repo" {
749
+
t.Errorf("repoDid mutated to %q, want did:plc:akshays-repo", akshay.RepoDid)
750
+
}
751
+
if akshay.Description == "sneaky repoDid swap" {
752
+
t.Errorf("metadata from repoDid-mutating update applied: %+v", akshay)
753
+
}
754
+
}
+155
appview/repoverify/verify.go
+155
appview/repoverify/verify.go
···
1
+
package repoverify
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
"net"
7
+
"net/http"
8
+
"net/url"
9
+
"syscall"
10
+
"time"
11
+
12
+
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/appview/xrpcclient"
16
+
"tangled.org/core/idresolver"
17
+
)
18
+
19
+
type RepoDid syntax.DID
20
+
21
+
func (r RepoDid) String() string { return string(r) }
22
+
23
+
func NewRepoDid(s string) (RepoDid, error) {
24
+
did, err := syntax.ParseDID(s)
25
+
if err != nil {
26
+
return "", fmt.Errorf("invalid repoDid %q: %w", s, err)
27
+
}
28
+
return RepoDid(did), nil
29
+
}
30
+
31
+
type OwnerDid syntax.DID
32
+
33
+
func (o OwnerDid) String() string { return string(o) }
34
+
35
+
func NewOwnerDid(s string) (OwnerDid, error) {
36
+
did, err := syntax.ParseDID(s)
37
+
if err != nil {
38
+
return "", fmt.Errorf("invalid ownerDid %q: %w", s, err)
39
+
}
40
+
return OwnerDid(did), nil
41
+
}
42
+
43
+
func ParseKnotEndpoint(raw string, dev bool) (*url.URL, error) {
44
+
if raw == "" {
45
+
return nil, fmt.Errorf("empty knot URL")
46
+
}
47
+
u, err := url.Parse(raw)
48
+
if err != nil {
49
+
return nil, fmt.Errorf("invalid knot URL %q: %w", raw, err)
50
+
}
51
+
if u.Host == "" {
52
+
return nil, fmt.Errorf("knot URL %q has no host", raw)
53
+
}
54
+
switch u.Scheme {
55
+
case "https":
56
+
case "http":
57
+
if !dev {
58
+
return nil, fmt.Errorf("knot URL %q must use https outside dev mode", raw)
59
+
}
60
+
default:
61
+
return nil, fmt.Errorf("knot URL %q has unsupported scheme %q", raw, u.Scheme)
62
+
}
63
+
return u, nil
64
+
}
65
+
66
+
type Result struct {
67
+
RepoDid RepoDid
68
+
OwnerDid OwnerDid
69
+
KnotURL *url.URL
70
+
}
71
+
72
+
type Verifier func(ctx context.Context, repoDid RepoDid) (Result, error)
73
+
74
+
const verifyTimeout = 10 * time.Second
75
+
76
+
func New(resolver *idresolver.Resolver, dev bool) Verifier {
77
+
transport := &http.Transport{
78
+
DialContext: safeDialer(dev).DialContext,
79
+
}
80
+
httpClient := &http.Client{
81
+
Timeout: verifyTimeout,
82
+
Transport: transport,
83
+
}
84
+
85
+
return func(ctx context.Context, repoDid RepoDid) (Result, error) {
86
+
ctx, cancel := context.WithTimeout(ctx, verifyTimeout)
87
+
defer cancel()
88
+
return resolveAndDescribe(ctx, resolver, httpClient, repoDid, dev)
89
+
}
90
+
}
91
+
92
+
func resolveAndDescribe(
93
+
ctx context.Context,
94
+
resolver *idresolver.Resolver,
95
+
httpClient *http.Client,
96
+
repoDid RepoDid,
97
+
dev bool,
98
+
) (Result, error) {
99
+
ident, err := resolver.ResolveIdent(ctx, repoDid.String())
100
+
if err != nil {
101
+
return Result{}, fmt.Errorf("resolve repoDid %s: %w", repoDid, err)
102
+
}
103
+
104
+
knot, err := ParseKnotEndpoint(ident.GetServiceEndpoint("atproto_pds"), dev)
105
+
if err != nil {
106
+
return Result{}, fmt.Errorf("repoDid %s: %w", repoDid, err)
107
+
}
108
+
109
+
client := &indigoxrpc.Client{Host: knot.String(), Client: httpClient}
110
+
out, err := tangled.RepoDescribeRepo(ctx, client, repoDid.String())
111
+
if xrpcErr := xrpcclient.HandleXrpcErr(err); xrpcErr != nil {
112
+
return Result{}, fmt.Errorf("describeRepo on %s: %w", knot, xrpcErr)
113
+
}
114
+
115
+
if out.RepoDid != repoDid.String() {
116
+
return Result{}, fmt.Errorf("knot %s returned mismatched repoDid: got %q, want %q", knot, out.RepoDid, repoDid)
117
+
}
118
+
119
+
ownerDid, err := NewOwnerDid(out.OwnerDid)
120
+
if err != nil {
121
+
return Result{}, fmt.Errorf("describeRepo on %s returned invalid ownerDid: %w", knot, err)
122
+
}
123
+
124
+
return Result{
125
+
RepoDid: repoDid,
126
+
OwnerDid: ownerDid,
127
+
KnotURL: knot,
128
+
}, nil
129
+
}
130
+
131
+
func safeDialer(dev bool) *net.Dialer {
132
+
d := &net.Dialer{
133
+
Timeout: 5 * time.Second,
134
+
KeepAlive: 30 * time.Second,
135
+
}
136
+
if dev {
137
+
return d
138
+
}
139
+
d.Control = func(network, address string, _ syscall.RawConn) error {
140
+
host, _, err := net.SplitHostPort(address)
141
+
if err != nil {
142
+
return fmt.Errorf("invalid dial address %q: %w", address, err)
143
+
}
144
+
ip := net.ParseIP(host)
145
+
if ip == nil {
146
+
return fmt.Errorf("dial address %q did not resolve to IP", address)
147
+
}
148
+
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
149
+
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
150
+
return fmt.Errorf("refusing to dial %s: reserved or private address", ip)
151
+
}
152
+
return nil
153
+
}
154
+
return d
155
+
}
+66
appview/repoverify/verify_test.go
+66
appview/repoverify/verify_test.go
···
1
+
package repoverify
2
+
3
+
import "testing"
4
+
5
+
func TestNewRepoDid_RejectsInvalid(t *testing.T) {
6
+
if _, err := NewRepoDid(""); err == nil {
7
+
t.Error("NewRepoDid(\"\") err = nil, want error")
8
+
}
9
+
}
10
+
11
+
func TestNewRepoDid_AcceptsValid(t *testing.T) {
12
+
raw := "did:plc:abc123abc123abc123abc123"
13
+
got, err := NewRepoDid(raw)
14
+
if err != nil {
15
+
t.Fatalf("NewRepoDid(%q): %v", raw, err)
16
+
}
17
+
if got.String() != raw {
18
+
t.Errorf("got %q, want %q", got, raw)
19
+
}
20
+
}
21
+
22
+
func TestParseKnotEndpoint_RejectsHttpInProd(t *testing.T) {
23
+
if _, err := ParseKnotEndpoint("http://knot.example", false); err == nil {
24
+
t.Error("http:// knot URL accepted in prod")
25
+
}
26
+
}
27
+
28
+
func TestParseKnotEndpoint_AllowsHttpInDev(t *testing.T) {
29
+
u, err := ParseKnotEndpoint("http://knot.example", true)
30
+
if err != nil {
31
+
t.Fatalf("dev mode should allow http: %v", err)
32
+
}
33
+
if u.Host != "knot.example" {
34
+
t.Errorf("Host = %q, want knot.example", u.Host)
35
+
}
36
+
}
37
+
38
+
func TestParseKnotEndpoint_RejectsUnsupportedScheme(t *testing.T) {
39
+
if _, err := ParseKnotEndpoint("ftp://knot.example", true); err == nil {
40
+
t.Error("ParseKnotEndpoint accepted ftp:// in dev")
41
+
}
42
+
if _, err := ParseKnotEndpoint("ftp://knot.example", false); err == nil {
43
+
t.Error("ParseKnotEndpoint accepted ftp:// in prod")
44
+
}
45
+
}
46
+
47
+
func TestParseKnotEndpoint_RejectsEmptyOrHostless(t *testing.T) {
48
+
cases := []string{"", "https://", "not a url at all"}
49
+
for _, raw := range cases {
50
+
t.Run(raw, func(t *testing.T) {
51
+
if _, err := ParseKnotEndpoint(raw, false); err == nil {
52
+
t.Errorf("ParseKnotEndpoint(%q) accepted bogus URL", raw)
53
+
}
54
+
})
55
+
}
56
+
}
57
+
58
+
func TestParseKnotEndpoint_HostPreservesPort(t *testing.T) {
59
+
u, err := ParseKnotEndpoint("http://localhost:3000", true)
60
+
if err != nil {
61
+
t.Fatalf("ParseKnotEndpoint: %v", err)
62
+
}
63
+
if u.Host != "localhost:3000" {
64
+
t.Errorf("Host = %q, want localhost:3000", u.Host)
65
+
}
66
+
}
+2
appview/state/state.go
+2
appview/state/state.go
···
29
29
"tangled.org/core/appview/oauth"
30
30
"tangled.org/core/appview/pages"
31
31
"tangled.org/core/appview/reporesolver"
32
+
"tangled.org/core/appview/repoverify"
32
33
"tangled.org/core/appview/validator"
33
34
xrpcclient "tangled.org/core/appview/xrpcclient"
34
35
"tangled.org/core/consts"
···
181
182
Logger: log.SubLogger(logger, "ingester"),
182
183
Validator: validator,
183
184
Notifier: notifier,
185
+
Verifier: repoverify.New(res, config.Core.Dev),
184
186
}
185
187
err = jc.StartJetstream(ctx, ingester.Ingest())
186
188
if err != nil {
+39
knotserver/xrpc/repo_describe_repo.go
+39
knotserver/xrpc/repo_describe_repo.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"database/sql"
5
+
"errors"
6
+
"net/http"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/api/tangled"
10
+
xrpcerr "tangled.org/core/xrpc/errors"
11
+
)
12
+
13
+
func (x *Xrpc) RepoDescribeRepo(w http.ResponseWriter, r *http.Request) {
14
+
raw := r.URL.Query().Get("repoDid")
15
+
repoDid, err := syntax.ParseDID(raw)
16
+
if err != nil {
17
+
writeError(w, xrpcerr.NewXrpcError(
18
+
xrpcerr.WithTag("InvalidRequest"),
19
+
xrpcerr.WithMessage("missing or invalid repoDid parameter"),
20
+
), http.StatusBadRequest)
21
+
return
22
+
}
23
+
24
+
ownerDid, rkey, err := x.Db.GetRepoKeyOwner(repoDid.String())
25
+
if errors.Is(err, sql.ErrNoRows) {
26
+
writeError(w, xrpcerr.RepoNotFoundError, http.StatusNotFound)
27
+
return
28
+
}
29
+
if err != nil {
30
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
31
+
return
32
+
}
33
+
34
+
x.writeJson(w, tangled.RepoDescribeRepo_Output{
35
+
RepoDid: repoDid.String(),
36
+
OwnerDid: ownerDid,
37
+
Rkey: rkey,
38
+
})
39
+
}
+94
knotserver/xrpc/repo_describe_repo_test.go
+94
knotserver/xrpc/repo_describe_repo_test.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"io"
7
+
"log/slog"
8
+
"net/http"
9
+
"net/http/httptest"
10
+
"net/url"
11
+
"path/filepath"
12
+
"testing"
13
+
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/knotserver/config"
16
+
"tangled.org/core/knotserver/db"
17
+
)
18
+
19
+
func newTestXrpc(t *testing.T) *Xrpc {
20
+
t.Helper()
21
+
d, err := db.Setup(context.Background(), filepath.Join(t.TempDir(), "test.db"))
22
+
if err != nil {
23
+
t.Fatalf("db.Setup: %v", err)
24
+
}
25
+
return &Xrpc{
26
+
Db: d,
27
+
Config: &config.Config{Server: config.Server{Hostname: "knot.example", MaxResponseKB: 5120}},
28
+
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
29
+
}
30
+
}
31
+
32
+
func TestRepoDescribeRepo_ReturnsOwner(t *testing.T) {
33
+
x := newTestXrpc(t)
34
+
if err := x.Db.StoreRepoKey("did:plc:repo1", []byte("dummy"), "did:plc:akshay", "myrepo"); err != nil {
35
+
t.Fatalf("StoreRepoKey: %v", err)
36
+
}
37
+
38
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/sh.tangled.repo.describeRepo?repoDid=did:plc:repo1", nil)
39
+
rec := httptest.NewRecorder()
40
+
x.RepoDescribeRepo(rec, req)
41
+
42
+
if rec.Code != http.StatusOK {
43
+
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
44
+
}
45
+
46
+
var out tangled.RepoDescribeRepo_Output
47
+
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
48
+
t.Fatalf("decode: %v", err)
49
+
}
50
+
if out.RepoDid != "did:plc:repo1" {
51
+
t.Errorf("RepoDid = %q", out.RepoDid)
52
+
}
53
+
if out.OwnerDid != "did:plc:akshay" {
54
+
t.Errorf("OwnerDid = %q, want did:plc:akshay", out.OwnerDid)
55
+
}
56
+
if out.Rkey != "myrepo" {
57
+
t.Errorf("Rkey = %q, want myrepo", out.Rkey)
58
+
}
59
+
}
60
+
61
+
func TestRepoDescribeRepo_UnknownRepoDidReturns404(t *testing.T) {
62
+
x := newTestXrpc(t)
63
+
64
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/sh.tangled.repo.describeRepo?repoDid=did:plc:unknown", nil)
65
+
rec := httptest.NewRecorder()
66
+
x.RepoDescribeRepo(rec, req)
67
+
68
+
if rec.Code != http.StatusNotFound {
69
+
t.Errorf("status = %d, want 404", rec.Code)
70
+
}
71
+
}
72
+
73
+
func TestRepoDescribeRepo_MissingParamReturns400(t *testing.T) {
74
+
x := newTestXrpc(t)
75
+
76
+
req := httptest.NewRequest(http.MethodGet, "/xrpc/sh.tangled.repo.describeRepo", nil)
77
+
rec := httptest.NewRecorder()
78
+
x.RepoDescribeRepo(rec, req)
79
+
80
+
if rec.Code != http.StatusBadRequest {
81
+
t.Errorf("status = %d, want 400", rec.Code)
82
+
}
83
+
}
84
+
85
+
func TestRepoDescribeRepo_MalformedDidParamReturns400(t *testing.T) {
86
+
x := newTestXrpc(t)
87
+
u := "/xrpc/sh.tangled.repo.describeRepo?repoDid=" + url.QueryEscape("notadid")
88
+
req := httptest.NewRequest(http.MethodGet, u, nil)
89
+
rec := httptest.NewRecorder()
90
+
x.RepoDescribeRepo(rec, req)
91
+
if rec.Code != http.StatusBadRequest {
92
+
t.Errorf("status = %d, want 400", rec.Code)
93
+
}
94
+
}
+1
knotserver/xrpc/xrpc.go
+1
knotserver/xrpc/xrpc.go
···
67
67
r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff)
68
68
r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare)
69
69
r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch)
70
+
r.Get("/"+tangled.RepoDescribeRepoNSID, x.RepoDescribeRepo)
70
71
r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch)
71
72
r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive)
72
73
r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages)
+53
lexicons/repo/describeRepo.json
+53
lexicons/repo/describeRepo.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.describeRepo",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "Fetch the knot's authoritative metadata for a git repo DID.",
8
+
"parameters": {
9
+
"type": "params",
10
+
"required": ["repoDid"],
11
+
"properties": {
12
+
"repoDid": {
13
+
"type": "string",
14
+
"format": "did",
15
+
"description": "DID of the git repo as minted by the knot"
16
+
}
17
+
}
18
+
},
19
+
"output": {
20
+
"encoding": "application/json",
21
+
"schema": {
22
+
"type": "object",
23
+
"required": ["repoDid", "ownerDid", "rkey"],
24
+
"properties": {
25
+
"repoDid": {
26
+
"type": "string",
27
+
"format": "did"
28
+
},
29
+
"ownerDid": {
30
+
"type": "string",
31
+
"format": "did",
32
+
"description": "DID of the current owner according to the knot."
33
+
},
34
+
"rkey": {
35
+
"type": "string",
36
+
"description": "Current rkey of the sh.tangled.repo record tracked by this knot"
37
+
}
38
+
}
39
+
}
40
+
},
41
+
"errors": [
42
+
{
43
+
"name": "RepoNotFound",
44
+
"description": "Repo DID is not registered on this knot"
45
+
},
46
+
{
47
+
"name": "InvalidRequest",
48
+
"description": "Invalid request parameters"
49
+
}
50
+
]
51
+
}
52
+
}
53
+
}
History
1 round
0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
appview,knotserver: validate git repo ownership according to knot
Lewis: May this revision serve well! <lewis@tangled.org>
no conflicts, ready to merge