This repository has no description
0

Configure Feed

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

knot2/index: resolve clone path by display name when no rkey

Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>

author did:plc:3fwecdnvtcscjnrx2p4n7a… date (Jul 28, 2026, 10:38 PM +0300) commit 3778d4e9 parent 3e92a8a1 change-id lzlrvmur
+869 -118
+18 -10
knot2/crates/knot-index/src/intern.rs
··· 1 1 use std::sync::Arc; 2 2 3 - use knot_types::{AccountDid, OwnerDid, RepoDid, RepoRkey}; 3 + use knot_types::{AccountDid, OwnerDid, RepoDid, RepoName, RepoRkey}; 4 4 use lasso::{Spur, ThreadedRodeo}; 5 5 6 6 #[derive(Debug, Clone, Default)] ··· 13 13 } 14 14 15 15 macro_rules! interned { 16 - ($( 17 - $key:ident of $value:ty { 18 - $intern:ident, $get:ident, $resolve:ident, $label:literal 19 - } 20 - )+) => {$( 16 + (@lookup $key:ident of $value:ty { $intern:ident, $get:ident }) => { 21 17 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] 22 18 pub(crate) struct $key(Spur); 23 19 ··· 29 25 pub(crate) fn $get(&self, value: &$value) -> Option<$key> { 30 26 self.0.get(value.as_str()).map($key) 31 27 } 28 + } 29 + }; 30 + ($( 31 + $key:ident of $value:ty { 32 + $intern:ident, $get:ident $(, $resolve:ident, $label:literal)? 33 + } 34 + )+) => {$( 35 + interned!(@lookup $key of $value { $intern, $get }); 32 36 33 - pub(crate) fn $resolve(&self, key: $key) -> $value { 34 - <$value>::new(self.0.resolve(&key.0)) 35 - .expect(concat!("interned ", $label, " is valid ", $label)) 37 + $( 38 + impl Interner { 39 + pub(crate) fn $resolve(&self, key: $key) -> $value { 40 + <$value>::new(self.0.resolve(&key.0)) 41 + .expect(concat!("interned ", $label, " is valid ", $label)) 42 + } 36 43 } 37 - } 44 + )? 38 45 )+}; 39 46 } 40 47 ··· 43 50 RepoKey of RepoDid { intern_repo, repo, resolve_repo, "repo DID" } 44 51 OwnerKey of OwnerDid { intern_owner, owner, resolve_owner, "owner DID" } 45 52 RkeyKey of RepoRkey { intern_rkey, rkey, resolve_rkey, "rkey" } 53 + NameKey of RepoName { intern_name, name } 46 54 }
+10 -1
knot2/crates/knot-index/src/lib.rs
··· 16 16 MembersCob, RegistryChange, RepoRegistryCob, 17 17 }; 18 18 use knot_git::{Layout, Repo}; 19 - use knot_types::{AccountDid, OwnerDid, RepoDid, RepoRkey}; 19 + use knot_types::{AccountDid, ClonePath, OwnerDid, RepoDid, RepoRkey}; 20 20 21 21 use intern::Interner; 22 22 use projections::{CollaboratorsProjection, GrantSetProjection, KeyProjection, RegistryProjection}; ··· 197 197 198 198 pub fn resolve_repo(&self, owner: &OwnerDid, rkey: &RepoRkey) -> Resolved<Option<RepoDid>> { 199 199 self.registry.resolve(&self.interner, owner, rkey) 200 + } 201 + 202 + pub fn resolve_clone_path( 203 + &self, 204 + owner: &OwnerDid, 205 + path: &ClonePath, 206 + ) -> Resolved<Option<RepoDid>> { 207 + self.registry 208 + .resolve_clone_path(&self.interner, owner, path) 200 209 } 201 210 202 211 pub fn owner_of(&self, repo: &RepoDid) -> Resolved<Option<OwnerDid>> {
+119 -13
knot2/crates/knot-index/src/projections.rs
··· 9 9 CollaboratorsChange, CollaboratorsCob, Grant, GrantChange, Registration, Registry, 10 10 RegistryChange, Rename, RepoRef, RepoRegistryCob, Roster, 11 11 }; 12 - use knot_types::{AccountDid, OfferedKey, OwnerDid, RepoDid, RepoRkey, UnixSeconds}; 12 + use knot_types::{AccountDid, ClonePath, OfferedKey, OwnerDid, RepoDid, RepoRkey, UnixSeconds}; 13 13 14 14 use crate::coverage::{Coverage, CoverageCell, Resolved}; 15 15 use crate::error::IndexError; 16 - use crate::intern::{AccountKey, Interner, OwnerKey, RepoKey, RkeyKey}; 16 + use crate::intern::{AccountKey, Interner, NameKey, OwnerKey, RepoKey, RkeyKey}; 17 17 18 18 const KEY_CACHE_CAPACITY: usize = 16_384; 19 19 ··· 414 414 struct RecordSlot { 415 415 owner: OwnerKey, 416 416 rkey: RkeyKey, 417 + name: NameKey, 418 + created_at: UnixSeconds, 417 419 } 418 420 419 421 pub(crate) struct RegistryProjection { 420 422 aliases: scc::HashMap<(OwnerKey, RkeyKey), RepoKey>, 423 + names: scc::HashMap<(OwnerKey, NameKey), BTreeSet<(UnixSeconds, RepoKey)>>, 421 424 records: scc::HashMap<RepoKey, RecordSlot>, 422 425 coverage: CoverageCell, 423 426 tip: Mutex<Option<ChangeId>>, ··· 427 430 pub(crate) fn new() -> Self { 428 431 Self { 429 432 aliases: scc::HashMap::new(), 433 + names: scc::HashMap::new(), 430 434 records: scc::HashMap::new(), 431 435 coverage: CoverageCell::new(Coverage::Warming), 432 436 tip: Mutex::new(None), ··· 444 448 .unwrap_or_else(|poisoned| poisoned.into_inner()); 445 449 let evacuated = self.hosted_repos(interner); 446 450 self.aliases.clear_sync(); 451 + self.names.clear_sync(); 447 452 self.records.clear_sync(); 448 453 *tip = None; 449 454 self.coverage.set(Coverage::Ready); ··· 468 473 } 469 474 } 470 475 476 + pub(crate) fn resolve_clone_path( 477 + &self, 478 + interner: &Interner, 479 + owner: &OwnerDid, 480 + path: &ClonePath, 481 + ) -> Resolved<Option<RepoDid>> { 482 + if self.coverage.get() == Coverage::Warming { 483 + return Resolved::Warming; 484 + } 485 + let Some(owner) = interner.owner(owner) else { 486 + return Resolved::Ready(None); 487 + }; 488 + let by_rkey = path 489 + .rkeys() 490 + .filter_map(|rkey| interner.rkey(rkey)) 491 + .find_map(|rkey| self.aliases.read_sync(&(owner, rkey), |_, repo| *repo)); 492 + if let Some(repo) = by_rkey { 493 + return Resolved::Ready(Some(interner.resolve_repo(repo))); 494 + } 495 + Resolved::Ready( 496 + path.names() 497 + .filter_map(|name| interner.name(name)) 498 + .find_map(|name| self.oldest_registration_for_name(interner, owner, name)), 499 + ) 500 + } 501 + 502 + fn oldest_registration_for_name( 503 + &self, 504 + interner: &Interner, 505 + owner: OwnerKey, 506 + name: NameKey, 507 + ) -> Option<RepoDid> { 508 + // Notice how set keys sort by whichever string the interner saw first, 509 + // and a cold rebuild will see them in a different order than live replay, 510 + // so 2 entries with the same timestamp will settle by comparing 511 + // DIDs instead. 512 + self.names 513 + .read_sync(&(owner, name), |_, registered| { 514 + let earliest = registered.first()?.0; 515 + registered 516 + .iter() 517 + .take_while(|(created_at, _)| *created_at == earliest) 518 + .map(|(_, repo)| interner.resolve_repo(*repo)) 519 + .min() 520 + }) 521 + .flatten() 522 + } 523 + 471 524 pub(crate) fn owner_of( 472 525 &self, 473 526 interner: &Interner, ··· 549 602 550 603 fn seed(&self, interner: &Interner, registry: &Registry) { 551 604 self.aliases.clear_sync(); 605 + self.names.clear_sync(); 552 606 self.records.clear_sync(); 553 607 registry.records().for_each(|(repo, record)| { 608 + let repo = interner.intern_repo(repo); 609 + let owner = interner.intern_owner(&record.owner); 610 + let name = interner.intern_name(&record.name); 554 611 self.upsert_record( 555 - interner.intern_repo(repo), 556 - interner.intern_owner(&record.owner), 612 + repo, 613 + owner, 557 614 interner.intern_rkey(&record.rkey), 615 + name, 616 + record.created_at, 558 617 ); 618 + self.bind_name(owner, name, record.created_at, repo); 559 619 }); 560 620 registry.aliases().for_each(|(owner, rkey, repo)| { 561 621 self.upsert_alias( ··· 612 672 let repo = interner.intern_repo(&registration.repo); 613 673 let owner = interner.intern_owner(&registration.owner); 614 674 let rkey = interner.intern_rkey(&registration.rkey); 675 + let name = interner.intern_name(&registration.name); 615 676 if self.records.contains_sync(&repo) { 616 677 self.drop_record(repo); 617 678 displaced.push(repo); 618 679 } 619 680 displaced.extend(self.steal_alias(owner, rkey, repo)); 620 - self.upsert_record(repo, owner, rkey); 681 + self.upsert_record(repo, owner, rkey, name, registration.created_at); 621 682 self.upsert_alias(owner, rkey, repo); 683 + self.bind_name(owner, name, registration.created_at, repo); 622 684 displaced 623 685 } 624 686 ··· 631 693 let repo = interner.intern_repo(&rename.repo); 632 694 let owner = interner.intern_owner(&rename.owner); 633 695 let rkey = interner.intern_rkey(&rename.rkey); 696 + let name = interner.intern_name(&rename.name); 634 697 let held = self 635 698 .records 636 - .read_sync(&repo, |_, slot| slot.owner == owner) 637 - .unwrap_or(false); 638 - if !held { 699 + .read_sync(&repo, |_, slot| { 700 + (slot.owner == owner).then_some((slot.created_at, slot.name)) 701 + }) 702 + .flatten(); 703 + let Some((created_at, previous)) = held else { 639 704 return displaced; 640 - } 705 + }; 641 706 displaced.extend(self.steal_alias(owner, rkey, repo)); 642 - self.upsert_record(repo, owner, rkey); 707 + self.upsert_record(repo, owner, rkey, name, created_at); 643 708 self.upsert_alias(owner, rkey, repo); 709 + self.bind_name(owner, name, created_at, repo); 710 + if previous != name { 711 + self.unbind_name(owner, previous, created_at, repo); 712 + } 644 713 displaced 645 714 } 646 715 ··· 683 752 } 684 753 685 754 fn drop_record(&self, repo: RepoKey) { 686 - let _ = self.records.remove_sync(&repo); 755 + if let Some((_, slot)) = self.records.remove_sync(&repo) { 756 + self.unbind_name(slot.owner, slot.name, slot.created_at, repo); 757 + } 687 758 self.aliases.retain_sync(|_, holder| *holder != repo); 688 759 } 689 760 690 - fn upsert_record(&self, repo: RepoKey, owner: OwnerKey, rkey: RkeyKey) { 761 + fn bind_name(&self, owner: OwnerKey, name: NameKey, created_at: UnixSeconds, repo: RepoKey) { 762 + match self.names.entry_sync((owner, name)) { 763 + scc::hash_map::Entry::Occupied(mut occupied) => { 764 + occupied.get_mut().insert((created_at, repo)); 765 + } 766 + scc::hash_map::Entry::Vacant(vacant) => { 767 + vacant.insert_entry(BTreeSet::from([(created_at, repo)])); 768 + } 769 + } 770 + } 771 + 772 + fn unbind_name(&self, owner: OwnerKey, name: NameKey, created_at: UnixSeconds, repo: RepoKey) { 773 + let _ = self.names.remove_if_sync(&(owner, name), |registered| { 774 + registered.remove(&(created_at, repo)); 775 + registered.is_empty() 776 + }); 777 + } 778 + 779 + fn upsert_record( 780 + &self, 781 + repo: RepoKey, 782 + owner: OwnerKey, 783 + rkey: RkeyKey, 784 + name: NameKey, 785 + created_at: UnixSeconds, 786 + ) { 691 787 if self 692 788 .records 693 789 .update_sync(&repo, |_, slot| { 694 790 slot.owner = owner; 695 791 slot.rkey = rkey; 792 + slot.name = name; 793 + slot.created_at = created_at; 696 794 }) 697 795 .is_none() 698 796 { 699 - let _ = self.records.insert_sync(repo, RecordSlot { owner, rkey }); 797 + let _ = self.records.insert_sync( 798 + repo, 799 + RecordSlot { 800 + owner, 801 + rkey, 802 + name, 803 + created_at, 804 + }, 805 + ); 700 806 } 701 807 } 702 808
+16
knot2/crates/knot-index/tests/common/mod.rs
··· 52 52 } 53 53 } 54 54 55 + pub fn named_registration( 56 + owner_id: &str, 57 + key: &str, 58 + display: &str, 59 + repo: &RepoDid, 60 + seconds: i64, 61 + ) -> Registration { 62 + Registration { 63 + owner: own(owner_id), 64 + rkey: rkey(key), 65 + name: RepoName::new(display).unwrap(), 66 + repo: repo.clone(), 67 + created_at: at(seconds), 68 + } 69 + } 70 + 55 71 pub struct World { 56 72 _dir: TempDir, 57 73 pub meta_path: PathBuf,
+508 -2
knot2/crates/knot-index/tests/projections.rs
··· 5 5 use knot_cobs::{CollaboratorsChange, MembersChange, RegistryChange, Removal, Rename, RepoRef}; 6 6 use knot_git::{RefUpdate, Repo}; 7 7 use knot_index::{Coverage, IndexError, OfferedKey, Resolved}; 8 - use knot_types::{RefName, RepoName}; 8 + use knot_types::{ClonePath, RefName, RepoName}; 9 9 use serde::{Deserialize, Serialize}; 10 10 11 11 mod common; 12 - use common::{World, acc, at, grant, meta_home, own, registration, repo_did, rkey}; 12 + use common::{ 13 + World, acc, at, grant, meta_home, named_registration, own, registration, repo_did, rkey, 14 + }; 13 15 14 16 #[derive(Serialize, Deserialize)] 15 17 #[serde(tag = "op", content = "data", rename_all = "snake_case")] ··· 565 567 Resolved::Ready(Some(acc("nel"))) 566 568 ); 567 569 } 570 + 571 + fn path(raw: &str) -> ClonePath { 572 + ClonePath::parse(raw).unwrap() 573 + } 574 + 575 + #[test] 576 + fn a_clone_path_resolves_by_name_when_the_record_key_is_a_tid() { 577 + let world = World::new(); 578 + let repo = repo_did("squid"); 579 + 580 + let meta = Repo::open(&world.meta_path).unwrap(); 581 + let store = CobStore::new(&meta); 582 + store 583 + .create( 584 + &meta_home(), 585 + &RegistryChange::Register(named_registration( 586 + "nel", 587 + "3mizfnpxii522", 588 + "substratum.cloud", 589 + &repo, 590 + 1, 591 + )), 592 + &world.signer, 593 + at(1), 594 + ) 595 + .unwrap(); 596 + 597 + let index = world.index(); 598 + index.rebuild().unwrap(); 599 + 600 + assert_eq!( 601 + index.resolve_clone_path(&own("nel"), &path("substratum.cloud")), 602 + Resolved::Ready(Some(repo.clone())), 603 + "a PDS-native record key leaves the display name as the only human clone path" 604 + ); 605 + assert_eq!( 606 + index.resolve_clone_path(&own("nel"), &path("3mizfnpxii522")), 607 + Resolved::Ready(Some(repo.clone())), 608 + "the record key still resolves" 609 + ); 610 + assert_eq!( 611 + index.resolve_clone_path(&own("nel"), &path("substratum.cloud.git")), 612 + Resolved::Ready(Some(repo)), 613 + "the conventional .git suffix strips before the name lookup" 614 + ); 615 + assert_eq!( 616 + index.resolve_clone_path(&own("nel"), &path("periwinkle")), 617 + Resolved::Ready(None) 618 + ); 619 + } 620 + 621 + #[test] 622 + fn a_record_key_outranks_another_repos_name() { 623 + let world = World::new(); 624 + let by_name = repo_did("limpet"); 625 + let by_rkey = repo_did("mussel"); 626 + 627 + let meta = Repo::open(&world.meta_path).unwrap(); 628 + let store = CobStore::new(&meta); 629 + let object = store 630 + .create( 631 + &meta_home(), 632 + &RegistryChange::Register(named_registration("nel", "limpet", "mussel", &by_name, 1)), 633 + &world.signer, 634 + at(1), 635 + ) 636 + .unwrap() 637 + .object; 638 + store 639 + .update( 640 + &meta_home(), 641 + object, 642 + &RegistryChange::Register(named_registration("nel", "mussel", "scallop", &by_rkey, 2)), 643 + &world.signer, 644 + at(2), 645 + ) 646 + .unwrap(); 647 + 648 + let index = world.index(); 649 + index.rebuild().unwrap(); 650 + 651 + assert_eq!( 652 + index.resolve_clone_path(&own("nel"), &path("mussel")), 653 + Resolved::Ready(Some(by_rkey)), 654 + "a record key match wins over another repo holding that string as its name" 655 + ); 656 + assert_eq!( 657 + index.resolve_clone_path(&own("nel"), &path("scallop")), 658 + Resolved::Ready(Some(repo_did("mussel"))) 659 + ); 660 + } 661 + 662 + #[test] 663 + fn a_name_two_repos_share_resolves_to_the_older_registration() { 664 + let world = World::new(); 665 + let first = repo_did("whelk"); 666 + let second = repo_did("conch"); 667 + 668 + let meta = Repo::open(&world.meta_path).unwrap(); 669 + let store = CobStore::new(&meta); 670 + let object = store 671 + .create( 672 + &meta_home(), 673 + &RegistryChange::Register(named_registration( 674 + "nel", 675 + "3lubrptx57d22", 676 + "kelp", 677 + &first, 678 + 1, 679 + )), 680 + &world.signer, 681 + at(1), 682 + ) 683 + .unwrap() 684 + .object; 685 + store 686 + .update( 687 + &meta_home(), 688 + object, 689 + &RegistryChange::Register(named_registration( 690 + "nel", 691 + "3mqydma3re27z", 692 + "kelp", 693 + &second, 694 + 2, 695 + )), 696 + &world.signer, 697 + at(2), 698 + ) 699 + .unwrap(); 700 + 701 + let index = world.index(); 702 + index.rebuild().unwrap(); 703 + 704 + assert_eq!( 705 + index.resolve_clone_path(&own("nel"), &path("kelp")), 706 + Resolved::Ready(Some(first.clone())), 707 + "a contested name resolves to whichever repo registered first, ordered by \ 708 + created_at so a cold seed and an incremental replay agree" 709 + ); 710 + assert_eq!( 711 + index.resolve_clone_path(&own("nel"), &path("3lubrptx57d22")), 712 + Resolved::Ready(Some(first)), 713 + "each record key stays unambiguous" 714 + ); 715 + assert_eq!( 716 + index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")), 717 + Resolved::Ready(Some(second)), 718 + "the repo that lost the name is still reachable by its record key" 719 + ); 720 + } 721 + 722 + #[test] 723 + fn a_repo_registered_later_never_takes_a_contested_name_by_renaming_onto_it() { 724 + let world = World::new(); 725 + let holder = repo_did("whelk"); 726 + let latecomer = repo_did("conch"); 727 + 728 + let meta = Repo::open(&world.meta_path).unwrap(); 729 + let store = CobStore::new(&meta); 730 + let object = store 731 + .create( 732 + &meta_home(), 733 + &RegistryChange::Register(named_registration( 734 + "nel", 735 + "3lubrptx57d22", 736 + "kelp", 737 + &holder, 738 + 1, 739 + )), 740 + &world.signer, 741 + at(1), 742 + ) 743 + .unwrap() 744 + .object; 745 + store 746 + .update( 747 + &meta_home(), 748 + object, 749 + &RegistryChange::Register(named_registration( 750 + "nel", 751 + "3mqydma3re27z", 752 + "uni", 753 + &latecomer, 754 + 2, 755 + )), 756 + &world.signer, 757 + at(2), 758 + ) 759 + .unwrap(); 760 + 761 + let index = world.index(); 762 + index.rebuild().unwrap(); 763 + 764 + store 765 + .update( 766 + &meta_home(), 767 + object, 768 + &RegistryChange::Rename(Rename { 769 + owner: own("nel"), 770 + rkey: rkey("3mqydma3re27z"), 771 + name: RepoName::new("kelp").unwrap(), 772 + repo: latecomer.clone(), 773 + }), 774 + &world.signer, 775 + at(3), 776 + ) 777 + .unwrap(); 778 + index.refresh_registry().unwrap(); 779 + 780 + assert_eq!( 781 + index.resolve_clone_path(&own("nel"), &path("kelp")), 782 + Resolved::Ready(Some(holder)), 783 + "a rename keeps the repo's original created_at, so renaming onto a name \ 784 + another repo registered earlier cannot take it" 785 + ); 786 + assert_eq!( 787 + index.resolve_clone_path(&own("nel"), &path("uni")), 788 + Resolved::Ready(None), 789 + "the renamed repo's previous name stops resolving" 790 + ); 791 + assert_eq!( 792 + index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")), 793 + Resolved::Ready(Some(latecomer)) 794 + ); 795 + } 796 + 797 + #[test] 798 + fn deregistering_the_older_registration_moves_a_shared_name_to_the_survivor() { 799 + let world = World::new(); 800 + let first = repo_did("whelk"); 801 + let survivor = repo_did("conch"); 802 + 803 + let meta = Repo::open(&world.meta_path).unwrap(); 804 + let store = CobStore::new(&meta); 805 + let object = store 806 + .create( 807 + &meta_home(), 808 + &RegistryChange::Register(named_registration( 809 + "nel", 810 + "3lubrptx57d22", 811 + "kelp", 812 + &first, 813 + 1, 814 + )), 815 + &world.signer, 816 + at(1), 817 + ) 818 + .unwrap() 819 + .object; 820 + store 821 + .update( 822 + &meta_home(), 823 + object, 824 + &RegistryChange::Register(named_registration( 825 + "nel", 826 + "3mqydma3re27z", 827 + "kelp", 828 + &survivor, 829 + 2, 830 + )), 831 + &world.signer, 832 + at(2), 833 + ) 834 + .unwrap(); 835 + 836 + let index = world.index(); 837 + index.rebuild().unwrap(); 838 + assert_eq!( 839 + index.resolve_clone_path(&own("nel"), &path("kelp")), 840 + Resolved::Ready(Some(first)), 841 + "the name resolves to the older registration while both exist" 842 + ); 843 + 844 + store 845 + .update( 846 + &meta_home(), 847 + object, 848 + &RegistryChange::Deregister(RepoRef { 849 + owner: own("nel"), 850 + rkey: rkey("3lubrptx57d22"), 851 + }), 852 + &world.signer, 853 + at(3), 854 + ) 855 + .unwrap(); 856 + index.refresh_registry().unwrap(); 857 + 858 + assert_eq!( 859 + index.resolve_clone_path(&own("nel"), &path("kelp")), 860 + Resolved::Ready(Some(survivor)), 861 + "the name resolves to the remaining repo once the older one is deregistered" 862 + ); 863 + } 864 + 865 + #[test] 866 + fn a_rename_moves_name_resolution_off_the_old_name() { 867 + let world = World::new(); 868 + let repo = repo_did("squid"); 869 + 870 + let meta = Repo::open(&world.meta_path).unwrap(); 871 + let store = CobStore::new(&meta); 872 + let object = store 873 + .create( 874 + &meta_home(), 875 + &RegistryChange::Register(named_registration( 876 + "nel", 877 + "3mizfnpxii522", 878 + "anemone", 879 + &repo, 880 + 1, 881 + )), 882 + &world.signer, 883 + at(1), 884 + ) 885 + .unwrap() 886 + .object; 887 + 888 + let index = world.index(); 889 + index.rebuild().unwrap(); 890 + assert_eq!( 891 + index.resolve_clone_path(&own("nel"), &path("anemone")), 892 + Resolved::Ready(Some(repo.clone())) 893 + ); 894 + 895 + store 896 + .update( 897 + &meta_home(), 898 + object, 899 + &RegistryChange::Rename(Rename { 900 + owner: own("nel"), 901 + rkey: rkey("3mizfnpxii522"), 902 + name: RepoName::new("barnacle").unwrap(), 903 + repo: repo.clone(), 904 + }), 905 + &world.signer, 906 + at(2), 907 + ) 908 + .unwrap(); 909 + index.refresh_registry().unwrap(); 910 + 911 + assert_eq!( 912 + index.resolve_clone_path(&own("nel"), &path("barnacle")), 913 + Resolved::Ready(Some(repo.clone())), 914 + "the new name resolves after a rename that keeps the record key" 915 + ); 916 + assert_eq!( 917 + index.resolve_clone_path(&own("nel"), &path("anemone")), 918 + Resolved::Ready(None), 919 + "the superseded name stops resolving" 920 + ); 921 + assert_eq!( 922 + index.resolve_clone_path(&own("nel"), &path("3mizfnpxii522")), 923 + Resolved::Ready(Some(repo)) 924 + ); 925 + } 926 + 927 + #[test] 928 + fn a_rename_that_changes_only_the_record_key_keeps_the_name_resolving() { 929 + let world = World::new(); 930 + let repo = repo_did("squid"); 931 + 932 + let meta = Repo::open(&world.meta_path).unwrap(); 933 + let store = CobStore::new(&meta); 934 + let object = store 935 + .create( 936 + &meta_home(), 937 + &RegistryChange::Register(named_registration("nel", "3lubrptx57d22", "kelp", &repo, 1)), 938 + &world.signer, 939 + at(1), 940 + ) 941 + .unwrap() 942 + .object; 943 + 944 + let index = world.index(); 945 + index.rebuild().unwrap(); 946 + 947 + store 948 + .update( 949 + &meta_home(), 950 + object, 951 + &RegistryChange::Rename(Rename { 952 + owner: own("nel"), 953 + rkey: rkey("3mqydma3re27z"), 954 + name: RepoName::new("kelp").unwrap(), 955 + repo: repo.clone(), 956 + }), 957 + &world.signer, 958 + at(2), 959 + ) 960 + .unwrap(); 961 + index.refresh_registry().unwrap(); 962 + 963 + assert_eq!( 964 + index.resolve_clone_path(&own("nel"), &path("kelp")), 965 + Resolved::Ready(Some(repo.clone())), 966 + "the unchanged name survives a rename that swaps the record key" 967 + ); 968 + assert_eq!( 969 + index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")), 970 + Resolved::Ready(Some(repo.clone())) 971 + ); 972 + assert_eq!( 973 + index.resolve_clone_path(&own("nel"), &path("3lubrptx57d22")), 974 + Resolved::Ready(Some(repo)), 975 + "the superseded record key keeps resolving through its retained alias" 976 + ); 977 + } 978 + 979 + #[test] 980 + fn seeding_and_replaying_agree_on_name_resolution() { 981 + let world = World::new(); 982 + let first = repo_did("whelk"); 983 + let second = repo_did("conch"); 984 + 985 + let meta = Repo::open(&world.meta_path).unwrap(); 986 + let store = CobStore::new(&meta); 987 + let object = store 988 + .create( 989 + &meta_home(), 990 + &RegistryChange::Register(named_registration( 991 + "nel", 992 + "3lubrptx57d22", 993 + "kelp", 994 + &first, 995 + 1, 996 + )), 997 + &world.signer, 998 + at(1), 999 + ) 1000 + .unwrap() 1001 + .object; 1002 + 1003 + let replayed = world.index(); 1004 + replayed.rebuild().unwrap(); 1005 + 1006 + store 1007 + .update( 1008 + &meta_home(), 1009 + object, 1010 + &RegistryChange::Register(named_registration( 1011 + "nel", 1012 + "3mqydma3re27z", 1013 + "uni", 1014 + &second, 1015 + 2, 1016 + )), 1017 + &world.signer, 1018 + at(2), 1019 + ) 1020 + .unwrap(); 1021 + replayed.refresh_registry().unwrap(); 1022 + 1023 + let seeded = world.index(); 1024 + seeded.rebuild().unwrap(); 1025 + 1026 + ["kelp", "uni", "3lubrptx57d22", "3mqydma3re27z", "nautilus"] 1027 + .into_iter() 1028 + .for_each(|segment| { 1029 + assert_eq!( 1030 + replayed.resolve_clone_path(&own("nel"), &path(segment)), 1031 + seeded.resolve_clone_path(&own("nel"), &path(segment)), 1032 + "incremental replay and a cold seed disagree on {segment}" 1033 + ); 1034 + }); 1035 + } 1036 + 1037 + #[test] 1038 + fn a_name_differing_from_its_record_key_only_by_case_resolves() { 1039 + let world = World::new(); 1040 + let repo = repo_did("squid"); 1041 + 1042 + let meta = Repo::open(&world.meta_path).unwrap(); 1043 + let store = CobStore::new(&meta); 1044 + store 1045 + .create( 1046 + &meta_home(), 1047 + &RegistryChange::Register(named_registration( 1048 + "nel", 1049 + "runic_lang", 1050 + "Runic_lang", 1051 + &repo, 1052 + 1, 1053 + )), 1054 + &world.signer, 1055 + at(1), 1056 + ) 1057 + .unwrap(); 1058 + 1059 + let index = world.index(); 1060 + index.rebuild().unwrap(); 1061 + 1062 + assert_eq!( 1063 + index.resolve_clone_path(&own("nel"), &path("Runic_lang")), 1064 + Resolved::Ready(Some(repo.clone())), 1065 + "the appview lowercases the record key but keeps the display name's case, \ 1066 + so the mixed-case path resolves by name" 1067 + ); 1068 + assert_eq!( 1069 + index.resolve_clone_path(&own("nel"), &path("runic_lang")), 1070 + Resolved::Ready(Some(repo)), 1071 + "the lowercased record key still resolves" 1072 + ); 1073 + }
+8 -28
knot2/crates/knot-pack/src/lib.rs
··· 30 30 use knot_resource::{PackSlots, SlotPermit}; 31 31 use knot_runtime::Clock; 32 32 use knot_types::{ 33 - AccountDid, Handle, KnotHostname, OwnerDid, OwnerRef, ParseError, RepoDid, RepoRkey, 33 + AccountDid, ClonePath, Handle, KnotHostname, OwnerDid, OwnerRef, ParseError, RepoDid, 34 34 }; 35 35 use std::sync::Arc; 36 36 use tokio::sync::mpsc; ··· 276 276 #[derive(Debug, Clone, PartialEq, Eq)] 277 277 pub enum RepoTarget { 278 278 Did(RepoDid), 279 - OwnerRkey(OwnerDid, RepoRkey), 279 + OwnerPath(OwnerDid, ClonePath), 280 280 } 281 281 282 282 #[derive(Debug, Clone, PartialEq, Eq)] ··· 287 287 } 288 288 289 289 impl RepoLookup { 290 - pub fn or_else(self, next: impl FnOnce() -> RepoLookup) -> RepoLookup { 291 - match self { 292 - RepoLookup::Unhosted => next(), 293 - decided => decided, 294 - } 295 - } 296 - 297 - pub fn first( 298 - candidates: impl IntoIterator<Item = RepoRkey>, 299 - resolve: impl Fn(RepoRkey) -> RepoLookup, 300 - ) -> RepoLookup { 301 - candidates 302 - .into_iter() 303 - .fold(RepoLookup::Unhosted, |acc, rkey| { 304 - acc.or_else(|| resolve(rkey)) 305 - }) 306 - } 307 - 308 290 pub fn from_resolved<T>( 309 291 resolved: knot_index::Resolved<Option<T>>, 310 292 found: impl FnOnce(T) -> RepoDid, ··· 531 513 owner: &OwnerDid, 532 514 name: &str, 533 515 ) -> Result<RepoDid, PackError> { 534 - lookup_did(RepoLookup::first( 535 - RepoRkey::clone_path_candidates(name), 536 - |rkey| { 537 - state 538 - .resolver 539 - .resolve(&RepoTarget::OwnerRkey(owner.clone(), rkey)) 540 - }, 541 - )) 516 + let path = ClonePath::parse(name).ok_or(PackError::NotFound)?; 517 + lookup_did( 518 + state 519 + .resolver 520 + .resolve(&RepoTarget::OwnerPath(owner.clone(), path)), 521 + ) 542 522 } 543 523 544 524 fn resolve_did_did(state: &PackState, did: &str) -> Result<RepoDid, PackError> {
+1 -1
knot2/crates/knot-pack/tests/common/mod.rs
··· 235 235 pub fn serve_dids() -> Arc<dyn RepoResolver> { 236 236 Arc::new(|target: &RepoTarget| match target { 237 237 RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()), 238 - RepoTarget::OwnerRkey(_, _) => RepoLookup::Unhosted, 238 + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, 239 239 }) 240 240 } 241 241
+8 -4
knot2/crates/knot-pack/tests/git_client.rs
··· 120 120 let plain_did = plain_did.clone(); 121 121 let literal_did = literal_did.clone(); 122 122 Arc::new(move |target: &RepoTarget| match target { 123 - RepoTarget::OwnerRkey(o, n) if *o == owner && n.as_str() == "anemone" => { 124 - RepoLookup::Hosted(plain_did.clone()) 125 - } 126 - RepoTarget::OwnerRkey(o, n) if *o == owner && n.as_str() == "barnacle.git" => { 123 + RepoTarget::OwnerPath(o, p) 124 + if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "barnacle.git") => 125 + { 127 126 RepoLookup::Hosted(literal_did.clone()) 127 + } 128 + RepoTarget::OwnerPath(o, p) 129 + if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "anemone") => 130 + { 131 + RepoLookup::Hosted(plain_did.clone()) 128 132 } 129 133 _ => RepoLookup::Unhosted, 130 134 })
+1 -1
knot2/crates/knot-pack/tests/h3_conformance.rs
··· 53 53 fn serve_dids() -> Arc<dyn RepoResolver> { 54 54 Arc::new(|target: &RepoTarget| match target { 55 55 RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()), 56 - RepoTarget::OwnerRkey(_, _) => RepoLookup::Unhosted, 56 + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, 57 57 }) 58 58 } 59 59
+3 -1
knot2/crates/knot-pack/tests/handle_owner.rs
··· 27 27 let owner = OwnerDid::new("did:plc:nel").unwrap(); 28 28 let repo = RepoDid::new("did:plc:whelk").unwrap(); 29 29 Arc::new(move |target: &RepoTarget| match target { 30 - RepoTarget::OwnerRkey(o, n) if *o == owner && n.as_str() == "squid" => { 30 + RepoTarget::OwnerPath(o, p) 31 + if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "squid") => 32 + { 31 33 RepoLookup::Hosted(repo.clone()) 32 34 } 33 35 RepoTarget::Did(d) if *d == repo => RepoLookup::Hosted(d.clone()),
+1 -1
knot2/crates/knot-pack/tests/serving.rs
··· 18 18 fn serve_dids() -> Arc<dyn RepoResolver> { 19 19 Arc::new(|target: &RepoTarget| match target { 20 20 RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()), 21 - RepoTarget::OwnerRkey(_, _) => RepoLookup::Unhosted, 21 + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, 22 22 }) 23 23 } 24 24
+1 -1
knot2/crates/knot-pack/tests/soak.rs
··· 15 15 fn serve_dids() -> std::sync::Arc<dyn RepoResolver> { 16 16 std::sync::Arc::new(|target: &RepoTarget| match target { 17 17 RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()), 18 - RepoTarget::OwnerRkey(_, _) => RepoLookup::Unhosted, 18 + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, 19 19 }) 20 20 } 21 21
+4 -3
knot2/crates/knot-server/src/main.rs
··· 563 563 knot_pack::RepoTarget::Did(did) => { 564 564 knot_pack::RepoLookup::from_resolved(index.owner_of(did), |_| did.clone()) 565 565 } 566 - knot_pack::RepoTarget::OwnerRkey(owner, rkey) => { 567 - knot_pack::RepoLookup::from_resolved(index.resolve_repo(owner, rkey), |found| found) 568 - } 566 + knot_pack::RepoTarget::OwnerPath(owner, path) => knot_pack::RepoLookup::from_resolved( 567 + index.resolve_clone_path(owner, path), 568 + |found| found, 569 + ), 569 570 }) 570 571 }; 571 572 let receive_advertiser = knot_xrpc::receive_advertiser(Arc::clone(&xrpc_state));
+2 -2
knot2/crates/knot-sim/src/harness.rs
··· 781 781 Resolved::Ready(None) => knot_pack::RepoLookup::Unhosted, 782 782 Resolved::Warming => knot_pack::RepoLookup::Unavailable, 783 783 }, 784 - knot_pack::RepoTarget::OwnerRkey(owner, rkey) => { 785 - match index.resolve_repo(owner, rkey) { 784 + knot_pack::RepoTarget::OwnerPath(owner, path) => { 785 + match index.resolve_clone_path(owner, path) { 786 786 Resolved::Ready(Some(found)) => knot_pack::RepoLookup::Hosted(found), 787 787 Resolved::Ready(None) => knot_pack::RepoLookup::Unhosted, 788 788 Resolved::Warming => knot_pack::RepoLookup::Unavailable,
+1 -1
knot2/crates/knot-sim/tests/h3.rs
··· 128 128 fn serve_dids() -> Arc<dyn RepoResolver> { 129 129 Arc::new(|target: &RepoTarget| match target { 130 130 RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()), 131 - RepoTarget::OwnerRkey(_, _) => RepoLookup::Unhosted, 131 + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, 132 132 }) 133 133 } 134 134
+2 -2
knot2/crates/knot-sim/tests/lfs_roundtrip.rs
··· 393 393 knot_index::Resolved::Ready(None) => knot_pack::RepoLookup::Unhosted, 394 394 knot_index::Resolved::Warming => knot_pack::RepoLookup::Unavailable, 395 395 }, 396 - knot_pack::RepoTarget::OwnerRkey(owner, rkey) => { 397 - match index.resolve_repo(owner, rkey) { 396 + knot_pack::RepoTarget::OwnerPath(owner, path) => { 397 + match index.resolve_clone_path(owner, path) { 398 398 knot_index::Resolved::Ready(Some(found)) => { 399 399 knot_pack::RepoLookup::Hosted(found) 400 400 }
+16 -18
knot2/crates/knot-ssh/src/exec.rs
··· 9 9 use knot_lfs::TransferOp; 10 10 use knot_pack::{PackError, PackLimits, RepoLookup}; 11 11 use knot_runtime::{Clock, HttpTransport}; 12 - use knot_types::{AccountDid, ObjectFormat, OfferedKey, OwnerDid, RepoDid, RepoRkey}; 12 + use knot_types::{AccountDid, ClonePath, ObjectFormat, OfferedKey, OwnerDid, RepoDid}; 13 13 use russh::Channel; 14 14 use russh::server::Msg; 15 15 use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; ··· 49 49 50 50 enum RepoRef { 51 51 Did(RepoDid), 52 - OwnerRkey(OwnerDid, Vec<RepoRkey>), 53 - HandleRkey(knot_types::Handle, Vec<RepoRkey>), 52 + OwnerPath(OwnerDid, ClonePath), 53 + HandlePath(knot_types::Handle, ClonePath), 54 54 } 55 55 56 56 enum ResolvedRef { 57 57 Did(RepoDid), 58 - OwnerRkey(OwnerDid, Vec<RepoRkey>), 58 + OwnerPath(OwnerDid, ClonePath), 59 59 } 60 60 61 61 fn parse_exec(command: &[u8]) -> Option<(Service, RepoRef)> { ··· 86 86 .trim_start_matches('/'); 87 87 match path.split_once('/') { 88 88 Some((owner, name)) => { 89 - let candidates: Vec<RepoRkey> = RepoRkey::clone_path_candidates(name).collect(); 90 - if candidates.is_empty() { 91 - return None; 92 - } 89 + let candidates = ClonePath::parse(name)?; 93 90 match knot_types::OwnerRef::parse(owner)? { 94 - knot_types::OwnerRef::Did(owner) => Some(RepoRef::OwnerRkey(owner, candidates)), 91 + knot_types::OwnerRef::Did(owner) => Some(RepoRef::OwnerPath(owner, candidates)), 95 92 knot_types::OwnerRef::Handle(handle) => { 96 - Some(RepoRef::HandleRkey(handle, candidates)) 93 + Some(RepoRef::HandlePath(handle, candidates)) 97 94 } 98 95 } 99 96 } ··· 107 104 ) -> RepoLookup { 108 105 let candidate = match repo_ref { 109 106 ResolvedRef::Did(did) => RepoLookup::Hosted(did), 110 - ResolvedRef::OwnerRkey(owner, candidates) => RepoLookup::first(candidates, |rkey| { 111 - RepoLookup::from_resolved(state.index.resolve_repo(&owner, &rkey), |found| found) 112 - }), 107 + ResolvedRef::OwnerPath(owner, candidates) => RepoLookup::from_resolved( 108 + state.index.resolve_clone_path(&owner, &candidates), 109 + |found| found, 110 + ), 113 111 }; 114 112 match candidate { 115 113 RepoLookup::Hosted(did) => { ··· 151 149 }; 152 150 let resolved_ref = match repo_ref { 153 151 RepoRef::Did(did) => ResolvedRef::Did(did), 154 - RepoRef::OwnerRkey(owner, candidates) => ResolvedRef::OwnerRkey(owner, candidates), 155 - RepoRef::HandleRkey(owner_handle, candidates) => { 152 + RepoRef::OwnerPath(owner, candidates) => ResolvedRef::OwnerPath(owner, candidates), 153 + RepoRef::HandlePath(owner_handle, candidates) => { 156 154 match state 157 155 .atproto 158 156 .resolve_handle_to_did(&owner_handle) 159 157 .await 160 158 .ok() 161 159 { 162 - Some(did) => ResolvedRef::OwnerRkey(did.into(), candidates), 160 + Some(did) => ResolvedRef::OwnerPath(did.into(), candidates), 163 161 None => { 164 162 fail(channel, &state.catalog.ssh.repo_not_found.text()).await; 165 163 return; ··· 949 947 fn the_repo_path_parser_separates_dids_from_handles() { 950 948 assert!(matches!( 951 949 parse_repo_path("did:plc:nel/squid"), 952 - Some(RepoRef::OwnerRkey(..)) 950 + Some(RepoRef::OwnerPath(..)) 953 951 )); 954 952 assert!(matches!( 955 953 parse_repo_path("nel.pet/squid"), 956 - Some(RepoRef::HandleRkey(..)) 954 + Some(RepoRef::HandlePath(..)) 957 955 )); 958 956 assert!(matches!( 959 957 parse_repo_path("did:plc:barnacle"),
+71 -2
knot2/crates/knot-ssh/tests/ssh_push.rs
··· 24 24 const REPO_DID: &str = "did:plc:squid"; 25 25 const REPO_NAME: &str = "anemone"; 26 26 const OWNER_DID: &str = "did:plc:nel"; 27 + const TID_REPO_DID: &str = "did:plc:limpet"; 28 + const TID_RKEY: &str = "3mizfnpxii522"; 29 + const TID_REPO_NAME: &str = "periwinkle.cloud"; 27 30 const PDS_HOST: &str = "pds.oyster.cafe"; 28 31 29 32 fn git(cwd: &Path, env: &[(&str, &str)], args: &[&str]) -> (bool, String) { ··· 140 143 list_records_body(&[&published_line]) 141 144 } else if path.ends_with(REPO_DID) { 142 145 did_document(&signer, REPO_DID, &pds) 146 + } else if path.ends_with(TID_REPO_DID) { 147 + did_document(&signer, TID_REPO_DID, &pds) 143 148 } else if path.ends_with(OWNER_DID) { 144 149 did_document(&signer, OWNER_DID, &pds) 145 150 } else { ··· 225 230 let signer = K256Signer::generate(&SeededEntropy::new(2)); 226 231 let meta = Repo::open(&meta_path).unwrap(); 227 232 let store = CobStore::new(&meta); 228 - store 233 + let home = CobHome::from(&KnotId::new("did:web:nel.pet").unwrap()); 234 + let registry = store 229 235 .create( 230 - &CobHome::from(&KnotId::new("did:web:nel.pet").unwrap()), 236 + &home, 231 237 &RegistryChange::Register(Registration { 232 238 owner: OwnerDid::new(OWNER_DID).unwrap(), 233 239 rkey: RepoRkey::new(REPO_NAME).unwrap(), ··· 237 243 }), 238 244 &signer, 239 245 UnixSeconds::new(1), 246 + ) 247 + .unwrap() 248 + .object; 249 + 250 + let tid_repo_did = RepoDid::new(TID_REPO_DID).unwrap(); 251 + layout.create(&tid_repo_did).unwrap(); 252 + store 253 + .update( 254 + &home, 255 + registry, 256 + &RegistryChange::Register(Registration { 257 + owner: OwnerDid::new(OWNER_DID).unwrap(), 258 + rkey: RepoRkey::new(TID_RKEY).unwrap(), 259 + name: RepoName::new(TID_REPO_NAME).unwrap(), 260 + repo: tid_repo_did, 261 + created_at: UnixSeconds::new(2), 262 + }), 263 + &signer, 264 + UnixSeconds::new(2), 240 265 ) 241 266 .unwrap(); 242 267 ··· 536 561 ); 537 562 }) 538 563 .await; 564 + } 565 + 566 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 567 + async fn a_display_name_addresses_a_repo_whose_record_key_is_a_tid() { 568 + let fx = fixture().await; 569 + let head = seed_work(&fx.work); 570 + let head_oid = Oid::from_hex(&head).unwrap(); 571 + let port = fx.server.port; 572 + let target = RepoDid::new(TID_REPO_DID).unwrap(); 573 + let variants = [ 574 + format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{TID_REPO_NAME}"), 575 + format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{TID_REPO_NAME}.git"), 576 + format!("ssh://git@127.0.0.1:{port}/nel.pet/{TID_REPO_NAME}"), 577 + format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{TID_RKEY}"), 578 + ]; 579 + let fx = &fx; 580 + let target = &target; 581 + futures::stream::iter(variants) 582 + .for_each(|url| async move { 583 + let (ok, out) = push(&fx.work, &url, &fx.key_path, &["main"]).await; 584 + assert!( 585 + ok, 586 + "a PDS-minted record key leaves the display name as the only human \ 587 + path, so {url} must resolve and push:\n{out}" 588 + ); 589 + assert_eq!( 590 + main_tip(&fx.server.layout, target), 591 + Some(head_oid), 592 + "{url}: pushed commit must be the named repository's main tip" 593 + ); 594 + }) 595 + .await; 596 + 597 + let (ok, out) = push( 598 + &fx.work, 599 + &format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/whelk"), 600 + &fx.key_path, 601 + &["main"], 602 + ) 603 + .await; 604 + assert!( 605 + !ok, 606 + "a segment matching neither a record key nor a name stays unresolvable:\n{out}" 607 + ); 539 608 } 540 609 541 610 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+65 -13
knot2/crates/knot-types/src/ids.rs
··· 542 542 } 543 543 } 544 544 545 - impl RepoRkey { 546 - pub fn clone_path_candidates(raw: &str) -> impl Iterator<Item = RepoRkey> + '_ { 547 - std::iter::once(raw) 548 - .chain(raw.strip_suffix(".git")) 549 - .filter_map(|candidate| Self::new(candidate).ok()) 545 + #[derive(Debug, Clone, PartialEq, Eq)] 546 + pub struct ClonePath { 547 + rkeys: Vec<RepoRkey>, 548 + names: Vec<RepoName>, 549 + } 550 + 551 + impl ClonePath { 552 + pub fn parse(raw: &str) -> Option<Self> { 553 + let segments = || std::iter::once(raw).chain(raw.strip_suffix(".git")); 554 + let rkeys: Vec<RepoRkey> = segments() 555 + .filter_map(|segment| RepoRkey::new(segment).ok()) 556 + .collect(); 557 + let names: Vec<RepoName> = segments() 558 + .filter_map(|segment| RepoName::new(segment).ok()) 559 + .collect(); 560 + (!rkeys.is_empty() || !names.is_empty()).then_some(Self { rkeys, names }) 561 + } 562 + 563 + pub fn rkeys(&self) -> impl Iterator<Item = &RepoRkey> { 564 + self.rkeys.iter() 565 + } 566 + 567 + pub fn names(&self) -> impl Iterator<Item = &RepoName> { 568 + self.names.iter() 550 569 } 551 570 } 552 571 ··· 1020 1039 } 1021 1040 1022 1041 #[test] 1023 - fn clone_path_candidates_try_the_exact_rkey_before_the_stripped_one() { 1024 - let suffixed: Vec<RepoRkey> = RepoRkey::clone_path_candidates("anemone.git").collect(); 1042 + fn clone_paths_try_the_exact_segment_before_the_stripped_one() { 1043 + let suffixed = ClonePath::parse("anemone.git").unwrap(); 1025 1044 assert_eq!( 1026 - suffixed, 1045 + suffixed.rkeys().cloned().collect::<Vec<_>>(), 1027 1046 vec![ 1028 1047 RepoRkey::new("anemone.git").unwrap(), 1029 1048 RepoRkey::new("anemone").unwrap() 1030 1049 ], 1031 1050 "literal .git rkey wins over conventional suffix interpretation" 1051 + ); 1052 + assert_eq!( 1053 + suffixed.names().cloned().collect::<Vec<_>>(), 1054 + vec![ 1055 + RepoName::new("anemone.git").unwrap(), 1056 + RepoName::new("anemone").unwrap() 1057 + ] 1032 1058 ); 1033 1059 1034 - let plain: Vec<RepoRkey> = RepoRkey::clone_path_candidates("anemone").collect(); 1035 - assert_eq!(plain, vec![RepoRkey::new("anemone").unwrap()]); 1060 + let plain = ClonePath::parse("anemone").unwrap(); 1061 + assert_eq!( 1062 + plain.rkeys().cloned().collect::<Vec<_>>(), 1063 + vec![RepoRkey::new("anemone").unwrap()] 1064 + ); 1036 1065 1037 - let bare: Vec<RepoRkey> = RepoRkey::clone_path_candidates(".git").collect(); 1066 + let bare = ClonePath::parse(".git").unwrap(); 1038 1067 assert_eq!( 1039 - bare, 1068 + bare.rkeys().cloned().collect::<Vec<_>>(), 1040 1069 vec![RepoRkey::new(".git").unwrap()], 1041 1070 "stripping .git from bare suffix leaves nothing valid to try" 1042 1071 ); 1043 1072 1044 - assert_eq!(RepoRkey::clone_path_candidates("a/b.git").count(), 0); 1073 + assert!(ClonePath::parse("a/b.git").is_none()); 1074 + } 1075 + 1076 + #[test] 1077 + fn clone_paths_keep_segments_only_valid_as_one_of_the_two_kinds() { 1078 + let plus = ClonePath::parse("c++").unwrap(); 1079 + assert_eq!( 1080 + plus.rkeys().count(), 1081 + 0, 1082 + "a record key allows only [A-Za-z0-9._~:-]" 1083 + ); 1084 + assert_eq!( 1085 + plus.names().cloned().collect::<Vec<_>>(), 1086 + vec![RepoName::new("c++").unwrap()], 1087 + "a repo name accepts the wider charset, so the segment resolves by name" 1088 + ); 1089 + 1090 + let long = "x".repeat(200); 1091 + let overlong = ClonePath::parse(&long).unwrap(); 1092 + assert_eq!(overlong.names().count(), 0, "a repo name is at most 100"); 1093 + assert_eq!( 1094 + overlong.rkeys().cloned().collect::<Vec<_>>(), 1095 + vec![RepoRkey::new(&long).unwrap()] 1096 + ); 1045 1097 } 1046 1098 1047 1099 #[test]
+3 -3
knot2/crates/knot-types/src/lib.rs
··· 6 6 7 7 mod ids; 8 8 pub use ids::{ 9 - AccountDid, ActorId, AppviewEndpoint, AuthorName, BranchName, ChangeId, CiLogsAddr, CobId, 10 - Email, HttpStatus, KnotHostname, KnotId, KnotServiceUrl, LanguageBytes, LanguageName, LogsHost, 11 - LogsPort, ObjectCount, ObjectFormat, OfferedKey, Oid, OwnerDid, OwnerRef, ParseError, 9 + AccountDid, ActorId, AppviewEndpoint, AuthorName, BranchName, ChangeId, CiLogsAddr, ClonePath, 10 + CobId, Email, HttpStatus, KnotHostname, KnotId, KnotServiceUrl, LanguageBytes, LanguageName, 11 + LogsHost, LogsPort, ObjectCount, ObjectFormat, OfferedKey, Oid, OwnerDid, OwnerRef, ParseError, 12 12 PushOption, PushOptions, RefName, RefTransition, RepoDid, RepoName, RepoPath, RepoRkey, 13 13 ServiceDid, TagName, TypeName, UnixMicros, UnixSeconds, 14 14 };
+11 -11
knot2/crates/knot-xrpc/src/lib.rs
··· 64 64 use knot_runtime::{Clock, Entropy, HttpTransport}; 65 65 use knot_secrets::SealedStore; 66 66 use knot_types::{ 67 - AccountDid, AdmissionPolicy, AppviewEndpoint, CiLogsAddr, KnotHostname, KnotId, KnotServiceUrl, 68 - Nsid, OwnerDid, OwnerRef, RepoDid, RepoRkey, UnixSeconds, 67 + AccountDid, AdmissionPolicy, AppviewEndpoint, CiLogsAddr, ClonePath, KnotHostname, KnotId, 68 + KnotServiceUrl, Nsid, OwnerDid, OwnerRef, RepoDid, UnixSeconds, 69 69 }; 70 70 71 71 use base64::Engine; ··· 578 578 name: &RepoNameSegment, 579 579 ) -> Result<RepoDid, XrpcError> { 580 580 let owner = resolve_owner_segment(state, owner).await?; 581 - RepoRkey::clone_path_candidates(name.as_str()) 582 - .find_map(|rkey| match state.index.resolve_repo(&owner, &rkey) { 583 - Resolved::Ready(Some(did)) => Some(Ok(did)), 584 - Resolved::Ready(None) => None, 585 - Resolved::Warming => Some(Err(XrpcError::warming( 586 - "registry projection is still warming, retry shortly", 587 - ))), 588 - }) 589 - .unwrap_or_else(|| Err(XrpcError::not_found("repository not found"))) 581 + let path = ClonePath::parse(name.as_str()) 582 + .ok_or_else(|| XrpcError::not_found("repository not found"))?; 583 + match state.index.resolve_clone_path(&owner, &path) { 584 + Resolved::Ready(Some(did)) => Ok(did), 585 + Resolved::Ready(None) => Err(XrpcError::not_found("repository not found")), 586 + Resolved::Warming => Err(XrpcError::warming( 587 + "registry projection is still warming, retry shortly", 588 + )), 589 + } 590 590 } 591 591 592 592 async fn resolve_owner_segment<H: HttpTransport, C: Clock>(