This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / spindle / tapclient_test.go
18 kB 686 lines
1package spindle 2 3import ( 4 "context" 5 "encoding/json" 6 "log/slog" 7 "net/http" 8 "net/http/httptest" 9 "net/url" 10 "strings" 11 "testing" 12 "time" 13 14 "tangled.org/core/jetstream" 15 "tangled.org/core/repoident" 16 "tangled.org/core/repoverify" 17 18 "github.com/bluesky-social/indigo/atproto/identity" 19 "github.com/bluesky-social/indigo/atproto/syntax" 20 "github.com/stretchr/testify/assert" 21 "tangled.org/core/api/tangled" 22 "tangled.org/core/eventconsumer" 23 "tangled.org/core/idresolver" 24 "tangled.org/core/spindle/config" 25 "tangled.org/core/spindle/db" 26 27 "tangled.org/core/tapc" 28) 29 30type mockDirectory struct { 31 ident *identity.Identity 32} 33 34func (m *mockDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) { 35 return m.ident, nil 36} 37 38func (m *mockDirectory) LookupHandle(ctx context.Context, handle syntax.Handle) (*identity.Identity, error) { 39 return m.ident, nil 40} 41 42func (m *mockDirectory) Lookup(ctx context.Context, id syntax.AtIdentifier) (*identity.Identity, error) { 43 return m.ident, nil 44} 45 46func (m *mockDirectory) Purge(ctx context.Context, id syntax.AtIdentifier) error { 47 return nil 48} 49 50func mockRepoVerifier(res repoverify.Result) repoverify.Verifier { 51 return func(ctx context.Context, repoDid repoident.RepoDid) (repoverify.Result, error) { 52 return res, nil 53 } 54} 55 56func TestProcessRepo_MembershipChange(t *testing.T) { 57 member := syntax.DID("did:example:foo") 58 59 var ok bool 60 var err error 61 d, _ := newTestSpindleDB(t) 62 63 ok, err = d.IsAllowedMember(t.Context(), member, false) 64 assert.NoError(t, err) 65 assert.False(t, ok) 66 67 ok, err = d.IsAllowedMember(t.Context(), member, true) 68 assert.NoError(t, err) 69 assert.True(t, ok) 70 71 assert.NoError(t, d.AllowMember(t.Context(), member)) 72 73 ok, err = d.IsAllowedMember(t.Context(), member, false) 74 assert.NoError(t, err) 75 assert.True(t, ok) 76 77 ok, err = d.IsAllowedMember(t.Context(), member, true) 78 assert.NoError(t, err) 79 assert.True(t, ok) 80} 81 82func TestProcessRepo_MembershipCheck(t *testing.T) { 83 d, e := newTestSpindleDB(t) 84 85 cfg := &config.Config{} 86 cfg.Server.Hostname = "spindle.test" 87 cfg.Server.InviteOnly = true 88 89 ccfg := eventconsumer.NewConsumerConfig() 90 ccfg.Logger = slog.Default() 91 ks := eventconsumer.NewConsumer(*ccfg) 92 93 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 94 if jcerr != nil { 95 t.Fatalf("NewJetstreamClient: %v", jcerr) 96 } 97 s := &Spindle{ 98 db: d, 99 e: e, 100 l: slog.Default(), 101 cfg: cfg, 102 ks: ks, 103 jc: jc, 104 rootCtx: context.Background(), 105 verify: mockRepoVerifier(repoverify.Result{ 106 RepoDid: "did:plc:testrepo123", 107 OwnerDid: "did:plc:memberowner", 108 Rkey: "test-repo-rkey", 109 KnotURL: func() *url.URL { 110 u, err := url.Parse("https://knot.test") 111 assert.NoError(t, err) 112 return u 113 }(), 114 }), 115 } 116 117 tap := &Tap{ 118 spindle: s, 119 logger: slog.Default(), 120 } 121 122 ownerDid := syntax.DID("did:plc:memberowner") 123 nonMemberDid := syntax.DID("did:plc:nonmemberowner") 124 repoDid := syntax.DID("did:plc:testrepo123") 125 126 err := d.AllowMember(t.Context(), ownerDid) 127 if err != nil { 128 t.Fatalf("AddSpindleMember: %v", err) 129 } 130 131 recNonMember := tangled.Repo{ 132 Knot: "knot.test", 133 RepoDid: (*string)(&repoDid), 134 Spindle: &cfg.Server.Hostname, 135 CreatedAt: time.Now().Format(time.RFC3339), 136 } 137 recNonMemberJson, _ := json.Marshal(recNonMember) 138 139 err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 140 Live: true, 141 Did: nonMemberDid, 142 Rkey: "test-repo-rkey", 143 Collection: syntax.NSID(tangled.RepoNSID), 144 Action: tapc.RecordCreateAction, 145 Record: recNonMemberJson, 146 }) 147 if err != nil { 148 t.Fatalf("processRepo returned error for non-member: %v", err) 149 } 150 151 _, err = d.GetRepoByDid(repoDid) 152 if err == nil { 153 t.Fatal("repo for non-member was registered in DB, expected rejection") 154 } 155 156 recMember := tangled.Repo{ 157 Knot: "knot.test", 158 RepoDid: (*string)(&repoDid), 159 Spindle: &cfg.Server.Hostname, 160 CreatedAt: time.Now().Format(time.RFC3339), 161 } 162 recMemberJson, _ := json.Marshal(recMember) 163 164 err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 165 Live: true, 166 Did: ownerDid, 167 Rkey: "test-repo-rkey", 168 Collection: syntax.NSID(tangled.RepoNSID), 169 Action: tapc.RecordCreateAction, 170 Record: recMemberJson, 171 }) 172 if err == nil { 173 t.Fatal("expected git clone error for valid member, but got nil") 174 } 175 176 if !strings.Contains(err.Error(), "setting up sparse-clone git repo") { 177 t.Fatalf("expected sparse-clone error, got: %v", err) 178 } 179} 180 181func TestProcessPull_IsCollaboratorCheck(t *testing.T) { 182 d, e := newTestSpindleDB(t) 183 184 cfg := &config.Config{} 185 cfg.Server.Hostname = "spindle.test" 186 187 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 188 if jcerr != nil { 189 t.Fatalf("NewJetstreamClient: %v", jcerr) 190 } 191 s := &Spindle{ 192 db: d, 193 e: e, 194 l: slog.Default(), 195 cfg: cfg, 196 res: idresolver.DefaultResolver("https://plc.test"), 197 jc: jc, 198 rootCtx: context.Background(), 199 verify: mockRepoVerifier(repoverify.Result{ 200 RepoDid: "did:plc:testrepo123", 201 OwnerDid: "did:plc:repoowner", 202 Rkey: "test-repo-rkey", 203 KnotURL: func() *url.URL { 204 u, _ := url.Parse("knot.test") 205 return u 206 }(), 207 }), 208 } 209 210 repoOwnerDid := syntax.DID("did:plc:repoowner") 211 nonPusherDid := syntax.DID("did:plc:nonpusher") 212 pusherDid := syntax.DID("did:plc:pusher") 213 repoDid := syntax.DID("did:plc:testrepo123") 214 215 err := d.UpsertRepo(db.Repo{ 216 Knot: "knot.test", 217 Owner: repoOwnerDid, 218 Rkey: "test-repo-rkey", 219 RepoDid: repoDid, 220 CreatedAt: time.Now().Format(time.RFC3339), 221 }) 222 if err != nil { 223 t.Fatalf("AddRepo: %v", err) 224 } 225 226 err = e.SetRepoOwner(repoOwnerDid, repoDid) 227 if err != nil { 228 t.Fatalf("AddRepo permissions: %v", err) 229 } 230 err = e.AddRepoCollaborator(pusherDid, repoDid) 231 if err != nil { 232 t.Fatalf("AddCollaborator: %v", err) 233 } 234 235 pullRecord := tangled.RepoPull{ 236 Target: &tangled.RepoPull_Target{ 237 Branch: "main", 238 Repo: repoDid.String(), 239 }, 240 Source: &tangled.RepoPull_Source{ 241 Branch: "feature", 242 Repo: nil, // branch-based PR (source repo is nil) 243 }, 244 } 245 pullRecordJson, _ := json.Marshal(pullRecord) 246 247 err = s.processPull(context.Background(), &tapc.RecordEventData{ 248 Live: true, 249 Did: nonPusherDid, 250 Rkey: "pull-rkey-1", 251 Collection: syntax.NSID(tangled.RepoPullNSID), 252 Action: tapc.RecordCreateAction, 253 Record: pullRecordJson, 254 }) 255 if err != nil { 256 t.Fatalf("processPull returned error for non-pusher: %v", err) 257 } 258 259 // fetch fails because plc/pds are not real 260 err = s.processPull(context.Background(), &tapc.RecordEventData{ 261 Live: true, 262 Did: pusherDid, 263 Rkey: "pull-rkey-2", 264 Collection: syntax.NSID(tangled.RepoPullNSID), 265 Action: tapc.RecordCreateAction, 266 Record: pullRecordJson, 267 }) 268 if err == nil { 269 t.Fatal("expected error from fetchLatestSubmission for valid pusher, but got nil") 270 } 271 272 if !strings.Contains(err.Error(), "checking push access") && !strings.Contains(err.Error(), "resolve PR owner") && !strings.Contains(err.Error(), "invalid memory address") { 273 t.Fatalf("expected failed identity resolution or connection error, got: %v", err) 274 } 275} 276 277func TestProcessRepo_HijackRepoDidCheck(t *testing.T) { 278 d, e := newTestSpindleDB(t) 279 280 cfg := &config.Config{} 281 cfg.Server.Hostname = "spindle.test" 282 283 ccfg := eventconsumer.NewConsumerConfig() 284 ccfg.Logger = slog.Default() 285 ks := eventconsumer.NewConsumer(*ccfg) 286 287 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 288 if jcerr != nil { 289 t.Fatalf("NewJetstreamClient: %v", jcerr) 290 } 291 s := &Spindle{ 292 db: d, 293 e: e, 294 l: slog.Default(), 295 cfg: cfg, 296 ks: ks, 297 jc: jc, 298 rootCtx: context.Background(), 299 verify: mockRepoVerifier(repoverify.Result{ 300 RepoDid: "did:plc:sharedrepo", 301 OwnerDid: "did:plc:alice", 302 Rkey: "alice-repo", 303 KnotURL: func() *url.URL { 304 u, _ := url.Parse("knot.test") 305 return u 306 }(), 307 }), 308 } 309 310 tap := &Tap{ 311 spindle: s, 312 logger: slog.Default(), 313 } 314 315 aliceDid := syntax.DID("did:plc:alice") 316 bobDid := syntax.DID("did:plc:bob") 317 repoDid := syntax.DID("did:plc:sharedrepo") 318 319 err := d.AllowMember(t.Context(), aliceDid) 320 if err != nil { 321 t.Fatalf("AddSpindleMember alice: %v", err) 322 } 323 err = d.AllowMember(t.Context(), bobDid) 324 if err != nil { 325 t.Fatalf("AddSpindleMember bob: %v", err) 326 } 327 328 err = d.UpsertRepo(db.Repo{ 329 Knot: "knot.test", 330 Owner: aliceDid, 331 Rkey: "alice-repo", 332 RepoDid: syntax.DID(repoDid), 333 CreatedAt: time.Now().Format(time.RFC3339), 334 }) 335 if err != nil { 336 t.Fatalf("d.AddRepo: %v", err) 337 } 338 339 if err := e.SetRepoOwner(aliceDid, repoDid); err != nil { 340 t.Fatalf("SetRepoOwner: %v", err) 341 } 342 343 // bob tries to register alice's repo did, must reject the hijack 344 recBob := tangled.Repo{ 345 Knot: "knot.test", 346 RepoDid: (*string)(&repoDid), 347 Spindle: &cfg.Server.Hostname, 348 CreatedAt: time.Now().Format(time.RFC3339), 349 } 350 recBobJson, _ := json.Marshal(recBob) 351 352 err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 353 Live: true, 354 Did: bobDid, 355 Rkey: "bob-repo", 356 Collection: syntax.NSID(tangled.RepoNSID), 357 Action: tapc.RecordCreateAction, 358 Record: recBobJson, 359 }) 360 if err != nil { 361 t.Fatalf("processRepo returned error on duplicate repoDid hijack attempt: %v", err) 362 } 363 364 stored, err := d.GetRepoByDid(repoDid) 365 if err != nil { 366 t.Fatalf("alice's repo row was destroyed by bob's hijack attempt: %v", err) 367 } 368 if stored.Owner != aliceDid || stored.Rkey != "alice-repo" { 369 t.Fatalf("bob hijacked alice's repoDid: owner=%s rkey=%s", stored.Owner, stored.Rkey) 370 } 371 372 // bob points the same repoDid at another spindle, which must not tear alice's repo down 373 otherSpindle := "other.test" 374 recTeardown := tangled.Repo{ 375 Knot: "knot.test", 376 RepoDid: (*string)(&repoDid), 377 Spindle: &otherSpindle, 378 CreatedAt: time.Now().Format(time.RFC3339), 379 } 380 recTeardownJson, _ := json.Marshal(recTeardown) 381 382 err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 383 Live: true, 384 Did: bobDid, 385 Rkey: "bob-repo", 386 Collection: syntax.NSID(tangled.RepoNSID), 387 Action: tapc.RecordUpdateAction, 388 Record: recTeardownJson, 389 }) 390 if err != nil { 391 t.Fatalf("processRepo returned error on forged teardown: %v", err) 392 } 393 394 if _, err := d.GetRepoByDid(repoDid); err != nil { 395 t.Fatalf("bob tore down alice's repo by naming her repoDid: %v", err) 396 } 397 ok, err := e.IsRepoOwner(aliceDid, repoDid) 398 if err != nil || !ok { 399 t.Fatal("bob's forged teardown removed alice's owner policy") 400 } 401} 402 403func TestTeardownRepo_RBAC(t *testing.T) { 404 d, e := newTestSpindleDB(t) 405 406 cfg := &config.Config{} 407 cfg.Server.Hostname = "spindle.test" 408 409 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 410 if jcerr != nil { 411 t.Fatalf("NewJetstreamClient: %v", jcerr) 412 } 413 s := &Spindle{ 414 db: d, 415 e: e, 416 l: slog.Default(), 417 cfg: cfg, 418 jc: jc, 419 rootCtx: context.Background(), 420 verify: mockRepoVerifier(repoverify.Result{ 421 RepoDid: "did:plc:testrepo123", 422 OwnerDid: "did:plc:repoowner", 423 Rkey: "test-repo-rkey", 424 KnotURL: func() *url.URL { 425 u, _ := url.Parse("knot.test") 426 return u 427 }(), 428 }), 429 } 430 431 tap := &Tap{ 432 spindle: s, 433 logger: slog.Default(), 434 } 435 436 ownerDid := syntax.DID("did:plc:repoowner") 437 repoDid := syntax.DID("did:plc:testrepo123") 438 collabDid := syntax.DID("did:plc:collab") 439 440 err := d.UpsertRepo(db.Repo{ 441 Knot: "knot.test", 442 Owner: ownerDid, 443 Rkey: "test-repo-rkey", 444 RepoDid: repoDid, 445 CreatedAt: time.Now().Format(time.RFC3339), 446 }) 447 if err != nil { 448 t.Fatalf("AddRepo DB: %v", err) 449 } 450 451 err = e.SetRepoOwner(ownerDid, repoDid) 452 if err != nil { 453 t.Fatalf("AddRepo policy: %v", err) 454 } 455 456 err = e.AddRepoCollaborator(collabDid, repoDid) 457 if err != nil { 458 t.Fatalf("AddCollaborator policy: %v", err) 459 } 460 461 err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 462 Live: true, 463 Did: ownerDid, 464 Rkey: "test-repo-rkey", 465 Collection: syntax.NSID(tangled.RepoNSID), 466 Action: tapc.RecordDeleteAction, 467 }) 468 if err != nil { 469 t.Fatalf("processRepo delete returned error: %v", err) 470 } 471 472 _, err = d.GetRepoByDid(repoDid) 473 if err == nil { 474 t.Fatal("repo remained in DB after delete") 475 } 476 477 ok, err := e.IsRepoOwner(ownerDid, repoDid) 478 if err != nil || ok { 479 t.Fatal("repo owner policy remained in Casbin after delete") 480 } 481 482 ok, err = e.IsRepoCollaborator(collabDid, repoDid) 483 if err != nil || ok { 484 t.Fatal("collaborator policy remained in Casbin after delete") 485 } 486} 487 488func TestProcessRepo_ForgeDeleteRejection(t *testing.T) { 489 d, e := newTestSpindleDB(t) 490 491 cfg := &config.Config{} 492 cfg.Server.Hostname = "spindle.test" 493 494 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 495 if jcerr != nil { 496 t.Fatalf("NewJetstreamClient: %v", jcerr) 497 } 498 499 s := &Spindle{ 500 db: d, 501 e: e, 502 l: slog.Default(), 503 cfg: cfg, 504 jc: jc, 505 rootCtx: context.Background(), 506 verify: mockRepoVerifier(repoverify.Result{ 507 RepoDid: "did:plc:sharedrepo", 508 OwnerDid: "did:plc:alice", 509 Rkey: "test-repo-rkey", 510 KnotURL: func() *url.URL { 511 u, _ := url.Parse("knot.test") 512 return u 513 }(), 514 }), 515 } 516 517 tap := &Tap{ 518 spindle: s, 519 logger: slog.Default(), 520 } 521 522 aliceDid := syntax.DID("did:plc:alice") 523 bobDid := syntax.DID("did:plc:bob") 524 repoDid := syntax.DID("did:plc:sharedrepo") 525 526 err := d.UpsertRepo(db.Repo{ 527 Knot: "knot.test", 528 Owner: aliceDid, 529 Rkey: "test-repo-rkey", 530 RepoDid: repoDid, 531 CreatedAt: time.Now().Format(time.RFC3339), 532 }) 533 if err != nil { 534 t.Fatalf("AddRepo DB: %v", err) 535 } 536 537 err = e.SetRepoOwner(aliceDid, repoDid) 538 if err != nil { 539 t.Fatalf("AddRepo policy: %v", err) 540 } 541 542 // bob tries to delete alice's repo, must reject forged delete 543 err = tap.processRepo(context.Background(), &tapc.RecordEventData{ 544 Live: true, 545 Did: bobDid, 546 Rkey: "test-repo-rkey", 547 Collection: syntax.NSID(tangled.RepoNSID), 548 Action: tapc.RecordDeleteAction, 549 }) 550 if err != nil { 551 t.Fatalf("processRepo returned error on delete: %v", err) 552 } 553 554 _, err = d.GetRepoByDid(repoDid) 555 if err != nil { 556 t.Fatalf("Alice's repo was deleted or error: %v", err) 557 } 558 559 ok, err := e.IsRepoOwner(aliceDid, repoDid) 560 if err != nil || !ok { 561 t.Fatal("Alice's owner policy was removed from Casbin by forged delete") 562 } 563} 564 565func TestReconcileCollaborators(t *testing.T) { 566 d, e := newTestSpindleDB(t) 567 568 ownerDid := syntax.DID("did:plc:owner") 569 repoDid := syntax.DID("did:plc:repo") 570 staleDid := syntax.DID("did:plc:stale") 571 keptDid := syntax.DID("did:plc:kept") 572 newDid := syntax.DID("did:plc:new") 573 574 if err := e.SetRepoOwner(ownerDid, repoDid); err != nil { 575 t.Fatalf("SetRepoOwner: %v", err) 576 } 577 // spindle's view: one collaborator the knot dropped, one it still has 578 for _, did := range []syntax.DID{staleDid, keptDid} { 579 if err := e.AddRepoCollaborator(did, repoDid); err != nil { 580 t.Fatalf("AddRepoCollaborator(%s): %v", did, err) 581 } 582 } 583 584 // the knot's view: keptDid and a collaborator spindle never saw. Paginated, and it lists 585 // the owner too - reconcile must not treat that as an explicit grant to remove. 586 pages := [][]string{ 587 {ownerDid.String(), keptDid.String()}, 588 {newDid.String()}, 589 } 590 var gotSubject string 591 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 592 if r.URL.Path != "/xrpc/"+tangled.RepoListCollaboratorsNSID { 593 http.NotFound(w, r) 594 return 595 } 596 gotSubject = r.URL.Query().Get("subject") 597 page := 0 598 if c := r.URL.Query().Get("cursor"); c != "" { 599 page = 1 600 } 601 out := tangled.RepoListCollaborators_Output{} 602 for _, s := range pages[page] { 603 out.Items = append(out.Items, &tangled.RepoListCollaborators_ListItem{Subject: s}) 604 } 605 if page == 0 { 606 next := "page2" 607 out.Cursor = &next 608 } 609 json.NewEncoder(w).Encode(out) 610 })) 611 defer srv.Close() 612 613 cfg := &config.Config{} 614 cfg.Server.Dev = true // http, not https 615 jc, jcerr := jetstream.NewJetstreamClient("", "", nil, nil, slog.Default(), nil, false, false) 616 if jcerr != nil { 617 t.Fatalf("NewJetstreamClient: %v", jcerr) 618 } 619 tap := &Tap{ 620 spindle: &Spindle{db: d, e: e, l: slog.Default(), cfg: cfg, jc: jc}, 621 logger: slog.Default(), 622 } 623 624 knot := strings.TrimPrefix(srv.URL, "http://") 625 tap.reconcileCollaborators(context.Background(), slog.Default(), knot, repoDid, ownerDid) 626 627 if gotSubject != repoDid.String() { 628 t.Errorf("knot queried with subject %q, want %q", gotSubject, repoDid) 629 } 630 631 for _, tc := range []struct { 632 did syntax.DID 633 want bool 634 }{ 635 {ownerDid, true}, // owner keeps access via role inheritance 636 {keptDid, true}, // still on the knot 637 {newDid, true}, // added from the knot's roster 638 {staleDid, false}, // removed on the knot while spindle was down 639 } { 640 ok, err := e.IsRepoCollaborator(tc.did, repoDid) 641 if err != nil { 642 t.Fatalf("IsRepoCollaborator(%s): %v", tc.did, err) 643 } 644 if ok != tc.want { 645 t.Errorf("IsRepoCollaborator(%s) = %v, want %v", tc.did, ok, tc.want) 646 } 647 } 648} 649 650func TestReconcileCollaboratorsKeepsGrantsOnFetchFailure(t *testing.T) { 651 d, e := newTestSpindleDB(t) 652 653 ownerDid := syntax.DID("did:plc:owner") 654 repoDid := syntax.DID("did:plc:repo") 655 collabDid := syntax.DID("did:plc:collab") 656 657 if err := e.SetRepoOwner(ownerDid, repoDid); err != nil { 658 t.Fatalf("SetRepoOwner: %v", err) 659 } 660 if err := e.AddRepoCollaborator(collabDid, repoDid); err != nil { 661 t.Fatalf("AddRepoCollaborator: %v", err) 662 } 663 664 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 665 http.Error(w, "boom", http.StatusInternalServerError) 666 })) 667 defer srv.Close() 668 669 cfg := &config.Config{} 670 cfg.Server.Dev = true 671 tap := &Tap{ 672 spindle: &Spindle{db: d, e: e, l: slog.Default(), cfg: cfg}, 673 logger: slog.Default(), 674 } 675 676 tap.reconcileCollaborators(context.Background(), slog.Default(), 677 strings.TrimPrefix(srv.URL, "http://"), repoDid, ownerDid) 678 679 ok, err := e.IsRepoCollaborator(collabDid, repoDid) 680 if err != nil { 681 t.Fatalf("IsRepoCollaborator: %v", err) 682 } 683 if !ok { 684 t.Error("an unreachable knot wiped the collaborator roster") 685 } 686}