···44use knot_cob::{ChangePayload, CobHome, CobId, CobStore};
55use knot_cobs::{CollaboratorsChange, MembersChange, RegistryChange, Removal, Rename, RepoRef};
66use knot_git::{RefUpdate, Repo};
77-use knot_index::{Coverage, IndexError, Resolved};
77+use knot_index::{
88+ Coverage, HostedCoverage, IndexError, KeptAccounts, KeyBudget, KeyRecord, KeyReprieve,
99+ KeyReprieved, KeyTtl, OfferedKey, Resolved, SweepFloor,
1010+};
811use knot_types::{ClonePath, RefName, RepoName};
912use serde::{Deserialize, Serialize};
1013···536539 Resolved::Ready(true),
537540 "deregister and re-register within single delta leaves repo hosted, so its collaborators survive"
538541 );
542542+}
543543+544544+#[test]
545545+fn recording_and_retaining_track_who_still_publishes_each_key() {
546546+ let world = World::new();
547547+ let index = world.index();
548548+ let key = |i: u32| OfferedKey::from_bytes(i.to_le_bytes().to_vec());
549549+550550+ index
551551+ .keys()
552552+ .record(&acc("nel"), vec![key(0), key(1)], hour(0));
553553+ index
554554+ .keys()
555555+ .record(&acc("nel"), vec![key(1), key(2)], hour(0));
556556+ assert_eq!(
557557+ index.owner_of_key(&key(0), at(0)),
558558+ Resolved::Ready(None),
559559+ "a key the account stopped publishing stops resolving to it"
560560+ );
561561+ assert_eq!(
562562+ index.owner_of_key(&key(1), at(0)),
563563+ Resolved::Ready(Some(acc("nel"))),
564564+ "a key present in both sets survives the replacement"
565565+ );
566566+567567+ index
568568+ .keys()
569569+ .record(&acc("cuttle"), vec![key(1), key(3)], hour(0));
570570+ assert_eq!(
571571+ index
572572+ .keys()
573573+ .publisher_among(&[acc("cuttle")], &key(1), at(0)),
574574+ Some(acc("cuttle")),
575575+ "a deploy key two accounts publish must answer for the candidate that may push here, \
576576+ whichever of them the knot happened to read first"
577577+ );
578578+ assert_eq!(
579579+ index.keys().publisher_among(&[acc("teq")], &key(1), at(0)),
580580+ None,
581581+ "the key mustn't answer for an account that doesn't publish it"
582582+ );
583583+584584+ index.keys().retain(&KeptAccounts::new(vec![acc("nel")]));
585585+ assert_eq!(
586586+ index.owner_of_key(&key(3), at(0)),
587587+ Resolved::Ready(None),
588588+ "an account outside the grant set has its keys released"
589589+ );
590590+ assert_eq!(
591591+ index.owner_of_key(&key(1), at(0)),
592592+ Resolved::Ready(Some(acc("nel"))),
593593+ "one account leaving the grant set mustn't take a shared deploy key away from the \
594594+ account that still publishes it"
595595+ );
596596+597597+ index.keys().record(&acc("nel"), vec![key(2)], hour(0));
598598+ assert_eq!(
599599+ index.owner_of_key(&key(1), at(0)),
600600+ Resolved::Ready(None),
601601+ "with its last publisher no longer offering it, the key stops answering for anybody"
602602+ );
603603+}
604604+605605+#[test]
606606+fn a_full_budget_reports_unheld_readings_and_then_refuses_to_grow_at_all() {
607607+ let world = World::new();
608608+ let index = world.index_within(KeyBudget::from_bytes(560));
609609+ let fat = |seed: u8| OfferedKey::from_bytes(vec![seed; 48]);
610610+611611+ assert_eq!(
612612+ index.keys().record(&acc("nel"), vec![fat(1)], hour(0)),
613613+ KeyRecord::Stored
614614+ );
615615+ assert!(
616616+ !index.keys().any_unheld(),
617617+ "a set that kept every reading it took can vouch for its misses"
618618+ );
619619+620620+ assert_eq!(
621621+ index.keys().record(&acc("cuttle"), vec![fat(2)], hour(0)),
622622+ KeyRecord::Unheld,
623623+ "a budget too small for another account's keys still has room to record that the \
624624+ knot read the account"
625625+ );
626626+ assert!(
627627+ index.keys().any_unheld(),
628628+ "a reading the budget couldn't fit means an unknown key may still belong to a \
629629+ candidate, so the handshake must defer to the push check instead of refusing"
630630+ );
631631+ assert!(
632632+ !index.keys().is_fresh(&acc("cuttle"), at(0)),
633633+ "the refused account stays stale, so the fill keeps reporting the set incomplete and \
634634+ the knot keeps checking its pushes against its PDS"
635635+ );
636636+ assert_eq!(
637637+ index.owner_of_key(&fat(2), at(0)),
638638+ Resolved::Ready(None),
639639+ "a key the budget refused mustn't answer for anybody"
640640+ );
641641+642642+ assert_eq!(
643643+ index.keys().record(&acc("teq"), vec![fat(3)], hour(0)),
644644+ KeyRecord::Saturated,
645645+ "a knot whose grant set publishes more key bytes than it budgeted for must stop growing"
646646+ );
647647+ assert_eq!(
648648+ index.owner_of_key(&fat(1), at(0)),
649649+ Resolved::Ready(Some(acc("nel"))),
650650+ "refusing the accounts that didn't fit mustn't release the accounts already on file"
651651+ );
652652+653653+ index.keys().retain(&KeptAccounts::new(vec![acc("cuttle")]));
654654+ assert!(
655655+ index.keys().any_unheld(),
656656+ "releasing a different account mustn't clear the report while the unheld reading stays"
657657+ );
658658+ assert_eq!(
659659+ index.keys().record(&acc("cuttle"), vec![fat(2)], hour(0)),
660660+ KeyRecord::Stored,
661661+ "the budget frees up with the accounts that leave the grant set"
662662+ );
663663+ assert!(
664664+ !index.keys().any_unheld(),
665665+ "with every reading back on file the handshake can refuse unknown keys outright"
666666+ );
667667+668668+ assert_eq!(
669669+ index.keys().record(&acc("teq"), vec![fat(3)], hour(0)),
670670+ KeyRecord::Unheld
671671+ );
672672+ assert_eq!(
673673+ index
674674+ .keys()
675675+ .reprieve(&acc("teq"), at(21_601), grace(), hour(21_601)),
676676+ KeyReprieved::Exhausted
677677+ );
678678+ assert!(
679679+ !index.keys().any_unheld(),
680680+ "an account the knot gave up rereading is recorded with an empty key set, so its \
681681+ unheld reading mustn't keep the handshake open"
682682+ );
683683+}
684684+685685+#[test]
686686+fn a_pass_renews_at_the_lease_halfway_and_rereads_once_per_miss_outside_the_floor() {
687687+ let (_world, index) = folded();
688688+ index
689689+ .keys()
690690+ .record(&acc("nel"), vec![OfferedKey::from_bytes(vec![7])], hour(0));
691691+692692+ assert!(
693693+ matches!(index.keys().work(at(0), sweep()), Resolved::Ready(work)
694694+ if work.due.is_empty() && work.suspected.is_empty()),
695695+ "with every account's keys read inside the ttl the fill won't fetch anything"
696696+ );
697697+698698+ index.keys().note_miss();
699699+ let Resolved::Ready(work) = index.keys().work(at(100), sweep()) else {
700700+ panic!("the registry is folded, so the knot knows who may push");
701701+ };
702702+ assert_eq!(
703703+ work.suspected.len(),
704704+ 1,
705705+ "a key the accounts on file don't publish is the only evidence the knot gets that an \
706706+ account published a new key, so the next pass must reread rather than wait out the ttl"
707707+ );
708708+ assert!(
709709+ work.due.is_empty(),
710710+ "a reread a stranger asked for must stay separate from the reads coverage waits on, or \
711711+ the knot spends a whole pass on it without renewing anything"
712712+ );
713713+ assert!(
714714+ matches!(index.keys().work(at(100), sweep()), Resolved::Ready(work) if work.suspected.is_empty()),
715715+ "one miss is worth one reread, so a stranger offering keys can't set the fill's pace"
716716+ );
717717+718718+ index.keys().note_miss();
719719+ assert!(
720720+ matches!(index.keys().work(at(159), sweep()), Resolved::Ready(work) if work.suspected.is_empty()),
721721+ "a stranger can offer an unsigned key for free, so a knot that swept a moment ago \
722722+ mustn't spend another read at every PDS in the grant set"
723723+ );
724724+ assert!(
725725+ matches!(index.keys().work(at(160), sweep()), Resolved::Ready(work) if work.suspected.len() == 1),
726726+ "the miss the floor delayed is honored once the floor has passed, so a key published \
727727+ between passes is still picked up"
728728+ );
729729+730730+ assert!(
731731+ matches!(index.keys().work(at(1_799), sweep()), Resolved::Ready(work) if work.due.is_empty()),
732732+ "an account inside the first half of its lease is left alone"
733733+ );
734734+ let Resolved::Ready(work) = index.keys().work(at(1_800), sweep()) else {
735735+ panic!("the registry is folded, so the knot knows who may push");
736736+ };
737737+ assert_eq!(
738738+ work.due.len(),
739739+ 1,
740740+ "the fill renews at the halfway mark, so a pass no longer than the lease it is working \
741741+ to finishes before anything expires"
742742+ );
743743+ assert!(
744744+ work.complete,
745745+ "the lease the account still has answers for it, so a knot busy renewing keeps \
746746+ refusing keys the accounts on file don't publish instead of opening the handshake \
747747+ to every stranger"
748748+ );
749749+}
750750+751751+#[test]
752752+fn an_unfolded_repo_keeps_the_pass_partial_and_an_unopenable_repo_never_grants() {
753753+ let world = World::new();
754754+ let folded = repo_did("squid");
755755+ let never_created = repo_did("limpet");
756756+ world.layout.create(&folded).unwrap();
757757+ let registry = world.seed_registry(&folded);
758758+ world.register_owned(&never_created, "limpet", "cuttle", registry);
759759+ let index = world.index();
760760+ index.refresh_registry().unwrap();
761761+ index.refresh_collaborators(&folded).unwrap();
762762+763763+ let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else {
764764+ panic!("one repo the knot hasn't folded mustn't stop it filling the keys it can enumerate");
765765+ };
766766+ assert_eq!(
767767+ work.hosted,
768768+ HostedCoverage::Partial { unread: 1 },
769769+ "the pass must know it read less than the whole roll, or it evicts the accounts it \
770770+ couldn't enumerate"
771771+ );
772772+ assert!(
773773+ !work.due.is_empty(),
774774+ "the owner of the repo that did fold still needs its keys read"
775775+ );
776776+ assert!(
777777+ !work.complete,
778778+ "a partial roll mustn't let the fill call the set complete, or the knot refuses a \
779779+ pusher whose repo it never managed to read"
780780+ );
781781+782782+ assert_eq!(
783783+ index.warm_collaborators(),
784784+ 1,
785785+ "the repo registered without ever being created is what the knot can't open"
786786+ );
787787+ let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else {
788788+ panic!("the registry is folded, so the knot knows who may push");
789789+ };
790790+ assert_eq!(
791791+ work.hosted,
792792+ HostedCoverage::Whole,
793793+ "a repo the knot can't open won't serve a push, so counting it as unread would leave \
794794+ the set warming and the handshake open to every stranger for good"
795795+ );
796796+ assert_eq!(
797797+ work.pushers.as_slice(),
798798+ [acc("nel")],
799799+ "only the repo the knot can open grants anybody"
800800+ );
801801+802802+ index
803803+ .keys()
804804+ .record(&acc("nel"), vec![OfferedKey::from_bytes(vec![7])], hour(0));
805805+ assert!(
806806+ pushers_complete(&index, at(0)),
807807+ "with the one readable repo's owner on file the set is complete, so the knot can go \
808808+ back to refusing keys the accounts on file don't publish"
809809+ );
810810+}
811811+812812+#[test]
813813+fn an_acl_write_puts_the_key_set_back_to_warming_even_mid_pass() {
814814+ let (world, index) = folded();
815815+ index
816816+ .keys()
817817+ .record(&acc("nel"), vec![OfferedKey::from_bytes(vec![7])], hour(0));
818818+ let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else {
819819+ panic!("the registry is folded, so the knot knows who may push");
820820+ };
821821+ assert!(work.complete, "the one pusher's keys are on file");
822822+ index.keys().mark_ready(work.generation);
823823+ assert_eq!(index.keys().coverage(), Coverage::Ready);
824824+825825+ let roll = world.seed_members();
826826+ index.refresh_members().unwrap();
827827+ assert_eq!(
828828+ index.keys().coverage(),
829829+ Coverage::Warming,
830830+ "the knot mustn't check a grant it has never read keys for against a set it calls \
831831+ complete, or a new collaborator is refused until the next fill pass"
832832+ );
833833+834834+ let Resolved::Ready(rereading) = index.keys().work(at(0), sweep()) else {
835835+ panic!("the registry is folded, so the knot knows who may push");
836836+ };
837837+ world.add_member(roll, "teq", 5);
838838+ index.refresh_members().unwrap();
839839+ index.keys().mark_ready(rereading.generation);
840840+ assert_eq!(
841841+ index.keys().coverage(),
842842+ Coverage::Warming,
843843+ "a pass claims the grant set it read, so a grant written while it was reading keys \
844844+ mustn't count as covered, or whoever it grants is refused at the handshake"
845845+ );
846846+}
847847+848848+#[test]
849849+fn an_account_the_budget_cant_fit_is_checked_against_its_pds_without_delaying_coverage() {
850850+ let world = World::new();
851851+ let folded = repo_did("squid");
852852+ let second = repo_did("limpet");
853853+ world.layout.create(&folded).unwrap();
854854+ world.layout.create(&second).unwrap();
855855+ let registry = world.seed_registry(&folded);
856856+ world.register_owned(&second, "limpet", "cuttle", registry);
857857+ let index = world.index_within(KeyBudget::from_bytes(560));
858858+ index.refresh_registry().unwrap();
859859+ index.warm_collaborators();
860860+ let fat = |seed: u8| OfferedKey::from_bytes(vec![seed; 48]);
861861+862862+ assert_eq!(
863863+ index.keys().record(&acc("nel"), vec![fat(1)], hour(0)),
864864+ KeyRecord::Stored
865865+ );
866866+ assert_eq!(
867867+ index.keys().record(&acc("cuttle"), vec![fat(2)], hour(0)),
868868+ KeyRecord::Unheld
869869+ );
870870+ assert!(
871871+ pushers_complete(&index, at(0)),
872872+ "a full budget mustn't leave the set warming for good, or the knot spends the rest of \
873873+ its life accepting every key offered to it"
874874+ );
875875+ let Resolved::Ready(later) = index.keys().work(at(3_601), sweep()) else {
876876+ panic!("the registry is folded, so the knot knows who may push");
877877+ };
878878+ assert!(
879879+ later.due.as_slice().contains(&acc("cuttle")),
880880+ "the knot tries the account again once the lease runs out, so a budget that frees up \
881881+ starts keeping its keys. Every pass until then costs one read rather than a reread \
882882+ of the whole grant set"
883883+ );
884884+}
885885+886886+#[test]
887887+fn a_key_read_outside_its_lease_stops_answering_for_its_publisher() {
888888+ let world = World::new();
889889+ let index = world.index();
890890+ let key = OfferedKey::from_bytes(vec![7]);
891891+ index.keys().record(&acc("nel"), vec![key.clone()], hour(0));
892892+893893+ assert_eq!(
894894+ index.owner_of_key(&key, at(3_599)),
895895+ Resolved::Ready(Some(acc("nel"))),
896896+ "inside the lease the set answers for the account that published the key"
897897+ );
898898+ assert_eq!(
899899+ index.keys().publisher_among(&[acc("nel")], &key, at(3_599)),
900900+ Some(acc("nel"))
901901+ );
902902+ assert_eq!(
903903+ index.owner_of_key(&key, at(3_601)),
904904+ Resolved::Ready(None),
905905+ "past the lease the knot no longer knows the account publishes the key, so a key \
906906+ revoked while the fill was behind mustn't keep clearing the handshake"
907907+ );
908908+ assert_eq!(
909909+ index.keys().publisher_among(&[acc("nel")], &key, at(3_601)),
910910+ None,
911911+ "a push check is bound by the same lease, or a key revoked while the fill was behind \
912912+ keeps authorizing pushes"
913913+ );
914914+}
915915+916916+#[test]
917917+fn a_warming_member_roll_still_yields_the_accounts_that_may_push() {
918918+ let (_world, index) = folded();
919919+920920+ let Resolved::Ready(work) = index.keys().work(at(0), sweep()) else {
921921+ panic!("the registry is folded, so the knot knows who may push");
922922+ };
923923+ assert_eq!(work.pushers.len(), 1, "the one hosted repo has one owner");
924924+ assert_eq!(
925925+ work.due.len(),
926926+ 1,
927927+ "the owner of the one hosted repo may push and hasn't had its keys read yet"
928928+ );
929929+ assert!(
930930+ work.members.is_warming(),
931931+ "an unfolded member roll mustn't stop the knot reading the keys it checks pushes against"
932932+ );
933933+}
934934+935935+#[test]
936936+fn a_reprieve_coasts_on_the_last_read_until_its_budget_runs_out() {
937937+ let (_world, index) = folded();
938938+ let key = OfferedKey::from_bytes(vec![7]);
939939+ index.keys().record(&acc("nel"), vec![key.clone()], hour(0));
940940+941941+ assert_eq!(
942942+ index
943943+ .keys()
944944+ .reprieve(&acc("nel"), at(10), grace(), hour(10)),
945945+ KeyReprieved::Extended
946946+ );
947947+ assert!(
948948+ index.keys().is_fresh(&acc("nel"), at(3_599)),
949949+ "a reread the knot attempted early and couldn't finish mustn't cut the lease it already \
950950+ has down to one retry, or a flaky PDS multiplies what the knot reads from it"
951951+ );
952952+953953+ assert_eq!(
954954+ index
955955+ .keys()
956956+ .reprieve(&acc("nel"), at(4_000), grace(), hour(4_000)),
957957+ KeyReprieved::Extended
958958+ );
959959+ assert!(
960960+ pushers_complete(&index, at(4_000)),
961961+ "the reprieve keeps the keys the knot last read, so one unreachable PDS mustn't reopen \
962962+ the knot to every offered key"
963963+ );
964964+ assert_eq!(
965965+ index.owner_of_key(&key, at(4_000)),
966966+ Resolved::Ready(Some(acc("nel")))
967967+ );
968968+ assert!(
969969+ !index.keys().is_fresh(&acc("nel"), at(4_301)),
970970+ "a reprieve is one retry's worth, so the knot tries the account again shortly"
971971+ );
972972+973973+ assert_eq!(
974974+ index
975975+ .keys()
976976+ .reprieve(&acc("nel"), at(21_500), grace(), hour(21_500)),
977977+ KeyReprieved::Extended
978978+ );
979979+ assert!(
980980+ !index.keys().is_fresh(&acc("nel"), at(21_600)),
981981+ "the last reprieve before the budget runs out mustn't stretch past it"
982982+ );
983983+984984+ assert_eq!(
985985+ index
986986+ .keys()
987987+ .reprieve(&acc("nel"), at(21_601), grace(), hour(21_601)),
988988+ KeyReprieved::Exhausted,
989989+ "past the reprieve budget the knot stops coasting on keys it hasn't reread"
990990+ );
991991+ assert_eq!(
992992+ index.owner_of_key(&key, at(21_601)),
993993+ Resolved::Ready(None),
994994+ "a key revoked while its PDS was unreachable mustn't keep authorizing pushes forever"
995995+ );
996996+}
997997+998998+#[test]
999999+fn an_account_the_knot_never_reads_stops_delaying_the_whole_set() {
10001000+ let (_world, index) = folded();
10011001+ let offered = OfferedKey::from_bytes(vec![7]);
10021002+10031003+ assert_eq!(
10041004+ index.keys().reprieve(&acc("nel"), at(0), grace(), hour(0)),
10051005+ KeyReprieved::Pending,
10061006+ "an account the knot has never managed to read is still unread after a reprieve"
10071007+ );
10081008+ assert!(
10091009+ !pushers_complete(&index, at(0)),
10101010+ "an unread account mustn't count toward a complete set, or the knot refuses the keys \
10111011+ it has yet to fetch"
10121012+ );
10131013+ assert!(
10141014+ matches!(index.keys().work(at(299), sweep()), Resolved::Ready(work) if work.due.is_empty()),
10151015+ "a PDS that just refused the knot is left alone until the retry comes round"
10161016+ );
10171017+ assert!(
10181018+ matches!(index.keys().work(at(400), sweep()), Resolved::Ready(work) if work.due.len() == 1),
10191019+ "the knot retries an unread account every reprieve interval rather than every pass"
10201020+ );
10211021+10221022+ assert_eq!(
10231023+ index
10241024+ .keys()
10251025+ .reprieve(&acc("nel"), at(21_601), grace(), hour(21_601)),
10261026+ KeyReprieved::Exhausted,
10271027+ "the reprieve budget runs from the first failure, so a PDS that never answers stops \
10281028+ being a pending read"
10291029+ );
10301030+ assert!(
10311031+ pushers_complete(&index, at(21_601)),
10321032+ "one dead account mustn't keep the set warming forever, or the knot spends its whole \
10331033+ life accepting every key offered to it"
10341034+ );
10351035+ assert_eq!(
10361036+ index
10371037+ .keys()
10381038+ .publisher_among(&[acc("nel")], &offered, at(21_601)),
10391039+ None,
10401040+ "giving up on an account records what the knot knows, an empty key set"
10411041+ );
10421042+}
10431043+10441044+#[test]
10451045+fn concurrent_reads_of_one_account_never_leave_a_key_answering_for_a_set_it_left() {
10461046+ let world = World::new();
10471047+ let index = Arc::new(world.index());
10481048+ let hour = KeyTtl::from_secs(3_600).lease_from(at(0));
10491049+ let key = |i: u8| OfferedKey::from_bytes(vec![i]);
10501050+10511051+ (0..2_000u32).for_each(|round| {
10521052+ let first = key((round % 7) as u8);
10531053+ let second = key(7 + (round % 7) as u8);
10541054+ let start = std::sync::Barrier::new(6);
10551055+ std::thread::scope(|scope| {
10561056+ (0..6).for_each(|slot| {
10571057+ let index = Arc::clone(&index);
10581058+ let keys = match slot % 2 {
10591059+ 0 => vec![first.clone()],
10601060+ _ => vec![second.clone()],
10611061+ };
10621062+ let start = &start;
10631063+ scope.spawn(move || {
10641064+ start.wait();
10651065+ index.keys().record(&acc("nel"), keys, hour)
10661066+ });
10671067+ });
10681068+ });
10691069+10701070+ [first, second].iter().for_each(|key| {
10711071+ let reverse = index.owner_of_key(key, at(0));
10721072+ let forward = index.keys().publisher_among(&[acc("nel")], key, at(0));
10731073+ assert_eq!(
10741074+ reverse,
10751075+ Resolved::Ready(forward),
10761076+ "a key the account no longer publishes must stop answering for it, or two reads \
10771077+ arriving at once leave a revoked key authorizing pushes for good"
10781078+ );
10791079+ });
10801080+ });
10811081+}
10821082+10831083+fn folded() -> (World, knot_index::Index) {
10841084+ let world = World::new();
10851085+ let repo = repo_did("squid");
10861086+ world.layout.create(&repo).unwrap();
10871087+ world.seed_registry(&repo);
10881088+ let index = world.index();
10891089+ index.refresh_registry().unwrap();
10901090+ index.warm_collaborators();
10911091+ (world, index)
10921092+}
10931093+10941094+fn grace() -> KeyReprieve {
10951095+ KeyReprieve::from_secs(300, 21_600)
10961096+}
10971097+10981098+fn hour(from: i64) -> knot_index::KeyLease {
10991099+ KeyTtl::from_secs(3_600).lease_from(at(from))
11001100+}
11011101+11021102+fn sweep() -> SweepFloor {
11031103+ SweepFloor::from_secs(60)
11041104+}
11051105+11061106+fn pushers_complete(index: &knot_index::Index, now: knot_types::UnixSeconds) -> bool {
11071107+ matches!(index.keys().work(now, sweep()), Resolved::Ready(work) if work.complete)
5391108}
54011095411110fn path(raw: &str) -> ClonePath {