This repository has no description
50 kB
1612 lines
1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use knot_cob::{ChangePayload, CobHome, CobId, CobStore};
5use knot_cobs::{CollaboratorsChange, MembersChange, RegistryChange, Removal, Rename, RepoRef};
6use knot_git::{RefUpdate, Repo};
7use knot_index::{
8 Coverage, HostedCoverage, IndexError, KeptAccounts, KeyBudget, KeyRecord, KeyReprieve,
9 KeyReprieved, KeyTtl, OfferedKey, Resolved, SweepFloor,
10};
11use knot_types::{ClonePath, RefName, RepoName};
12use serde::{Deserialize, Serialize};
13
14mod common;
15use common::{
16 World, acc, at, grant, meta_home, named_registration, own, registration, repo_did, rkey,
17};
18
19#[derive(Serialize, Deserialize)]
20#[serde(tag = "op", content = "data", rename_all = "snake_case")]
21enum BadMembers {
22 Explode(u8),
23}
24impl ChangePayload for BadMembers {
25 const TYPE: &'static str = "sh.tangled.knot.member";
26}
27
28#[test]
29fn a_concurrent_reader_never_sees_a_net_absent_subject() {
30 let world = World::new();
31 let object = world.seed_members();
32 let index = Arc::new(world.index());
33 index.rebuild().unwrap();
34 assert_eq!(index.is_member(&acc("teq")), Resolved::Ready(false));
35
36 let meta = Repo::open(&world.meta_path).unwrap();
37 let store = CobStore::new(&meta);
38 store
39 .update(
40 &meta_home(),
41 object,
42 &MembersChange::Add(grant("teq", "nel", 10)),
43 &world.signer,
44 at(10),
45 )
46 .unwrap();
47 (0..1000).for_each(|i| {
48 store
49 .update(
50 &meta_home(),
51 object,
52 &MembersChange::Add(grant(&format!("f{i}"), "nel", 11 + i)),
53 &world.signer,
54 at(11 + i),
55 )
56 .unwrap();
57 });
58 store
59 .update(
60 &meta_home(),
61 object,
62 &MembersChange::Remove(Removal {
63 subject: acc("teq"),
64 }),
65 &world.signer,
66 at(20_000),
67 )
68 .unwrap();
69
70 let done = Arc::new(AtomicBool::new(false));
71 let leaked = Arc::new(AtomicBool::new(false));
72 std::thread::scope(|scope| {
73 let reader = Arc::clone(&index);
74 let reader_done = Arc::clone(&done);
75 let reader_leaked = Arc::clone(&leaked);
76 scope.spawn(move || {
77 while !reader_done.load(Ordering::Acquire) {
78 if reader.is_member(&acc("teq")) == Resolved::Ready(true) {
79 reader_leaked.store(true, Ordering::Release);
80 }
81 }
82 });
83 index.refresh_members().unwrap();
84 done.store(true, Ordering::Release);
85 });
86
87 assert!(
88 !leaked.load(Ordering::Acquire),
89 "net-absent subject is never written, so no reader can observe it mid-delta"
90 );
91 assert_eq!(index.is_member(&acc("teq")), Resolved::Ready(false));
92 assert_eq!(index.is_member(&acc("f0")), Resolved::Ready(true));
93 assert_eq!(index.is_member(&acc("f999")), Resolved::Ready(true));
94}
95
96#[test]
97fn a_concurrent_reader_never_sees_a_collaborator_roster_emptied_mid_refresh() {
98 let world = World::new();
99 let repo = repo_did("squid");
100 let git = world.layout.create(&repo).unwrap();
101 let store = CobStore::new(&git);
102 let object = store
103 .create(
104 &CobHome::from(&repo),
105 &CollaboratorsChange::Add(grant("anchor", "nel", 1)),
106 &world.signer,
107 at(1),
108 )
109 .unwrap()
110 .object;
111 (0..128).for_each(|i| {
112 store
113 .update(
114 &CobHome::from(&repo),
115 object,
116 &CollaboratorsChange::Add(grant(&format!("c{i}"), "nel", 2 + i)),
117 &world.signer,
118 at(2 + i),
119 )
120 .unwrap();
121 });
122
123 let index = Arc::new(world.index());
124 index.rebuild().unwrap();
125 index.ensure_collaborators(&repo).unwrap();
126 assert_eq!(
127 index.is_collaborator(&repo, &acc("anchor")),
128 Resolved::Ready(true)
129 );
130
131 let done = Arc::new(AtomicBool::new(false));
132 let leaked = Arc::new(AtomicBool::new(false));
133 std::thread::scope(|scope| {
134 let reader = Arc::clone(&index);
135 let reader_done = Arc::clone(&done);
136 let reader_leaked = Arc::clone(&leaked);
137 let target = repo.clone();
138 scope.spawn(move || {
139 while !reader_done.load(Ordering::Acquire) {
140 if reader.is_collaborator(&target, &acc("anchor")) != Resolved::Ready(true) {
141 reader_leaked.store(true, Ordering::Release);
142 }
143 }
144 });
145 (0..500).for_each(|_| index.refresh_collaborators(&repo).unwrap());
146 done.store(true, Ordering::Release);
147 });
148
149 assert!(
150 !leaked.load(Ordering::Acquire),
151 "in-place mem::take runs under per-repo lock, so anchor present in both \
152 pre- and post-refresh roster is never observed absent or warming mid-refresh"
153 );
154 assert_eq!(
155 index.is_collaborator(&repo, &acc("anchor")),
156 Resolved::Ready(true)
157 );
158}
159
160fn cob_ref(type_name: &str, object: CobId) -> RefName {
161 RefName::new(format!("refs/cobs/{type_name}/{}", object.oid())).unwrap()
162}
163
164#[test]
165fn a_diverged_collaborators_tip_purges_the_roster_instead_of_serving_it_stale() {
166 let world = World::new();
167 let repo = repo_did("squid");
168 let git = world.layout.create(&repo).unwrap();
169 let store = CobStore::new(&git);
170 let created = store
171 .create(
172 &CobHome::from(&repo),
173 &CollaboratorsChange::Add(grant("lyna", "nel", 1)),
174 &world.signer,
175 at(1),
176 )
177 .unwrap();
178 let tip = store
179 .update(
180 &CobHome::from(&repo),
181 created.object,
182 &CollaboratorsChange::Add(grant("bailey", "nel", 2)),
183 &world.signer,
184 at(2),
185 )
186 .unwrap();
187
188 let index = world.index();
189 index.rebuild().unwrap();
190 index.ensure_collaborators(&repo).unwrap();
191 assert_eq!(
192 index.is_collaborator(&repo, &acc("lyna")),
193 Resolved::Ready(true)
194 );
195
196 git.update_ref(&RefUpdate::Update {
197 name: cob_ref(CollaboratorsChange::TYPE, created.object),
198 old: tip.oid(),
199 new: created.object.oid(),
200 })
201 .unwrap();
202
203 assert!(
204 index.refresh_collaborators(&repo).is_err(),
205 "tip that no longer descends from folded tip is structural error"
206 );
207 assert_eq!(
208 index.is_collaborator(&repo, &acc("lyna")),
209 Resolved::Warming,
210 "diverged COB tip purges roster and fails closed, it does not serve \
211 pre-divergence collaborators"
212 );
213}
214
215#[test]
216fn a_diverged_members_tip_fails_closed_to_warming() {
217 let world = World::new();
218 let object = world.seed_members();
219 let meta = Repo::open(&world.meta_path).unwrap();
220 let store = CobStore::new(&meta);
221 let tip = store
222 .update(
223 &meta_home(),
224 object,
225 &MembersChange::Add(grant("teq", "nel", 3)),
226 &world.signer,
227 at(3),
228 )
229 .unwrap();
230
231 let index = world.index();
232 index.rebuild().unwrap();
233 assert_eq!(index.is_member(&acc("nel")), Resolved::Ready(true));
234
235 meta.update_ref(&RefUpdate::Update {
236 name: cob_ref(MembersChange::TYPE, object),
237 old: tip.oid(),
238 new: object.oid(),
239 })
240 .unwrap();
241
242 assert!(
243 index.refresh_members().is_err(),
244 "tip that no longer descends from folded tip is structural error"
245 );
246 assert_eq!(index.coverage().members, Coverage::Warming);
247 assert_eq!(
248 index.is_member(&acc("nel")),
249 Resolved::Warming,
250 "diverged members COB fails projection closed instead of serving stale members"
251 );
252}
253
254#[test]
255fn an_undecodable_change_fails_closed_with_no_partial_apply() {
256 let world = World::new();
257 let object = world.seed_members();
258 let index = world.index();
259 index.rebuild().unwrap();
260
261 let meta = Repo::open(&world.meta_path).unwrap();
262 let store = CobStore::new(&meta);
263 store
264 .update(
265 &meta_home(),
266 object,
267 &MembersChange::Add(grant("teq", "nel", 3)),
268 &world.signer,
269 at(3),
270 )
271 .unwrap();
272 store
273 .update(
274 &meta_home(),
275 object,
276 &BadMembers::Explode(0),
277 &world.signer,
278 at(4),
279 )
280 .unwrap();
281 store
282 .update(
283 &meta_home(),
284 object,
285 &MembersChange::Remove(Removal {
286 subject: acc("teq"),
287 }),
288 &world.signer,
289 at(5),
290 )
291 .unwrap();
292
293 assert!(matches!(
294 index.refresh_members(),
295 Err(IndexError::Decode { .. })
296 ));
297
298 assert_eq!(index.coverage().members, Coverage::Warming);
299 assert_eq!(
300 index.is_member(&acc("teq")),
301 Resolved::Warming,
302 "no partial apply: teq from pre-error change was never committed"
303 );
304 assert_eq!(
305 index.is_member(&acc("nel")),
306 Resolved::Warming,
307 "structurally broken COB fails whole projection closed"
308 );
309
310 assert!(matches!(
311 index.refresh_members(),
312 Err(IndexError::Decode { .. })
313 ));
314 assert_eq!(index.coverage().members, Coverage::Warming);
315}
316
317#[test]
318fn deregister_purges_collaborators_fail_closed() {
319 let world = World::new();
320 let repo = repo_did("clam");
321
322 let meta = Repo::open(&world.meta_path).unwrap();
323 let store = CobStore::new(&meta);
324 let registry = store
325 .create(
326 &meta_home(),
327 &RegistryChange::Register(registration("nel", "anemone", &repo, 1)),
328 &world.signer,
329 at(1),
330 )
331 .unwrap()
332 .object;
333
334 let git = world.layout.create(&repo).unwrap();
335 let cstore = CobStore::new(&git);
336 cstore
337 .create(
338 &CobHome::from(&repo),
339 &CollaboratorsChange::Add(grant("lyna", "nel", 1)),
340 &world.signer,
341 at(1),
342 )
343 .unwrap();
344
345 let index = world.index();
346 index.rebuild().unwrap();
347 index.warm_collaborators();
348 assert_eq!(
349 index.is_collaborator(&repo, &acc("lyna")),
350 Resolved::Ready(true)
351 );
352
353 store
354 .update(
355 &meta_home(),
356 registry,
357 &RegistryChange::Deregister(RepoRef {
358 owner: own("nel"),
359 rkey: rkey("anemone"),
360 }),
361 &world.signer,
362 at(2),
363 )
364 .unwrap();
365 index.refresh_registry().unwrap();
366
367 assert_eq!(
368 index.resolve_repo(&own("nel"), &rkey("anemone")),
369 Resolved::Ready(None)
370 );
371 assert_eq!(
372 index.is_collaborator(&repo, &acc("lyna")),
373 Resolved::Warming,
374 "deregistered repo's collaborators are purged and fail closed, not served stale"
375 );
376}
377
378#[test]
379fn a_renamed_repo_keeps_both_rkeys_and_its_collaborators() {
380 let world = World::new();
381 let repo = repo_did("squid");
382
383 let meta = Repo::open(&world.meta_path).unwrap();
384 let store = CobStore::new(&meta);
385 let registry = store
386 .create(
387 &meta_home(),
388 &RegistryChange::Register(registration("nel", "anemone", &repo, 1)),
389 &world.signer,
390 at(1),
391 )
392 .unwrap()
393 .object;
394
395 let git = world.layout.create(&repo).unwrap();
396 CobStore::new(&git)
397 .create(
398 &CobHome::from(&repo),
399 &CollaboratorsChange::Add(grant("lyna", "nel", 1)),
400 &world.signer,
401 at(1),
402 )
403 .unwrap();
404
405 let index = world.index();
406 index.rebuild().unwrap();
407 index.warm_collaborators();
408 assert_eq!(index.rkey_of(&repo), Resolved::Ready(Some(rkey("anemone"))));
409
410 store
411 .update(
412 &meta_home(),
413 registry,
414 &RegistryChange::Rename(Rename {
415 owner: own("nel"),
416 rkey: rkey("barnacle"),
417 name: RepoName::new("barnacle").unwrap(),
418 repo: repo.clone(),
419 }),
420 &world.signer,
421 at(2),
422 )
423 .unwrap();
424 index.refresh_registry().unwrap();
425
426 assert_eq!(
427 index.resolve_repo(&own("nel"), &rkey("anemone")),
428 Resolved::Ready(Some(repo.clone())),
429 "prior rkey keeps resolving as alias after rename is delta-applied"
430 );
431 assert_eq!(
432 index.resolve_repo(&own("nel"), &rkey("barnacle")),
433 Resolved::Ready(Some(repo.clone()))
434 );
435 assert_eq!(
436 index.rkey_of(&repo),
437 Resolved::Ready(Some(rkey("barnacle"))),
438 "new rkey is canonical"
439 );
440 assert_eq!(
441 index.is_collaborator(&repo, &acc("lyna")),
442 Resolved::Ready(true),
443 "rename never evacuates repo, so its collaborators survive"
444 );
445
446 store
447 .update(
448 &meta_home(),
449 registry,
450 &RegistryChange::Deregister(RepoRef {
451 owner: own("nel"),
452 rkey: rkey("anemone"),
453 }),
454 &world.signer,
455 at(3),
456 )
457 .unwrap();
458 index.refresh_registry().unwrap();
459 assert_eq!(
460 index.resolve_repo(&own("nel"), &rkey("barnacle")),
461 Resolved::Ready(None),
462 "deregistering through retained alias removes repo and every alias"
463 );
464 assert_eq!(
465 index.is_collaborator(&repo, &acc("lyna")),
466 Resolved::Warming,
467 "deregistered repo's collaborators are evacuated"
468 );
469}
470
471#[test]
472fn a_repo_moved_within_one_delta_is_not_evacuated() {
473 let world = World::new();
474 let repo = repo_did("squid");
475
476 let meta = Repo::open(&world.meta_path).unwrap();
477 let store = CobStore::new(&meta);
478 let registry = store
479 .create(
480 &meta_home(),
481 &RegistryChange::Register(registration("nel", "anemone", &repo, 1)),
482 &world.signer,
483 at(1),
484 )
485 .unwrap()
486 .object;
487
488 let git = world.layout.create(&repo).unwrap();
489 CobStore::new(&git)
490 .create(
491 &CobHome::from(&repo),
492 &CollaboratorsChange::Add(grant("lyna", "nel", 1)),
493 &world.signer,
494 at(1),
495 )
496 .unwrap();
497
498 let index = world.index();
499 index.rebuild().unwrap();
500 index.warm_collaborators();
501 assert_eq!(
502 index.is_collaborator(&repo, &acc("lyna")),
503 Resolved::Ready(true)
504 );
505
506 store
507 .update(
508 &meta_home(),
509 registry,
510 &RegistryChange::Deregister(RepoRef {
511 owner: own("nel"),
512 rkey: rkey("anemone"),
513 }),
514 &world.signer,
515 at(2),
516 )
517 .unwrap();
518 store
519 .update(
520 &meta_home(),
521 registry,
522 &RegistryChange::Register(registration("nel", "barnacle", &repo, 3)),
523 &world.signer,
524 at(3),
525 )
526 .unwrap();
527 index.refresh_registry().unwrap();
528
529 assert_eq!(
530 index.resolve_repo(&own("nel"), &rkey("anemone")),
531 Resolved::Ready(None)
532 );
533 assert_eq!(
534 index.resolve_repo(&own("nel"), &rkey("barnacle")),
535 Resolved::Ready(Some(repo.clone()))
536 );
537 assert_eq!(
538 index.is_collaborator(&repo, &acc("lyna")),
539 Resolved::Ready(true),
540 "deregister and re-register within single delta leaves repo hosted, so its collaborators survive"
541 );
542}
543
544#[test]
545fn recording_and_retaining_track_who_still_publishes_each_key() {
546 let world = World::new();
547 let index = world.index();
548 let key = |i: u32| OfferedKey::from_bytes(i.to_le_bytes().to_vec());
549
550 index
551 .keys()
552 .record(&acc("nel"), vec![key(0), key(1)], hour(0));
553 index
554 .keys()
555 .record(&acc("nel"), vec![key(1), key(2)], hour(0));
556 assert_eq!(
557 index.owner_of_key(&key(0), at(0)),
558 Resolved::Ready(None),
559 "a key the account stopped publishing stops resolving to it"
560 );
561 assert_eq!(
562 index.owner_of_key(&key(1), at(0)),
563 Resolved::Ready(Some(acc("nel"))),
564 "a key present in both sets survives the replacement"
565 );
566
567 index
568 .keys()
569 .record(&acc("cuttle"), vec![key(1), key(3)], hour(0));
570 assert_eq!(
571 index
572 .keys()
573 .publisher_among(&[acc("cuttle")], &key(1), at(0)),
574 Some(acc("cuttle")),
575 "a deploy key two accounts publish must answer for the candidate that may push here, \
576 whichever of them the knot happened to read first"
577 );
578 assert_eq!(
579 index.keys().publisher_among(&[acc("teq")], &key(1), at(0)),
580 None,
581 "the key mustn't answer for an account that doesn't publish it"
582 );
583
584 index.keys().retain(&KeptAccounts::new(vec![acc("nel")]));
585 assert_eq!(
586 index.owner_of_key(&key(3), at(0)),
587 Resolved::Ready(None),
588 "an account outside the grant set has its keys released"
589 );
590 assert_eq!(
591 index.owner_of_key(&key(1), at(0)),
592 Resolved::Ready(Some(acc("nel"))),
593 "one account leaving the grant set mustn't take a shared deploy key away from the \
594 account that still publishes it"
595 );
596
597 index.keys().record(&acc("nel"), vec![key(2)], hour(0));
598 assert_eq!(
599 index.owner_of_key(&key(1), at(0)),
600 Resolved::Ready(None),
601 "with its last publisher no longer offering it, the key stops answering for anybody"
602 );
603}
604
605#[test]
606fn a_full_budget_reports_unheld_readings_and_then_refuses_to_grow_at_all() {
607 let world = World::new();
608 let index = world.index_within(KeyBudget::from_bytes(560));
609 let fat = |seed: u8| OfferedKey::from_bytes(vec![seed; 48]);
610
611 assert_eq!(
612 index.keys().record(&acc("nel"), vec![fat(1)], hour(0)),
613 KeyRecord::Stored
614 );
615 assert!(
616 !index.keys().any_unheld(),
617 "a set that kept every reading it took can vouch for its misses"
618 );
619
620 assert_eq!(
621 index.keys().record(&acc("cuttle"), vec![fat(2)], hour(0)),
622 KeyRecord::Unheld,
623 "a budget too small for another account's keys still has room to record that the \
624 knot read the account"
625 );
626 assert!(
627 index.keys().any_unheld(),
628 "a reading the budget couldn't fit means an unknown key may still belong to a \
629 candidate, so the handshake must defer to the push check instead of refusing"
630 );
631 assert!(
632 !index.keys().is_fresh(&acc("cuttle"), at(0)),
633 "the refused account stays stale, so the fill keeps reporting the set incomplete and \
634 the knot keeps checking its pushes against its PDS"
635 );
636 assert_eq!(
637 index.owner_of_key(&fat(2), at(0)),
638 Resolved::Ready(None),
639 "a key the budget refused mustn't answer for anybody"
640 );
641
642 assert_eq!(
643 index.keys().record(&acc("teq"), vec![fat(3)], hour(0)),
644 KeyRecord::Saturated,
645 "a knot whose grant set publishes more key bytes than it budgeted for must stop growing"
646 );
647 assert_eq!(
648 index.owner_of_key(&fat(1), at(0)),
649 Resolved::Ready(Some(acc("nel"))),
650 "refusing the accounts that didn't fit mustn't release the accounts already on file"
651 );
652
653 index.keys().retain(&KeptAccounts::new(vec![acc("cuttle")]));
654 assert!(
655 index.keys().any_unheld(),
656 "releasing a different account mustn't clear the report while the unheld reading stays"
657 );
658 assert_eq!(
659 index.keys().record(&acc("cuttle"), vec![fat(2)], hour(0)),
660 KeyRecord::Stored,
661 "the budget frees up with the accounts that leave the grant set"
662 );
663 assert!(
664 !index.keys().any_unheld(),
665 "with every reading back on file the handshake can refuse unknown keys outright"
666 );
667
668 assert_eq!(
669 index.keys().record(&acc("teq"), vec![fat(3)], hour(0)),
670 KeyRecord::Unheld
671 );
672 assert_eq!(
673 index
674 .keys()
675 .reprieve(&acc("teq"), at(21_601), grace(), hour(21_601)),
676 KeyReprieved::Exhausted
677 );
678 assert!(
679 !index.keys().any_unheld(),
680 "an account the knot gave up rereading is recorded with an empty key set, so its \
681 unheld reading mustn't keep the handshake open"
682 );
683}
684
685#[test]
686fn a_pass_renews_at_the_lease_halfway_and_rereads_once_per_miss_outside_the_floor() {
687 let (_world, index) = folded();
688 index
689 .keys()
690 .record(&acc("nel"), vec![OfferedKey::from_bytes(vec![7])], hour(0));
691
692 assert!(
693 matches!(index.keys().work(at(0), sweep()), Resolved::Ready(work)
694 if work.due.is_empty() && work.suspected.is_empty()),
695 "with every account's keys read inside the ttl the fill won't fetch anything"
696 );
697
698 index.keys().note_miss();
699 let Resolved::Ready(work) = index.keys().work(at(100), sweep()) else {
700 panic!("the registry is folded, so the knot knows who may push");
701 };
702 assert_eq!(
703 work.suspected.len(),
704 1,
705 "a key the accounts on file don't publish is the only evidence the knot gets that an \
706 account published a new key, so the next pass must reread rather than wait out the ttl"
707 );
708 assert!(
709 work.due.is_empty(),
710 "a reread a stranger asked for must stay separate from the reads coverage waits on, or \
711 the knot spends a whole pass on it without renewing anything"
712 );
713 assert!(
714 matches!(index.keys().work(at(100), sweep()), Resolved::Ready(work) if work.suspected.is_empty()),
715 "one miss is worth one reread, so a stranger offering keys can't set the fill's pace"
716 );
717
718 index.keys().note_miss();
719 assert!(
720 matches!(index.keys().work(at(159), sweep()), Resolved::Ready(work) if work.suspected.is_empty()),
721 "a stranger can offer an unsigned key for free, so a knot that swept a moment ago \
722 mustn't spend another read at every PDS in the grant set"
723 );
724 assert!(
725 matches!(index.keys().work(at(160), sweep()), Resolved::Ready(work) if work.suspected.len() == 1),
726 "the miss the floor delayed is honored once the floor has passed, so a key published \
727 between passes is still picked up"
728 );
729
730 assert!(
731 matches!(index.keys().work(at(1_799), sweep()), Resolved::Ready(work) if work.due.is_empty()),
732 "an account inside the first half of its lease is left alone"
733 );
734 let Resolved::Ready(work) = index.keys().work(at(1_800), sweep()) else {
735 panic!("the registry is folded, so the knot knows who may push");
736 };
737 assert_eq!(
738 work.due.len(),
739 1,
740 "the fill renews at the halfway mark, so a pass no longer than the lease it is working \
741 to finishes before anything expires"
742 );
743 assert!(
744 work.complete,
745 "the lease the account still has answers for it, so a knot busy renewing keeps \
746 refusing keys the accounts on file don't publish instead of opening the handshake \
747 to every stranger"
748 );
749}
750
751#[test]
752fn an_unfolded_repo_keeps_the_pass_partial_and_an_unopenable_repo_never_grants() {
753 let world = World::new();
754 let folded = repo_did("squid");
755 let never_created = repo_did("limpet");
756 world.layout.create(&folded).unwrap();
757 let registry = world.seed_registry(&folded);
758 world.register_owned(&never_created, "limpet", "cuttle", registry);
759 let index = world.index();
760 index.refresh_registry().unwrap();
761 index.refresh_collaborators(&folded).unwrap();
762
763 let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else {
764 panic!("one repo the knot hasn't folded mustn't stop it filling the keys it can enumerate");
765 };
766 assert_eq!(
767 work.hosted,
768 HostedCoverage::Partial { unread: 1 },
769 "the pass must know it read less than the whole roll, or it evicts the accounts it \
770 couldn't enumerate"
771 );
772 assert!(
773 !work.due.is_empty(),
774 "the owner of the repo that did fold still needs its keys read"
775 );
776 assert!(
777 !work.complete,
778 "a partial roll mustn't let the fill call the set complete, or the knot refuses a \
779 pusher whose repo it never managed to read"
780 );
781
782 assert_eq!(
783 index.warm_collaborators(),
784 1,
785 "the repo registered without ever being created is what the knot can't open"
786 );
787 let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else {
788 panic!("the registry is folded, so the knot knows who may push");
789 };
790 assert_eq!(
791 work.hosted,
792 HostedCoverage::Whole,
793 "a repo the knot can't open won't serve a push, so counting it as unread would leave \
794 the set warming and the handshake open to every stranger for good"
795 );
796 assert_eq!(
797 work.pushers.as_slice(),
798 [acc("nel")],
799 "only the repo the knot can open grants anybody"
800 );
801
802 index
803 .keys()
804 .record(&acc("nel"), vec![OfferedKey::from_bytes(vec![7])], hour(0));
805 assert!(
806 pushers_complete(&index, at(0)),
807 "with the one readable repo's owner on file the set is complete, so the knot can go \
808 back to refusing keys the accounts on file don't publish"
809 );
810}
811
812#[test]
813fn an_acl_write_puts_the_key_set_back_to_warming_even_mid_pass() {
814 let (world, index) = folded();
815 index
816 .keys()
817 .record(&acc("nel"), vec![OfferedKey::from_bytes(vec![7])], hour(0));
818 let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else {
819 panic!("the registry is folded, so the knot knows who may push");
820 };
821 assert!(work.complete, "the one pusher's keys are on file");
822 index.keys().mark_ready(work.generation);
823 assert_eq!(index.keys().coverage(), Coverage::Ready);
824
825 let roll = world.seed_members();
826 index.refresh_members().unwrap();
827 assert_eq!(
828 index.keys().coverage(),
829 Coverage::Warming,
830 "the knot mustn't check a grant it has never read keys for against a set it calls \
831 complete, or a new collaborator is refused until the next fill pass"
832 );
833
834 let Resolved::Ready(rereading) = index.keys().work(at(0), sweep()) else {
835 panic!("the registry is folded, so the knot knows who may push");
836 };
837 world.add_member(roll, "teq", 5);
838 index.refresh_members().unwrap();
839 index.keys().mark_ready(rereading.generation);
840 assert_eq!(
841 index.keys().coverage(),
842 Coverage::Warming,
843 "a pass claims the grant set it read, so a grant written while it was reading keys \
844 mustn't count as covered, or whoever it grants is refused at the handshake"
845 );
846}
847
848#[test]
849fn an_account_the_budget_cant_fit_is_checked_against_its_pds_without_delaying_coverage() {
850 let world = World::new();
851 let folded = repo_did("squid");
852 let second = repo_did("limpet");
853 world.layout.create(&folded).unwrap();
854 world.layout.create(&second).unwrap();
855 let registry = world.seed_registry(&folded);
856 world.register_owned(&second, "limpet", "cuttle", registry);
857 let index = world.index_within(KeyBudget::from_bytes(560));
858 index.refresh_registry().unwrap();
859 index.warm_collaborators();
860 let fat = |seed: u8| OfferedKey::from_bytes(vec![seed; 48]);
861
862 assert_eq!(
863 index.keys().record(&acc("nel"), vec![fat(1)], hour(0)),
864 KeyRecord::Stored
865 );
866 assert_eq!(
867 index.keys().record(&acc("cuttle"), vec![fat(2)], hour(0)),
868 KeyRecord::Unheld
869 );
870 assert!(
871 pushers_complete(&index, at(0)),
872 "a full budget mustn't leave the set warming for good, or the knot spends the rest of \
873 its life accepting every key offered to it"
874 );
875 let Resolved::Ready(later) = index.keys().work(at(3_601), sweep()) else {
876 panic!("the registry is folded, so the knot knows who may push");
877 };
878 assert!(
879 later.due.as_slice().contains(&acc("cuttle")),
880 "the knot tries the account again once the lease runs out, so a budget that frees up \
881 starts keeping its keys. Every pass until then costs one read rather than a reread \
882 of the whole grant set"
883 );
884}
885
886#[test]
887fn a_key_read_outside_its_lease_stops_answering_for_its_publisher() {
888 let world = World::new();
889 let index = world.index();
890 let key = OfferedKey::from_bytes(vec![7]);
891 index.keys().record(&acc("nel"), vec![key.clone()], hour(0));
892
893 assert_eq!(
894 index.owner_of_key(&key, at(3_599)),
895 Resolved::Ready(Some(acc("nel"))),
896 "inside the lease the set answers for the account that published the key"
897 );
898 assert_eq!(
899 index.keys().publisher_among(&[acc("nel")], &key, at(3_599)),
900 Some(acc("nel"))
901 );
902 assert_eq!(
903 index.owner_of_key(&key, at(3_601)),
904 Resolved::Ready(None),
905 "past the lease the knot no longer knows the account publishes the key, so a key \
906 revoked while the fill was behind mustn't keep clearing the handshake"
907 );
908 assert_eq!(
909 index.keys().publisher_among(&[acc("nel")], &key, at(3_601)),
910 None,
911 "a push check is bound by the same lease, or a key revoked while the fill was behind \
912 keeps authorizing pushes"
913 );
914}
915
916#[test]
917fn a_warming_member_roll_still_yields_the_accounts_that_may_push() {
918 let (_world, index) = folded();
919
920 let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else {
921 panic!("the registry is folded, so the knot knows who may push");
922 };
923 assert_eq!(work.pushers.len(), 1, "the one hosted repo has one owner");
924 assert_eq!(
925 work.due.len(),
926 1,
927 "the owner of the one hosted repo may push and hasn't had its keys read yet"
928 );
929 assert!(
930 work.members.is_warming(),
931 "an unfolded member roll mustn't stop the knot reading the keys it checks pushes against"
932 );
933}
934
935#[test]
936fn a_reprieve_coasts_on_the_last_read_until_its_budget_runs_out() {
937 let (_world, index) = folded();
938 let key = OfferedKey::from_bytes(vec![7]);
939 index.keys().record(&acc("nel"), vec![key.clone()], hour(0));
940
941 assert_eq!(
942 index
943 .keys()
944 .reprieve(&acc("nel"), at(10), grace(), hour(10)),
945 KeyReprieved::Extended
946 );
947 assert!(
948 index.keys().is_fresh(&acc("nel"), at(3_599)),
949 "a reread the knot attempted early and couldn't finish mustn't cut the lease it already \
950 has down to one retry, or a flaky PDS multiplies what the knot reads from it"
951 );
952
953 assert_eq!(
954 index
955 .keys()
956 .reprieve(&acc("nel"), at(4_000), grace(), hour(4_000)),
957 KeyReprieved::Extended
958 );
959 assert!(
960 pushers_complete(&index, at(4_000)),
961 "the reprieve keeps the keys the knot last read, so one unreachable PDS mustn't reopen \
962 the knot to every offered key"
963 );
964 assert_eq!(
965 index.owner_of_key(&key, at(4_000)),
966 Resolved::Ready(Some(acc("nel")))
967 );
968 assert!(
969 !index.keys().is_fresh(&acc("nel"), at(4_301)),
970 "a reprieve is one retry's worth, so the knot tries the account again shortly"
971 );
972
973 assert_eq!(
974 index
975 .keys()
976 .reprieve(&acc("nel"), at(21_500), grace(), hour(21_500)),
977 KeyReprieved::Extended
978 );
979 assert!(
980 !index.keys().is_fresh(&acc("nel"), at(21_600)),
981 "the last reprieve before the budget runs out mustn't stretch past it"
982 );
983
984 assert_eq!(
985 index
986 .keys()
987 .reprieve(&acc("nel"), at(21_601), grace(), hour(21_601)),
988 KeyReprieved::Exhausted,
989 "past the reprieve budget the knot stops coasting on keys it hasn't reread"
990 );
991 assert_eq!(
992 index.owner_of_key(&key, at(21_601)),
993 Resolved::Ready(None),
994 "a key revoked while its PDS was unreachable mustn't keep authorizing pushes forever"
995 );
996}
997
998#[test]
999fn an_account_the_knot_never_reads_stops_delaying_the_whole_set() {
1000 let (_world, index) = folded();
1001 let offered = OfferedKey::from_bytes(vec![7]);
1002
1003 assert_eq!(
1004 index.keys().reprieve(&acc("nel"), at(0), grace(), hour(0)),
1005 KeyReprieved::Pending,
1006 "an account the knot has never managed to read is still unread after a reprieve"
1007 );
1008 assert!(
1009 !pushers_complete(&index, at(0)),
1010 "an unread account mustn't count toward a complete set, or the knot refuses the keys \
1011 it has yet to fetch"
1012 );
1013 assert!(
1014 matches!(index.keys().work(at(299), sweep()), Resolved::Ready(work) if work.due.is_empty()),
1015 "a PDS that just refused the knot is left alone until the retry comes round"
1016 );
1017 assert!(
1018 matches!(index.keys().work(at(400), sweep()), Resolved::Ready(work) if work.due.len() == 1),
1019 "the knot retries an unread account every reprieve interval rather than every pass"
1020 );
1021
1022 assert_eq!(
1023 index
1024 .keys()
1025 .reprieve(&acc("nel"), at(21_601), grace(), hour(21_601)),
1026 KeyReprieved::Exhausted,
1027 "the reprieve budget runs from the first failure, so a PDS that never answers stops \
1028 being a pending read"
1029 );
1030 assert!(
1031 pushers_complete(&index, at(21_601)),
1032 "one dead account mustn't keep the set warming forever, or the knot spends its whole \
1033 life accepting every key offered to it"
1034 );
1035 assert_eq!(
1036 index
1037 .keys()
1038 .publisher_among(&[acc("nel")], &offered, at(21_601)),
1039 None,
1040 "giving up on an account records what the knot knows, an empty key set"
1041 );
1042}
1043
1044#[test]
1045fn concurrent_reads_of_one_account_never_leave_a_key_answering_for_a_set_it_left() {
1046 let world = World::new();
1047 let index = Arc::new(world.index());
1048 let hour = KeyTtl::from_secs(3_600).lease_from(at(0));
1049 let key = |i: u8| OfferedKey::from_bytes(vec![i]);
1050
1051 (0..2_000u32).for_each(|round| {
1052 let first = key((round % 7) as u8);
1053 let second = key(7 + (round % 7) as u8);
1054 let start = std::sync::Barrier::new(6);
1055 std::thread::scope(|scope| {
1056 (0..6).for_each(|slot| {
1057 let index = Arc::clone(&index);
1058 let keys = match slot % 2 {
1059 0 => vec![first.clone()],
1060 _ => vec![second.clone()],
1061 };
1062 let start = &start;
1063 scope.spawn(move || {
1064 start.wait();
1065 index.keys().record(&acc("nel"), keys, hour)
1066 });
1067 });
1068 });
1069
1070 [first, second].iter().for_each(|key| {
1071 let reverse = index.owner_of_key(key, at(0));
1072 let forward = index.keys().publisher_among(&[acc("nel")], key, at(0));
1073 assert_eq!(
1074 reverse,
1075 Resolved::Ready(forward),
1076 "a key the account no longer publishes must stop answering for it, or two reads \
1077 arriving at once leave a revoked key authorizing pushes for good"
1078 );
1079 });
1080 });
1081}
1082
1083fn folded() -> (World, knot_index::Index) {
1084 let world = World::new();
1085 let repo = repo_did("squid");
1086 world.layout.create(&repo).unwrap();
1087 world.seed_registry(&repo);
1088 let index = world.index();
1089 index.refresh_registry().unwrap();
1090 index.warm_collaborators();
1091 (world, index)
1092}
1093
1094fn grace() -> KeyReprieve {
1095 KeyReprieve::from_secs(300, 21_600)
1096}
1097
1098fn hour(from: i64) -> knot_index::KeyLease {
1099 KeyTtl::from_secs(3_600).lease_from(at(from))
1100}
1101
1102fn sweep() -> SweepFloor {
1103 SweepFloor::from_secs(60)
1104}
1105
1106fn pushers_complete(index: &knot_index::Index, now: knot_types::UnixSeconds) -> bool {
1107 matches!(index.keys().work(now, sweep()), Resolved::Ready(work) if work.complete)
1108}
1109
1110fn path(raw: &str) -> ClonePath {
1111 ClonePath::parse(raw).unwrap()
1112}
1113
1114#[test]
1115fn a_clone_path_resolves_by_name_when_the_record_key_is_a_tid() {
1116 let world = World::new();
1117 let repo = repo_did("squid");
1118
1119 let meta = Repo::open(&world.meta_path).unwrap();
1120 let store = CobStore::new(&meta);
1121 store
1122 .create(
1123 &meta_home(),
1124 &RegistryChange::Register(named_registration(
1125 "nel",
1126 "3mizfnpxii522",
1127 "substratum.cloud",
1128 &repo,
1129 1,
1130 )),
1131 &world.signer,
1132 at(1),
1133 )
1134 .unwrap();
1135
1136 let index = world.index();
1137 index.rebuild().unwrap();
1138
1139 assert_eq!(
1140 index.resolve_clone_path(&own("nel"), &path("substratum.cloud")),
1141 Resolved::Ready(Some(repo.clone())),
1142 "a PDS-native record key leaves the display name as the only human clone path"
1143 );
1144 assert_eq!(
1145 index.resolve_clone_path(&own("nel"), &path("3mizfnpxii522")),
1146 Resolved::Ready(Some(repo.clone())),
1147 "the record key still resolves"
1148 );
1149 assert_eq!(
1150 index.resolve_clone_path(&own("nel"), &path("substratum.cloud.git")),
1151 Resolved::Ready(Some(repo)),
1152 "the conventional .git suffix strips before the name lookup"
1153 );
1154 assert_eq!(
1155 index.resolve_clone_path(&own("nel"), &path("periwinkle")),
1156 Resolved::Ready(None)
1157 );
1158}
1159
1160#[test]
1161fn a_record_key_outranks_another_repos_name() {
1162 let world = World::new();
1163 let by_name = repo_did("limpet");
1164 let by_rkey = repo_did("mussel");
1165
1166 let meta = Repo::open(&world.meta_path).unwrap();
1167 let store = CobStore::new(&meta);
1168 let object = store
1169 .create(
1170 &meta_home(),
1171 &RegistryChange::Register(named_registration("nel", "limpet", "mussel", &by_name, 1)),
1172 &world.signer,
1173 at(1),
1174 )
1175 .unwrap()
1176 .object;
1177 store
1178 .update(
1179 &meta_home(),
1180 object,
1181 &RegistryChange::Register(named_registration("nel", "mussel", "scallop", &by_rkey, 2)),
1182 &world.signer,
1183 at(2),
1184 )
1185 .unwrap();
1186
1187 let index = world.index();
1188 index.rebuild().unwrap();
1189
1190 assert_eq!(
1191 index.resolve_clone_path(&own("nel"), &path("mussel")),
1192 Resolved::Ready(Some(by_rkey)),
1193 "a record key match wins over another repo holding that string as its name"
1194 );
1195 assert_eq!(
1196 index.resolve_clone_path(&own("nel"), &path("scallop")),
1197 Resolved::Ready(Some(repo_did("mussel")))
1198 );
1199}
1200
1201#[test]
1202fn a_name_two_repos_share_resolves_to_the_older_registration() {
1203 let world = World::new();
1204 let first = repo_did("whelk");
1205 let second = repo_did("conch");
1206
1207 let meta = Repo::open(&world.meta_path).unwrap();
1208 let store = CobStore::new(&meta);
1209 let object = store
1210 .create(
1211 &meta_home(),
1212 &RegistryChange::Register(named_registration(
1213 "nel",
1214 "3lubrptx57d22",
1215 "kelp",
1216 &first,
1217 1,
1218 )),
1219 &world.signer,
1220 at(1),
1221 )
1222 .unwrap()
1223 .object;
1224 store
1225 .update(
1226 &meta_home(),
1227 object,
1228 &RegistryChange::Register(named_registration(
1229 "nel",
1230 "3mqydma3re27z",
1231 "kelp",
1232 &second,
1233 2,
1234 )),
1235 &world.signer,
1236 at(2),
1237 )
1238 .unwrap();
1239
1240 let index = world.index();
1241 index.rebuild().unwrap();
1242
1243 assert_eq!(
1244 index.resolve_clone_path(&own("nel"), &path("kelp")),
1245 Resolved::Ready(Some(first.clone())),
1246 "a contested name resolves to whichever repo registered first, ordered by \
1247 created_at so a cold seed and an incremental replay agree"
1248 );
1249 assert_eq!(
1250 index.resolve_clone_path(&own("nel"), &path("3lubrptx57d22")),
1251 Resolved::Ready(Some(first)),
1252 "each record key stays unambiguous"
1253 );
1254 assert_eq!(
1255 index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")),
1256 Resolved::Ready(Some(second)),
1257 "the repo that lost the name is still reachable by its record key"
1258 );
1259}
1260
1261#[test]
1262fn a_repo_registered_later_never_takes_a_contested_name_by_renaming_onto_it() {
1263 let world = World::new();
1264 let holder = repo_did("whelk");
1265 let latecomer = repo_did("conch");
1266
1267 let meta = Repo::open(&world.meta_path).unwrap();
1268 let store = CobStore::new(&meta);
1269 let object = store
1270 .create(
1271 &meta_home(),
1272 &RegistryChange::Register(named_registration(
1273 "nel",
1274 "3lubrptx57d22",
1275 "kelp",
1276 &holder,
1277 1,
1278 )),
1279 &world.signer,
1280 at(1),
1281 )
1282 .unwrap()
1283 .object;
1284 store
1285 .update(
1286 &meta_home(),
1287 object,
1288 &RegistryChange::Register(named_registration(
1289 "nel",
1290 "3mqydma3re27z",
1291 "uni",
1292 &latecomer,
1293 2,
1294 )),
1295 &world.signer,
1296 at(2),
1297 )
1298 .unwrap();
1299
1300 let index = world.index();
1301 index.rebuild().unwrap();
1302
1303 store
1304 .update(
1305 &meta_home(),
1306 object,
1307 &RegistryChange::Rename(Rename {
1308 owner: own("nel"),
1309 rkey: rkey("3mqydma3re27z"),
1310 name: RepoName::new("kelp").unwrap(),
1311 repo: latecomer.clone(),
1312 }),
1313 &world.signer,
1314 at(3),
1315 )
1316 .unwrap();
1317 index.refresh_registry().unwrap();
1318
1319 assert_eq!(
1320 index.resolve_clone_path(&own("nel"), &path("kelp")),
1321 Resolved::Ready(Some(holder)),
1322 "a rename keeps the repo's original created_at, so renaming onto a name \
1323 another repo registered earlier cannot take it"
1324 );
1325 assert_eq!(
1326 index.resolve_clone_path(&own("nel"), &path("uni")),
1327 Resolved::Ready(None),
1328 "the renamed repo's previous name stops resolving"
1329 );
1330 assert_eq!(
1331 index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")),
1332 Resolved::Ready(Some(latecomer))
1333 );
1334}
1335
1336#[test]
1337fn deregistering_the_older_registration_moves_a_shared_name_to_the_survivor() {
1338 let world = World::new();
1339 let first = repo_did("whelk");
1340 let survivor = repo_did("conch");
1341
1342 let meta = Repo::open(&world.meta_path).unwrap();
1343 let store = CobStore::new(&meta);
1344 let object = store
1345 .create(
1346 &meta_home(),
1347 &RegistryChange::Register(named_registration(
1348 "nel",
1349 "3lubrptx57d22",
1350 "kelp",
1351 &first,
1352 1,
1353 )),
1354 &world.signer,
1355 at(1),
1356 )
1357 .unwrap()
1358 .object;
1359 store
1360 .update(
1361 &meta_home(),
1362 object,
1363 &RegistryChange::Register(named_registration(
1364 "nel",
1365 "3mqydma3re27z",
1366 "kelp",
1367 &survivor,
1368 2,
1369 )),
1370 &world.signer,
1371 at(2),
1372 )
1373 .unwrap();
1374
1375 let index = world.index();
1376 index.rebuild().unwrap();
1377 assert_eq!(
1378 index.resolve_clone_path(&own("nel"), &path("kelp")),
1379 Resolved::Ready(Some(first)),
1380 "the name resolves to the older registration while both exist"
1381 );
1382
1383 store
1384 .update(
1385 &meta_home(),
1386 object,
1387 &RegistryChange::Deregister(RepoRef {
1388 owner: own("nel"),
1389 rkey: rkey("3lubrptx57d22"),
1390 }),
1391 &world.signer,
1392 at(3),
1393 )
1394 .unwrap();
1395 index.refresh_registry().unwrap();
1396
1397 assert_eq!(
1398 index.resolve_clone_path(&own("nel"), &path("kelp")),
1399 Resolved::Ready(Some(survivor)),
1400 "the name resolves to the remaining repo once the older one is deregistered"
1401 );
1402}
1403
1404#[test]
1405fn a_rename_moves_name_resolution_off_the_old_name() {
1406 let world = World::new();
1407 let repo = repo_did("squid");
1408
1409 let meta = Repo::open(&world.meta_path).unwrap();
1410 let store = CobStore::new(&meta);
1411 let object = store
1412 .create(
1413 &meta_home(),
1414 &RegistryChange::Register(named_registration(
1415 "nel",
1416 "3mizfnpxii522",
1417 "anemone",
1418 &repo,
1419 1,
1420 )),
1421 &world.signer,
1422 at(1),
1423 )
1424 .unwrap()
1425 .object;
1426
1427 let index = world.index();
1428 index.rebuild().unwrap();
1429 assert_eq!(
1430 index.resolve_clone_path(&own("nel"), &path("anemone")),
1431 Resolved::Ready(Some(repo.clone()))
1432 );
1433
1434 store
1435 .update(
1436 &meta_home(),
1437 object,
1438 &RegistryChange::Rename(Rename {
1439 owner: own("nel"),
1440 rkey: rkey("3mizfnpxii522"),
1441 name: RepoName::new("barnacle").unwrap(),
1442 repo: repo.clone(),
1443 }),
1444 &world.signer,
1445 at(2),
1446 )
1447 .unwrap();
1448 index.refresh_registry().unwrap();
1449
1450 assert_eq!(
1451 index.resolve_clone_path(&own("nel"), &path("barnacle")),
1452 Resolved::Ready(Some(repo.clone())),
1453 "the new name resolves after a rename that keeps the record key"
1454 );
1455 assert_eq!(
1456 index.resolve_clone_path(&own("nel"), &path("anemone")),
1457 Resolved::Ready(None),
1458 "the superseded name stops resolving"
1459 );
1460 assert_eq!(
1461 index.resolve_clone_path(&own("nel"), &path("3mizfnpxii522")),
1462 Resolved::Ready(Some(repo))
1463 );
1464}
1465
1466#[test]
1467fn a_rename_that_changes_only_the_record_key_keeps_the_name_resolving() {
1468 let world = World::new();
1469 let repo = repo_did("squid");
1470
1471 let meta = Repo::open(&world.meta_path).unwrap();
1472 let store = CobStore::new(&meta);
1473 let object = store
1474 .create(
1475 &meta_home(),
1476 &RegistryChange::Register(named_registration("nel", "3lubrptx57d22", "kelp", &repo, 1)),
1477 &world.signer,
1478 at(1),
1479 )
1480 .unwrap()
1481 .object;
1482
1483 let index = world.index();
1484 index.rebuild().unwrap();
1485
1486 store
1487 .update(
1488 &meta_home(),
1489 object,
1490 &RegistryChange::Rename(Rename {
1491 owner: own("nel"),
1492 rkey: rkey("3mqydma3re27z"),
1493 name: RepoName::new("kelp").unwrap(),
1494 repo: repo.clone(),
1495 }),
1496 &world.signer,
1497 at(2),
1498 )
1499 .unwrap();
1500 index.refresh_registry().unwrap();
1501
1502 assert_eq!(
1503 index.resolve_clone_path(&own("nel"), &path("kelp")),
1504 Resolved::Ready(Some(repo.clone())),
1505 "the unchanged name survives a rename that swaps the record key"
1506 );
1507 assert_eq!(
1508 index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")),
1509 Resolved::Ready(Some(repo.clone()))
1510 );
1511 assert_eq!(
1512 index.resolve_clone_path(&own("nel"), &path("3lubrptx57d22")),
1513 Resolved::Ready(Some(repo)),
1514 "the superseded record key keeps resolving through its retained alias"
1515 );
1516}
1517
1518#[test]
1519fn seeding_and_replaying_agree_on_name_resolution() {
1520 let world = World::new();
1521 let first = repo_did("whelk");
1522 let second = repo_did("conch");
1523
1524 let meta = Repo::open(&world.meta_path).unwrap();
1525 let store = CobStore::new(&meta);
1526 let object = store
1527 .create(
1528 &meta_home(),
1529 &RegistryChange::Register(named_registration(
1530 "nel",
1531 "3lubrptx57d22",
1532 "kelp",
1533 &first,
1534 1,
1535 )),
1536 &world.signer,
1537 at(1),
1538 )
1539 .unwrap()
1540 .object;
1541
1542 let replayed = world.index();
1543 replayed.rebuild().unwrap();
1544
1545 store
1546 .update(
1547 &meta_home(),
1548 object,
1549 &RegistryChange::Register(named_registration(
1550 "nel",
1551 "3mqydma3re27z",
1552 "uni",
1553 &second,
1554 2,
1555 )),
1556 &world.signer,
1557 at(2),
1558 )
1559 .unwrap();
1560 replayed.refresh_registry().unwrap();
1561
1562 let seeded = world.index();
1563 seeded.rebuild().unwrap();
1564
1565 ["kelp", "uni", "3lubrptx57d22", "3mqydma3re27z", "nautilus"]
1566 .into_iter()
1567 .for_each(|segment| {
1568 assert_eq!(
1569 replayed.resolve_clone_path(&own("nel"), &path(segment)),
1570 seeded.resolve_clone_path(&own("nel"), &path(segment)),
1571 "incremental replay and a cold seed disagree on {segment}"
1572 );
1573 });
1574}
1575
1576#[test]
1577fn a_name_differing_from_its_record_key_only_by_case_resolves() {
1578 let world = World::new();
1579 let repo = repo_did("squid");
1580
1581 let meta = Repo::open(&world.meta_path).unwrap();
1582 let store = CobStore::new(&meta);
1583 store
1584 .create(
1585 &meta_home(),
1586 &RegistryChange::Register(named_registration(
1587 "nel",
1588 "runic_lang",
1589 "Runic_lang",
1590 &repo,
1591 1,
1592 )),
1593 &world.signer,
1594 at(1),
1595 )
1596 .unwrap();
1597
1598 let index = world.index();
1599 index.rebuild().unwrap();
1600
1601 assert_eq!(
1602 index.resolve_clone_path(&own("nel"), &path("Runic_lang")),
1603 Resolved::Ready(Some(repo.clone())),
1604 "the appview lowercases the record key but keeps the display name's case, \
1605 so the mixed-case path resolves by name"
1606 );
1607 assert_eq!(
1608 index.resolve_clone_path(&own("nel"), &path("runic_lang")),
1609 Resolved::Ready(Some(repo)),
1610 "the lowercased record key still resolves"
1611 );
1612}