Now let's take a silly one
1use std::collections::BTreeSet;
2
3use knot_index::{Index, Resolved};
4use knot_types::{AccountDid, AdmissionPolicy, OwnerDid, RepoDid};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7#[must_use]
8pub enum Decision {
9 Allow,
10 Deny,
11}
12
13impl Decision {
14 pub fn is_allowed(self) -> bool {
15 matches!(self, Decision::Allow)
16 }
17
18 fn allow_if(granted: bool) -> Self {
19 if granted {
20 Decision::Allow
21 } else {
22 Decision::Deny
23 }
24 }
25}
26
27pub trait Acl {
28 fn is_admin(&self, who: &AccountDid) -> bool;
29 fn admission(&self) -> AdmissionPolicy;
30 fn is_member(&self, who: &AccountDid) -> Resolved<bool>;
31 fn is_blocked(&self, who: &AccountDid) -> Resolved<bool>;
32 fn is_collaborator(&self, repo: &RepoDid, who: &AccountDid) -> Resolved<bool>;
33 fn repo_owner(&self, repo: &RepoDid) -> Resolved<Option<OwnerDid>>;
34}
35
36fn confirmed(resolved: Resolved<bool>) -> bool {
37 matches!(resolved, Resolved::Ready(true))
38}
39
40fn owns_repo(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> bool {
41 confirmed(
42 acl.repo_owner(repo)
43 .map(|owner| owner.is_some_and(|owner| owner.is(who))),
44 )
45}
46
47fn not_blocked(acl: &impl Acl, who: &AccountDid) -> bool {
48 acl.is_admin(who) || matches!(acl.is_blocked(who), Resolved::Ready(false))
49}
50
51pub fn can_admin_knot(acl: &impl Acl, who: &AccountDid) -> Decision {
52 Decision::allow_if(acl.is_admin(who))
53}
54
55pub fn can_create_repo(acl: &impl Acl, who: &AccountDid) -> Decision {
56 Decision::allow_if(
57 acl.is_admin(who)
58 || (not_blocked(acl, who)
59 && match acl.admission() {
60 AdmissionPolicy::Open => true,
61 AdmissionPolicy::Closed => confirmed(acl.is_member(who)),
62 }),
63 )
64}
65
66pub fn can_push(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision {
67 Decision::allow_if(
68 not_blocked(acl, who)
69 && (owns_repo(acl, who, repo) || confirmed(acl.is_collaborator(repo, who))),
70 )
71}
72
73pub fn can_manage_collaborators(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision {
74 Decision::allow_if(not_blocked(acl, who) && owns_repo(acl, who, repo))
75}
76
77pub fn can_delete_repo(acl: &impl Acl, who: &AccountDid, repo: &RepoDid) -> Decision {
78 Decision::allow_if(acl.is_admin(who) || owns_repo(acl, who, repo))
79}
80
81pub struct KnotAcl<'a> {
82 admins: &'a BTreeSet<AccountDid>,
83 policy: AdmissionPolicy,
84 index: &'a Index,
85}
86
87impl<'a> KnotAcl<'a> {
88 pub fn new(
89 admins: &'a BTreeSet<AccountDid>,
90 policy: AdmissionPolicy,
91 index: &'a Index,
92 ) -> Self {
93 Self {
94 admins,
95 policy,
96 index,
97 }
98 }
99}
100
101impl Acl for KnotAcl<'_> {
102 fn is_admin(&self, who: &AccountDid) -> bool {
103 self.admins.contains(who)
104 }
105
106 fn admission(&self) -> AdmissionPolicy {
107 self.policy
108 }
109
110 fn is_member(&self, who: &AccountDid) -> Resolved<bool> {
111 self.index.is_member(who)
112 }
113
114 fn is_blocked(&self, who: &AccountDid) -> Resolved<bool> {
115 self.index.is_blocked(who)
116 }
117
118 fn is_collaborator(&self, repo: &RepoDid, who: &AccountDid) -> Resolved<bool> {
119 self.index.is_collaborator(repo, who)
120 }
121
122 fn repo_owner(&self, repo: &RepoDid) -> Resolved<Option<OwnerDid>> {
123 self.index.owner_of(repo)
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 fn acc(suffix: &str) -> AccountDid {
132 AccountDid::new(format!("did:plc:{suffix}")).unwrap()
133 }
134
135 fn owner(suffix: &str) -> OwnerDid {
136 OwnerDid::new(format!("did:plc:{suffix}")).unwrap()
137 }
138
139 fn repo(suffix: &str) -> RepoDid {
140 RepoDid::new(format!("did:plc:{suffix}")).unwrap()
141 }
142
143 struct Fake {
144 admins: BTreeSet<AccountDid>,
145 admission: AdmissionPolicy,
146 member: Resolved<bool>,
147 blocked: Resolved<bool>,
148 collaborator: Resolved<bool>,
149 owner: Resolved<Option<OwnerDid>>,
150 }
151
152 impl Fake {
153 fn new() -> Self {
154 Self {
155 admins: BTreeSet::new(),
156 admission: AdmissionPolicy::Closed,
157 member: Resolved::Warming,
158 blocked: Resolved::Ready(false),
159 collaborator: Resolved::Warming,
160 owner: Resolved::Warming,
161 }
162 }
163
164 fn admin(mut self, who: &str) -> Self {
165 self.admins.insert(acc(who));
166 self
167 }
168
169 fn open(mut self) -> Self {
170 self.admission = AdmissionPolicy::Open;
171 self
172 }
173
174 fn member(mut self, resolved: Resolved<bool>) -> Self {
175 self.member = resolved;
176 self
177 }
178
179 fn blocked(mut self, resolved: Resolved<bool>) -> Self {
180 self.blocked = resolved;
181 self
182 }
183
184 fn collaborator(mut self, resolved: Resolved<bool>) -> Self {
185 self.collaborator = resolved;
186 self
187 }
188
189 fn owner(mut self, resolved: Resolved<Option<OwnerDid>>) -> Self {
190 self.owner = resolved;
191 self
192 }
193 }
194
195 impl Acl for Fake {
196 fn is_admin(&self, who: &AccountDid) -> bool {
197 self.admins.contains(who)
198 }
199
200 fn admission(&self) -> AdmissionPolicy {
201 self.admission
202 }
203
204 fn is_member(&self, _who: &AccountDid) -> Resolved<bool> {
205 self.member.clone()
206 }
207
208 fn is_blocked(&self, _who: &AccountDid) -> Resolved<bool> {
209 self.blocked.clone()
210 }
211
212 fn is_collaborator(&self, _repo: &RepoDid, _who: &AccountDid) -> Resolved<bool> {
213 self.collaborator.clone()
214 }
215
216 fn repo_owner(&self, _repo: &RepoDid) -> Resolved<Option<OwnerDid>> {
217 self.owner.clone()
218 }
219 }
220
221 #[test]
222 fn an_admin_administers_and_creates_but_does_not_push_arbitrary_repos() {
223 let acl = Fake::new()
224 .admin("nel")
225 .owner(Resolved::Ready(Some(owner("olaren"))))
226 .collaborator(Resolved::Ready(false));
227 assert_eq!(can_admin_knot(&acl, &acc("nel")), Decision::Allow);
228 assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow);
229 assert_eq!(
230 can_push(&acl, &acc("nel"), &repo("squid")),
231 Decision::Deny,
232 "knot admin has no push on repo it neither owns nor collaborates on"
233 );
234 }
235
236 #[test]
237 fn a_member_creates_repos_but_cannot_administer_the_knot() {
238 let acl = Fake::new().member(Resolved::Ready(true));
239 assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Allow);
240 assert_eq!(can_admin_knot(&acl, &acc("olaren")), Decision::Deny);
241 }
242
243 #[test]
244 fn an_open_knot_admits_a_non_member_stranger() {
245 let acl = Fake::new().open().member(Resolved::Ready(false));
246 assert_eq!(
247 can_create_repo(&acl, &acc("teq")),
248 Decision::Allow,
249 "open admission lets anyone with valid identity create a repo"
250 );
251 }
252
253 #[test]
254 fn an_open_knot_does_not_widen_push_to_strangers() {
255 let acl = Fake::new()
256 .open()
257 .owner(Resolved::Ready(Some(owner("nel"))))
258 .collaborator(Resolved::Ready(false));
259 assert_eq!(
260 can_push(&acl, &acc("teq"), &repo("squid")),
261 Decision::Deny,
262 "open admission governs creation. Push stays owner-or-collaborator"
263 );
264 }
265
266 #[test]
267 fn a_blocked_account_cannot_create_even_on_an_open_knot() {
268 let acl = Fake::new()
269 .open()
270 .member(Resolved::Ready(true))
271 .blocked(Resolved::Ready(true));
272 assert_eq!(can_create_repo(&acl, &acc("squid")), Decision::Deny);
273 }
274
275 #[test]
276 fn a_blocked_owner_cannot_push_or_invite() {
277 let acl = Fake::new()
278 .owner(Resolved::Ready(Some(owner("squid"))))
279 .collaborator(Resolved::Ready(false))
280 .blocked(Resolved::Ready(true));
281 assert_eq!(
282 can_push(&acl, &acc("squid"), &repo("anemone")),
283 Decision::Deny,
284 "ban overrides ownership on write path"
285 );
286 assert_eq!(
287 can_manage_collaborators(&acl, &acc("squid"), &repo("anemone")),
288 Decision::Deny
289 );
290 }
291
292 #[test]
293 fn an_admin_is_immune_to_the_blocklist() {
294 let acl = Fake::new()
295 .open()
296 .admin("nel")
297 .blocked(Resolved::Ready(true));
298 assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow);
299 }
300
301 #[test]
302 fn a_warming_blocklist_fails_create_and_push_closed() {
303 let acl = Fake::new()
304 .open()
305 .blocked(Resolved::Warming)
306 .owner(Resolved::Ready(Some(owner("squid"))));
307 assert_eq!(
308 can_create_repo(&acl, &acc("squid")),
309 Decision::Deny,
310 "unresolved blocklist must not admit, ban could be hiding in it"
311 );
312 assert_eq!(
313 can_push(&acl, &acc("squid"), &repo("anemone")),
314 Decision::Deny
315 );
316 }
317
318 #[test]
319 fn the_repo_owner_pushes() {
320 let acl = Fake::new()
321 .owner(Resolved::Ready(Some(owner("nel"))))
322 .collaborator(Resolved::Ready(false));
323 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Allow);
324 }
325
326 #[test]
327 fn a_collaborator_pushes_without_owning() {
328 let acl = Fake::new()
329 .owner(Resolved::Ready(Some(owner("nel"))))
330 .collaborator(Resolved::Ready(true));
331 assert_eq!(
332 can_push(&acl, &acc("olaren"), &repo("squid")),
333 Decision::Allow
334 );
335 }
336
337 #[test]
338 fn a_stranger_is_denied_everything() {
339 let acl = Fake::new()
340 .member(Resolved::Ready(false))
341 .collaborator(Resolved::Ready(false))
342 .owner(Resolved::Ready(Some(owner("nel"))));
343 assert_eq!(can_admin_knot(&acl, &acc("teq")), Decision::Deny);
344 assert_eq!(can_create_repo(&acl, &acc("teq")), Decision::Deny);
345 assert_eq!(can_push(&acl, &acc("teq"), &repo("squid")), Decision::Deny);
346 }
347
348 #[test]
349 fn a_fully_warming_index_denies_every_index_backed_decision() {
350 let acl = Fake::new();
351 assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Deny);
352 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Deny);
353 }
354
355 #[test]
356 fn an_admin_is_authorized_before_the_projection_warms() {
357 let acl = Fake::new().admin("nel");
358 assert_eq!(
359 can_admin_knot(&acl, &acc("nel")),
360 Decision::Allow,
361 "admin set is config, so it answers while projection is still warming"
362 );
363 assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow);
364 }
365
366 #[test]
367 fn push_allows_on_a_confirmed_collaborator_even_when_the_registry_is_warming() {
368 let acl = Fake::new()
369 .owner(Resolved::Warming)
370 .collaborator(Resolved::Ready(true));
371 assert_eq!(
372 can_push(&acl, &acc("olaren"), &repo("squid")),
373 Decision::Allow,
374 "definitive collaborator grant does not depend on warming registry"
375 );
376 }
377
378 #[test]
379 fn push_allows_a_confirmed_owner_even_when_collaborators_are_warming() {
380 let acl = Fake::new()
381 .owner(Resolved::Ready(Some(owner("nel"))))
382 .collaborator(Resolved::Warming);
383 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Allow);
384 }
385
386 #[test]
387 fn push_denies_when_ownership_is_warming_and_not_a_collaborator() {
388 let acl = Fake::new()
389 .owner(Resolved::Warming)
390 .collaborator(Resolved::Ready(false));
391 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Deny);
392 }
393
394 #[test]
395 fn push_denies_an_unregistered_repo() {
396 let acl = Fake::new()
397 .owner(Resolved::Ready(None))
398 .collaborator(Resolved::Ready(false));
399 assert_eq!(can_push(&acl, &acc("nel"), &repo("squid")), Decision::Deny);
400 }
401
402 #[test]
403 fn push_matches_a_did_web_owner_across_authority_case() {
404 let acl = Fake::new()
405 .owner(Resolved::Ready(Some(
406 OwnerDid::new("did:web:OYSTER.cafe").unwrap(),
407 )))
408 .collaborator(Resolved::Ready(false));
409 assert_eq!(
410 can_push(
411 &acl,
412 &AccountDid::new("did:web:oyster.cafe").unwrap(),
413 &repo("squid")
414 ),
415 Decision::Allow,
416 "did:web owner registered upper-case grants push to same DID offered lower-case"
417 );
418 }
419
420 #[test]
421 fn push_denies_a_did_plc_owner_whose_case_differs() {
422 let acl = Fake::new()
423 .owner(Resolved::Ready(Some(OwnerDid::new("did:plc:ABC").unwrap())))
424 .collaborator(Resolved::Ready(false));
425 assert_eq!(
426 can_push(
427 &acl,
428 &AccountDid::new("did:plc:abc").unwrap(),
429 &repo("squid")
430 ),
431 Decision::Deny,
432 "did:plc is case-sensitive, so case-mismatched pusher is not owner"
433 );
434 }
435
436 #[test]
437 fn is_allowed_reports_the_verdict() {
438 assert!(Decision::Allow.is_allowed());
439 assert!(!Decision::Deny.is_allowed());
440 }
441
442 #[test]
443 fn only_the_repo_owner_manages_collaborators() {
444 let acl = Fake::new()
445 .admin("nel")
446 .owner(Resolved::Ready(Some(owner("olaren"))))
447 .collaborator(Resolved::Ready(true));
448 assert_eq!(
449 can_manage_collaborators(&acl, &acc("olaren"), &repo("squid")),
450 Decision::Allow,
451 "repo owner manages its own collaborators"
452 );
453 assert_eq!(
454 can_manage_collaborators(&acl, &acc("lyna"), &repo("squid")),
455 Decision::Deny,
456 "collaborator cannot manage collaborator set"
457 );
458 assert_eq!(
459 can_manage_collaborators(&acl, &acc("nel"), &repo("squid")),
460 Decision::Deny,
461 "knot admin has no collaborator-invite right on repo it does not own"
462 );
463 }
464
465 #[test]
466 fn manage_collaborators_fails_closed_while_ownership_is_warming() {
467 let acl = Fake::new().owner(Resolved::Warming);
468 assert_eq!(
469 can_manage_collaborators(&acl, &acc("olaren"), &repo("squid")),
470 Decision::Deny
471 );
472 }
473
474 #[test]
475 fn repo_deletion_is_the_owner_or_a_knot_admin() {
476 let acl = Fake::new()
477 .admin("nel")
478 .owner(Resolved::Ready(Some(owner("olaren"))))
479 .collaborator(Resolved::Ready(true));
480 assert_eq!(
481 can_delete_repo(&acl, &acc("olaren"), &repo("squid")),
482 Decision::Allow,
483 "repo owner deletes its own repo"
484 );
485 assert_eq!(
486 can_delete_repo(&acl, &acc("nel"), &repo("squid")),
487 Decision::Allow,
488 "knot admin deletes any repo"
489 );
490 assert_eq!(
491 can_delete_repo(&acl, &acc("lyna"), &repo("squid")),
492 Decision::Deny,
493 "collaborator cannot delete the repo"
494 );
495 }
496
497 #[test]
498 fn an_admin_deletes_a_repo_before_the_registry_warms() {
499 let acl = Fake::new().admin("nel").owner(Resolved::Warming);
500 assert_eq!(
501 can_delete_repo(&acl, &acc("nel"), &repo("squid")),
502 Decision::Allow,
503 "admin is static config, so deletion answers while ownership warms"
504 );
505 }
506
507 mod integration {
508 use super::*;
509 use knot_cob::{CobHome, CobStore};
510 use knot_cobs::{CollaboratorsChange, Grant, MembersChange, Registration, RegistryChange};
511 use knot_git::{Layout, Repo};
512 use knot_runtime::{K256Signer, SeededEntropy};
513 use knot_types::{KnotId, RepoName, RepoRkey, UnixSeconds};
514
515 fn knot_home() -> CobHome {
516 CobHome::from(&KnotId::new("did:web:knot.nel.pet").unwrap())
517 }
518
519 fn grant(subject: &str, at: i64) -> Grant {
520 Grant {
521 subject: acc(subject),
522 added_by: acc("nel"),
523 created_at: UnixSeconds::new(at),
524 }
525 }
526
527 fn registration(
528 owner_id: &str,
529 key: &str,
530 repo_did: &knot_types::RepoDid,
531 at: i64,
532 ) -> Registration {
533 Registration {
534 owner: owner(owner_id),
535 rkey: RepoRkey::new(key).unwrap(),
536 name: RepoName::new(key).unwrap(),
537 repo: repo_did.clone(),
538 created_at: UnixSeconds::new(at),
539 }
540 }
541
542 #[test]
543 fn the_enforcer_decides_over_a_real_rebuilt_index() {
544 let dir = tempfile::tempdir().unwrap();
545 let meta_path = dir.path().join("meta");
546 Repo::create(&meta_path).unwrap();
547 let layout = Layout::new(dir.path().join("repos"));
548 let signer = K256Signer::generate(&SeededEntropy::new(1));
549 let at = UnixSeconds::new;
550
551 let meta = Repo::open(&meta_path).unwrap();
552 let store = CobStore::new(&meta);
553 store
554 .create(
555 &knot_home(),
556 &MembersChange::Add(grant("olaren", 1)),
557 &signer,
558 at(1),
559 )
560 .unwrap();
561 let squid = repo("squid");
562 store
563 .create(
564 &knot_home(),
565 &RegistryChange::Register(registration("nel", "anemone", &squid, 1)),
566 &signer,
567 at(1),
568 )
569 .unwrap();
570 let git = layout.create(&squid).unwrap();
571 CobStore::new(&git)
572 .create(
573 &CobHome::from(&squid),
574 &CollaboratorsChange::Add(grant("lyna", 1)),
575 &signer,
576 at(1),
577 )
578 .unwrap();
579
580 let index = Index::new(&meta_path, layout.clone());
581 index.rebuild().unwrap();
582 index.ensure_collaborators(&squid).unwrap();
583 let admins = BTreeSet::from([acc("nel")]);
584 let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index);
585
586 assert_eq!(can_admin_knot(&acl, &acc("nel")), Decision::Allow);
587 assert_eq!(can_create_repo(&acl, &acc("nel")), Decision::Allow);
588 assert_eq!(
589 can_push(&acl, &acc("nel"), &squid),
590 Decision::Allow,
591 "nel owns squid in the registry"
592 );
593
594 assert_eq!(can_admin_knot(&acl, &acc("olaren")), Decision::Deny);
595 assert_eq!(can_create_repo(&acl, &acc("olaren")), Decision::Allow);
596 assert_eq!(
597 can_push(&acl, &acc("olaren"), &squid),
598 Decision::Deny,
599 "member who is neither owner nor collaborator cannot push"
600 );
601
602 assert_eq!(
603 can_push(&acl, &acc("lyna"), &squid),
604 Decision::Allow,
605 "lyna collaborates on squid"
606 );
607 assert_eq!(can_create_repo(&acl, &acc("lyna")), Decision::Deny);
608
609 assert_eq!(can_push(&acl, &acc("teq"), &squid), Decision::Deny);
610 assert_eq!(can_create_repo(&acl, &acc("teq")), Decision::Deny);
611
612 let cold = Index::new(&meta_path, layout);
613 let cold_acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &cold);
614 assert_eq!(
615 can_admin_knot(&cold_acl, &acc("nel")),
616 Decision::Allow,
617 "admin is config, answered before any rebuild"
618 );
619 assert_eq!(
620 can_push(&cold_acl, &acc("nel"), &squid),
621 Decision::Deny,
622 "before rebuild owner lookup is warming, so push fails closed"
623 );
624 assert_eq!(can_create_repo(&cold_acl, &acc("olaren")), Decision::Deny);
625 }
626
627 #[test]
628 fn a_repo_re_registered_under_a_second_owner_grants_push_only_to_the_later_owner() {
629 let dir = tempfile::tempdir().unwrap();
630 let meta_path = dir.path().join("meta");
631 Repo::create(&meta_path).unwrap();
632 let layout = Layout::new(dir.path().join("repos"));
633 let signer = K256Signer::generate(&SeededEntropy::new(1));
634 let at = UnixSeconds::new;
635
636 let squid = repo("squid");
637 layout.create(&squid).unwrap();
638
639 let meta = Repo::open(&meta_path).unwrap();
640 let store = CobStore::new(&meta);
641 let created = store
642 .create(
643 &knot_home(),
644 &RegistryChange::Register(registration("nel", "anemone", &squid, 1)),
645 &signer,
646 at(1),
647 )
648 .unwrap();
649 store
650 .update(
651 &knot_home(),
652 created.object,
653 &RegistryChange::Register(registration("olaren", "fork", &squid, 2)),
654 &signer,
655 at(2),
656 )
657 .unwrap();
658
659 let index = Index::new(&meta_path, layout);
660 index.rebuild().unwrap();
661 let admins = BTreeSet::new();
662 let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index);
663
664 assert_eq!(
665 can_push(&acl, &acc("nel"), &squid),
666 Decision::Deny,
667 "re-register moves repo wholesale, so displaced owner loses push"
668 );
669 assert_eq!(
670 can_push(&acl, &acc("olaren"), &squid),
671 Decision::Allow,
672 "linear causal order gives later registrant deterministic ownership"
673 );
674 }
675
676 #[test]
677 fn a_collaborator_on_an_unregistered_repo_cannot_push_after_a_real_rebuild() {
678 let dir = tempfile::tempdir().unwrap();
679 let meta_path = dir.path().join("meta");
680 Repo::create(&meta_path).unwrap();
681 let layout = Layout::new(dir.path().join("repos"));
682 let signer = K256Signer::generate(&SeededEntropy::new(1));
683 let at = UnixSeconds::new;
684
685 let squid = repo("squid");
686 let git = layout.create(&squid).unwrap();
687 CobStore::new(&git)
688 .create(
689 &CobHome::from(&squid),
690 &CollaboratorsChange::Add(grant("lyna", 1)),
691 &signer,
692 at(1),
693 )
694 .unwrap();
695
696 let index = Index::new(&meta_path, layout);
697 index.rebuild().unwrap();
698 let admins = BTreeSet::new();
699 let acl = KnotAcl::new(&admins, AdmissionPolicy::Closed, &index);
700 assert_eq!(
701 can_push(&acl, &acc("lyna"), &squid),
702 Decision::Deny,
703 "rebuild folds collaborators only for registered repos, so collaborator COB on unregistered repo never warms and grants no push"
704 );
705 }
706 }
707}