diff --git a/knot2/crates/knot-index/src/intern.rs b/knot2/crates/knot-index/src/intern.rs index e0e01cc2..3b74f801 100644 --- a/knot2/crates/knot-index/src/intern.rs +++ b/knot2/crates/knot-index/src/intern.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use knot_types::{AccountDid, OwnerDid, RepoDid, RepoRkey}; +use knot_types::{AccountDid, OwnerDid, RepoDid, RepoName, RepoRkey}; use lasso::{Spur, ThreadedRodeo}; #[derive(Debug, Clone, Default)] @@ -13,11 +13,7 @@ impl Interner { } macro_rules! interned { - ($( - $key:ident of $value:ty { - $intern:ident, $get:ident, $resolve:ident, $label:literal - } - )+) => {$( + (@lookup $key:ident of $value:ty { $intern:ident, $get:ident }) => { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub(crate) struct $key(Spur); @@ -29,12 +25,23 @@ macro_rules! interned { pub(crate) fn $get(&self, value: &$value) -> Option<$key> { self.0.get(value.as_str()).map($key) } - - pub(crate) fn $resolve(&self, key: $key) -> $value { - <$value>::new(self.0.resolve(&key.0)) - .expect(concat!("interned ", $label, " is valid ", $label)) - } } + }; + ($( + $key:ident of $value:ty { + $intern:ident, $get:ident $(, $resolve:ident, $label:literal)? + } + )+) => {$( + interned!(@lookup $key of $value { $intern, $get }); + + $( + impl Interner { + pub(crate) fn $resolve(&self, key: $key) -> $value { + <$value>::new(self.0.resolve(&key.0)) + .expect(concat!("interned ", $label, " is valid ", $label)) + } + } + )? )+}; } @@ -43,4 +50,5 @@ interned! { RepoKey of RepoDid { intern_repo, repo, resolve_repo, "repo DID" } OwnerKey of OwnerDid { intern_owner, owner, resolve_owner, "owner DID" } RkeyKey of RepoRkey { intern_rkey, rkey, resolve_rkey, "rkey" } + NameKey of RepoName { intern_name, name } } diff --git a/knot2/crates/knot-index/src/lib.rs b/knot2/crates/knot-index/src/lib.rs index a8371452..2a455285 100644 --- a/knot2/crates/knot-index/src/lib.rs +++ b/knot2/crates/knot-index/src/lib.rs @@ -16,7 +16,7 @@ use knot_cobs::{ MembersCob, RegistryChange, RepoRegistryCob, }; use knot_git::{Layout, Repo}; -use knot_types::{AccountDid, OwnerDid, RepoDid, RepoRkey}; +use knot_types::{AccountDid, ClonePath, OwnerDid, RepoDid, RepoRkey}; use intern::Interner; use projections::{CollaboratorsProjection, GrantSetProjection, KeyProjection, RegistryProjection}; @@ -199,6 +199,15 @@ impl Index { self.registry.resolve(&self.interner, owner, rkey) } + pub fn resolve_clone_path( + &self, + owner: &OwnerDid, + path: &ClonePath, + ) -> Resolved> { + self.registry + .resolve_clone_path(&self.interner, owner, path) + } + pub fn owner_of(&self, repo: &RepoDid) -> Resolved> { self.registry.owner_of(&self.interner, repo) } diff --git a/knot2/crates/knot-index/src/projections.rs b/knot2/crates/knot-index/src/projections.rs index 301be051..6905a6b5 100644 --- a/knot2/crates/knot-index/src/projections.rs +++ b/knot2/crates/knot-index/src/projections.rs @@ -9,11 +9,11 @@ use knot_cobs::{ CollaboratorsChange, CollaboratorsCob, Grant, GrantChange, Registration, Registry, RegistryChange, Rename, RepoRef, RepoRegistryCob, Roster, }; -use knot_types::{AccountDid, OfferedKey, OwnerDid, RepoDid, RepoRkey, UnixSeconds}; +use knot_types::{AccountDid, ClonePath, OfferedKey, OwnerDid, RepoDid, RepoRkey, UnixSeconds}; use crate::coverage::{Coverage, CoverageCell, Resolved}; use crate::error::IndexError; -use crate::intern::{AccountKey, Interner, OwnerKey, RepoKey, RkeyKey}; +use crate::intern::{AccountKey, Interner, NameKey, OwnerKey, RepoKey, RkeyKey}; const KEY_CACHE_CAPACITY: usize = 16_384; @@ -414,10 +414,13 @@ fn apply( struct RecordSlot { owner: OwnerKey, rkey: RkeyKey, + name: NameKey, + created_at: UnixSeconds, } pub(crate) struct RegistryProjection { aliases: scc::HashMap<(OwnerKey, RkeyKey), RepoKey>, + names: scc::HashMap<(OwnerKey, NameKey), BTreeSet<(UnixSeconds, RepoKey)>>, records: scc::HashMap, coverage: CoverageCell, tip: Mutex>, @@ -427,6 +430,7 @@ impl RegistryProjection { pub(crate) fn new() -> Self { Self { aliases: scc::HashMap::new(), + names: scc::HashMap::new(), records: scc::HashMap::new(), coverage: CoverageCell::new(Coverage::Warming), tip: Mutex::new(None), @@ -444,6 +448,7 @@ impl RegistryProjection { .unwrap_or_else(|poisoned| poisoned.into_inner()); let evacuated = self.hosted_repos(interner); self.aliases.clear_sync(); + self.names.clear_sync(); self.records.clear_sync(); *tip = None; self.coverage.set(Coverage::Ready); @@ -468,6 +473,54 @@ impl RegistryProjection { } } + pub(crate) fn resolve_clone_path( + &self, + interner: &Interner, + owner: &OwnerDid, + path: &ClonePath, + ) -> Resolved> { + if self.coverage.get() == Coverage::Warming { + return Resolved::Warming; + } + let Some(owner) = interner.owner(owner) else { + return Resolved::Ready(None); + }; + let by_rkey = path + .rkeys() + .filter_map(|rkey| interner.rkey(rkey)) + .find_map(|rkey| self.aliases.read_sync(&(owner, rkey), |_, repo| *repo)); + if let Some(repo) = by_rkey { + return Resolved::Ready(Some(interner.resolve_repo(repo))); + } + Resolved::Ready( + path.names() + .filter_map(|name| interner.name(name)) + .find_map(|name| self.oldest_registration_for_name(interner, owner, name)), + ) + } + + fn oldest_registration_for_name( + &self, + interner: &Interner, + owner: OwnerKey, + name: NameKey, + ) -> Option { + // Notice how set keys sort by whichever string the interner saw first, + // and a cold rebuild will see them in a different order than live replay, + // so 2 entries with the same timestamp will settle by comparing + // DIDs instead. + self.names + .read_sync(&(owner, name), |_, registered| { + let earliest = registered.first()?.0; + registered + .iter() + .take_while(|(created_at, _)| *created_at == earliest) + .map(|(_, repo)| interner.resolve_repo(*repo)) + .min() + }) + .flatten() + } + pub(crate) fn owner_of( &self, interner: &Interner, @@ -549,13 +602,20 @@ impl RegistryProjection { fn seed(&self, interner: &Interner, registry: &Registry) { self.aliases.clear_sync(); + self.names.clear_sync(); self.records.clear_sync(); registry.records().for_each(|(repo, record)| { + let repo = interner.intern_repo(repo); + let owner = interner.intern_owner(&record.owner); + let name = interner.intern_name(&record.name); self.upsert_record( - interner.intern_repo(repo), - interner.intern_owner(&record.owner), + repo, + owner, interner.intern_rkey(&record.rkey), + name, + record.created_at, ); + self.bind_name(owner, name, record.created_at, repo); }); registry.aliases().for_each(|(owner, rkey, repo)| { self.upsert_alias( @@ -612,13 +672,15 @@ impl RegistryProjection { let repo = interner.intern_repo(®istration.repo); let owner = interner.intern_owner(®istration.owner); let rkey = interner.intern_rkey(®istration.rkey); + let name = interner.intern_name(®istration.name); if self.records.contains_sync(&repo) { self.drop_record(repo); displaced.push(repo); } displaced.extend(self.steal_alias(owner, rkey, repo)); - self.upsert_record(repo, owner, rkey); + self.upsert_record(repo, owner, rkey, name, registration.created_at); self.upsert_alias(owner, rkey, repo); + self.bind_name(owner, name, registration.created_at, repo); displaced } @@ -631,16 +693,23 @@ impl RegistryProjection { let repo = interner.intern_repo(&rename.repo); let owner = interner.intern_owner(&rename.owner); let rkey = interner.intern_rkey(&rename.rkey); + let name = interner.intern_name(&rename.name); let held = self .records - .read_sync(&repo, |_, slot| slot.owner == owner) - .unwrap_or(false); - if !held { + .read_sync(&repo, |_, slot| { + (slot.owner == owner).then_some((slot.created_at, slot.name)) + }) + .flatten(); + let Some((created_at, previous)) = held else { return displaced; - } + }; displaced.extend(self.steal_alias(owner, rkey, repo)); - self.upsert_record(repo, owner, rkey); + self.upsert_record(repo, owner, rkey, name, created_at); self.upsert_alias(owner, rkey, repo); + self.bind_name(owner, name, created_at, repo); + if previous != name { + self.unbind_name(owner, previous, created_at, repo); + } displaced } @@ -683,20 +752,57 @@ impl RegistryProjection { } fn drop_record(&self, repo: RepoKey) { - let _ = self.records.remove_sync(&repo); + if let Some((_, slot)) = self.records.remove_sync(&repo) { + self.unbind_name(slot.owner, slot.name, slot.created_at, repo); + } self.aliases.retain_sync(|_, holder| *holder != repo); } - fn upsert_record(&self, repo: RepoKey, owner: OwnerKey, rkey: RkeyKey) { + fn bind_name(&self, owner: OwnerKey, name: NameKey, created_at: UnixSeconds, repo: RepoKey) { + match self.names.entry_sync((owner, name)) { + scc::hash_map::Entry::Occupied(mut occupied) => { + occupied.get_mut().insert((created_at, repo)); + } + scc::hash_map::Entry::Vacant(vacant) => { + vacant.insert_entry(BTreeSet::from([(created_at, repo)])); + } + } + } + + fn unbind_name(&self, owner: OwnerKey, name: NameKey, created_at: UnixSeconds, repo: RepoKey) { + let _ = self.names.remove_if_sync(&(owner, name), |registered| { + registered.remove(&(created_at, repo)); + registered.is_empty() + }); + } + + fn upsert_record( + &self, + repo: RepoKey, + owner: OwnerKey, + rkey: RkeyKey, + name: NameKey, + created_at: UnixSeconds, + ) { if self .records .update_sync(&repo, |_, slot| { slot.owner = owner; slot.rkey = rkey; + slot.name = name; + slot.created_at = created_at; }) .is_none() { - let _ = self.records.insert_sync(repo, RecordSlot { owner, rkey }); + let _ = self.records.insert_sync( + repo, + RecordSlot { + owner, + rkey, + name, + created_at, + }, + ); } } diff --git a/knot2/crates/knot-index/tests/common/mod.rs b/knot2/crates/knot-index/tests/common/mod.rs index 436dfe73..fae12027 100644 --- a/knot2/crates/knot-index/tests/common/mod.rs +++ b/knot2/crates/knot-index/tests/common/mod.rs @@ -52,6 +52,22 @@ pub fn registration(owner_id: &str, key: &str, repo: &RepoDid, seconds: i64) -> } } +pub fn named_registration( + owner_id: &str, + key: &str, + display: &str, + repo: &RepoDid, + seconds: i64, +) -> Registration { + Registration { + owner: own(owner_id), + rkey: rkey(key), + name: RepoName::new(display).unwrap(), + repo: repo.clone(), + created_at: at(seconds), + } +} + pub struct World { _dir: TempDir, pub meta_path: PathBuf, diff --git a/knot2/crates/knot-index/tests/projections.rs b/knot2/crates/knot-index/tests/projections.rs index d146b746..5c043877 100644 --- a/knot2/crates/knot-index/tests/projections.rs +++ b/knot2/crates/knot-index/tests/projections.rs @@ -5,11 +5,13 @@ use knot_cob::{ChangePayload, CobHome, CobId, CobStore}; use knot_cobs::{CollaboratorsChange, MembersChange, RegistryChange, Removal, Rename, RepoRef}; use knot_git::{RefUpdate, Repo}; use knot_index::{Coverage, IndexError, OfferedKey, Resolved}; -use knot_types::{RefName, RepoName}; +use knot_types::{ClonePath, RefName, RepoName}; use serde::{Deserialize, Serialize}; mod common; -use common::{World, acc, at, grant, meta_home, own, registration, repo_did, rkey}; +use common::{ + World, acc, at, grant, meta_home, named_registration, own, registration, repo_did, rkey, +}; #[derive(Serialize, Deserialize)] #[serde(tag = "op", content = "data", rename_all = "snake_case")] @@ -565,3 +567,507 @@ fn key_cache_evicts_least_recently_used() { Resolved::Ready(Some(acc("nel"))) ); } + +fn path(raw: &str) -> ClonePath { + ClonePath::parse(raw).unwrap() +} + +#[test] +fn a_clone_path_resolves_by_name_when_the_record_key_is_a_tid() { + let world = World::new(); + let repo = repo_did("squid"); + + let meta = Repo::open(&world.meta_path).unwrap(); + let store = CobStore::new(&meta); + store + .create( + &meta_home(), + &RegistryChange::Register(named_registration( + "nel", + "3mizfnpxii522", + "substratum.cloud", + &repo, + 1, + )), + &world.signer, + at(1), + ) + .unwrap(); + + let index = world.index(); + index.rebuild().unwrap(); + + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("substratum.cloud")), + Resolved::Ready(Some(repo.clone())), + "a PDS-native record key leaves the display name as the only human clone path" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("3mizfnpxii522")), + Resolved::Ready(Some(repo.clone())), + "the record key still resolves" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("substratum.cloud.git")), + Resolved::Ready(Some(repo)), + "the conventional .git suffix strips before the name lookup" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("periwinkle")), + Resolved::Ready(None) + ); +} + +#[test] +fn a_record_key_outranks_another_repos_name() { + let world = World::new(); + let by_name = repo_did("limpet"); + let by_rkey = repo_did("mussel"); + + let meta = Repo::open(&world.meta_path).unwrap(); + let store = CobStore::new(&meta); + let object = store + .create( + &meta_home(), + &RegistryChange::Register(named_registration("nel", "limpet", "mussel", &by_name, 1)), + &world.signer, + at(1), + ) + .unwrap() + .object; + store + .update( + &meta_home(), + object, + &RegistryChange::Register(named_registration("nel", "mussel", "scallop", &by_rkey, 2)), + &world.signer, + at(2), + ) + .unwrap(); + + let index = world.index(); + index.rebuild().unwrap(); + + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("mussel")), + Resolved::Ready(Some(by_rkey)), + "a record key match wins over another repo holding that string as its name" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("scallop")), + Resolved::Ready(Some(repo_did("mussel"))) + ); +} + +#[test] +fn a_name_two_repos_share_resolves_to_the_older_registration() { + let world = World::new(); + let first = repo_did("whelk"); + let second = repo_did("conch"); + + let meta = Repo::open(&world.meta_path).unwrap(); + let store = CobStore::new(&meta); + let object = store + .create( + &meta_home(), + &RegistryChange::Register(named_registration( + "nel", + "3lubrptx57d22", + "kelp", + &first, + 1, + )), + &world.signer, + at(1), + ) + .unwrap() + .object; + store + .update( + &meta_home(), + object, + &RegistryChange::Register(named_registration( + "nel", + "3mqydma3re27z", + "kelp", + &second, + 2, + )), + &world.signer, + at(2), + ) + .unwrap(); + + let index = world.index(); + index.rebuild().unwrap(); + + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("kelp")), + Resolved::Ready(Some(first.clone())), + "a contested name resolves to whichever repo registered first, ordered by \ + created_at so a cold seed and an incremental replay agree" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("3lubrptx57d22")), + Resolved::Ready(Some(first)), + "each record key stays unambiguous" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")), + Resolved::Ready(Some(second)), + "the repo that lost the name is still reachable by its record key" + ); +} + +#[test] +fn a_repo_registered_later_never_takes_a_contested_name_by_renaming_onto_it() { + let world = World::new(); + let holder = repo_did("whelk"); + let latecomer = repo_did("conch"); + + let meta = Repo::open(&world.meta_path).unwrap(); + let store = CobStore::new(&meta); + let object = store + .create( + &meta_home(), + &RegistryChange::Register(named_registration( + "nel", + "3lubrptx57d22", + "kelp", + &holder, + 1, + )), + &world.signer, + at(1), + ) + .unwrap() + .object; + store + .update( + &meta_home(), + object, + &RegistryChange::Register(named_registration( + "nel", + "3mqydma3re27z", + "uni", + &latecomer, + 2, + )), + &world.signer, + at(2), + ) + .unwrap(); + + let index = world.index(); + index.rebuild().unwrap(); + + store + .update( + &meta_home(), + object, + &RegistryChange::Rename(Rename { + owner: own("nel"), + rkey: rkey("3mqydma3re27z"), + name: RepoName::new("kelp").unwrap(), + repo: latecomer.clone(), + }), + &world.signer, + at(3), + ) + .unwrap(); + index.refresh_registry().unwrap(); + + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("kelp")), + Resolved::Ready(Some(holder)), + "a rename keeps the repo's original created_at, so renaming onto a name \ + another repo registered earlier cannot take it" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("uni")), + Resolved::Ready(None), + "the renamed repo's previous name stops resolving" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")), + Resolved::Ready(Some(latecomer)) + ); +} + +#[test] +fn deregistering_the_older_registration_moves_a_shared_name_to_the_survivor() { + let world = World::new(); + let first = repo_did("whelk"); + let survivor = repo_did("conch"); + + let meta = Repo::open(&world.meta_path).unwrap(); + let store = CobStore::new(&meta); + let object = store + .create( + &meta_home(), + &RegistryChange::Register(named_registration( + "nel", + "3lubrptx57d22", + "kelp", + &first, + 1, + )), + &world.signer, + at(1), + ) + .unwrap() + .object; + store + .update( + &meta_home(), + object, + &RegistryChange::Register(named_registration( + "nel", + "3mqydma3re27z", + "kelp", + &survivor, + 2, + )), + &world.signer, + at(2), + ) + .unwrap(); + + let index = world.index(); + index.rebuild().unwrap(); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("kelp")), + Resolved::Ready(Some(first)), + "the name resolves to the older registration while both exist" + ); + + store + .update( + &meta_home(), + object, + &RegistryChange::Deregister(RepoRef { + owner: own("nel"), + rkey: rkey("3lubrptx57d22"), + }), + &world.signer, + at(3), + ) + .unwrap(); + index.refresh_registry().unwrap(); + + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("kelp")), + Resolved::Ready(Some(survivor)), + "the name resolves to the remaining repo once the older one is deregistered" + ); +} + +#[test] +fn a_rename_moves_name_resolution_off_the_old_name() { + let world = World::new(); + let repo = repo_did("squid"); + + let meta = Repo::open(&world.meta_path).unwrap(); + let store = CobStore::new(&meta); + let object = store + .create( + &meta_home(), + &RegistryChange::Register(named_registration( + "nel", + "3mizfnpxii522", + "anemone", + &repo, + 1, + )), + &world.signer, + at(1), + ) + .unwrap() + .object; + + let index = world.index(); + index.rebuild().unwrap(); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("anemone")), + Resolved::Ready(Some(repo.clone())) + ); + + store + .update( + &meta_home(), + object, + &RegistryChange::Rename(Rename { + owner: own("nel"), + rkey: rkey("3mizfnpxii522"), + name: RepoName::new("barnacle").unwrap(), + repo: repo.clone(), + }), + &world.signer, + at(2), + ) + .unwrap(); + index.refresh_registry().unwrap(); + + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("barnacle")), + Resolved::Ready(Some(repo.clone())), + "the new name resolves after a rename that keeps the record key" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("anemone")), + Resolved::Ready(None), + "the superseded name stops resolving" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("3mizfnpxii522")), + Resolved::Ready(Some(repo)) + ); +} + +#[test] +fn a_rename_that_changes_only_the_record_key_keeps_the_name_resolving() { + let world = World::new(); + let repo = repo_did("squid"); + + let meta = Repo::open(&world.meta_path).unwrap(); + let store = CobStore::new(&meta); + let object = store + .create( + &meta_home(), + &RegistryChange::Register(named_registration("nel", "3lubrptx57d22", "kelp", &repo, 1)), + &world.signer, + at(1), + ) + .unwrap() + .object; + + let index = world.index(); + index.rebuild().unwrap(); + + store + .update( + &meta_home(), + object, + &RegistryChange::Rename(Rename { + owner: own("nel"), + rkey: rkey("3mqydma3re27z"), + name: RepoName::new("kelp").unwrap(), + repo: repo.clone(), + }), + &world.signer, + at(2), + ) + .unwrap(); + index.refresh_registry().unwrap(); + + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("kelp")), + Resolved::Ready(Some(repo.clone())), + "the unchanged name survives a rename that swaps the record key" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("3mqydma3re27z")), + Resolved::Ready(Some(repo.clone())) + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("3lubrptx57d22")), + Resolved::Ready(Some(repo)), + "the superseded record key keeps resolving through its retained alias" + ); +} + +#[test] +fn seeding_and_replaying_agree_on_name_resolution() { + let world = World::new(); + let first = repo_did("whelk"); + let second = repo_did("conch"); + + let meta = Repo::open(&world.meta_path).unwrap(); + let store = CobStore::new(&meta); + let object = store + .create( + &meta_home(), + &RegistryChange::Register(named_registration( + "nel", + "3lubrptx57d22", + "kelp", + &first, + 1, + )), + &world.signer, + at(1), + ) + .unwrap() + .object; + + let replayed = world.index(); + replayed.rebuild().unwrap(); + + store + .update( + &meta_home(), + object, + &RegistryChange::Register(named_registration( + "nel", + "3mqydma3re27z", + "uni", + &second, + 2, + )), + &world.signer, + at(2), + ) + .unwrap(); + replayed.refresh_registry().unwrap(); + + let seeded = world.index(); + seeded.rebuild().unwrap(); + + ["kelp", "uni", "3lubrptx57d22", "3mqydma3re27z", "nautilus"] + .into_iter() + .for_each(|segment| { + assert_eq!( + replayed.resolve_clone_path(&own("nel"), &path(segment)), + seeded.resolve_clone_path(&own("nel"), &path(segment)), + "incremental replay and a cold seed disagree on {segment}" + ); + }); +} + +#[test] +fn a_name_differing_from_its_record_key_only_by_case_resolves() { + let world = World::new(); + let repo = repo_did("squid"); + + let meta = Repo::open(&world.meta_path).unwrap(); + let store = CobStore::new(&meta); + store + .create( + &meta_home(), + &RegistryChange::Register(named_registration( + "nel", + "runic_lang", + "Runic_lang", + &repo, + 1, + )), + &world.signer, + at(1), + ) + .unwrap(); + + let index = world.index(); + index.rebuild().unwrap(); + + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("Runic_lang")), + Resolved::Ready(Some(repo.clone())), + "the appview lowercases the record key but keeps the display name's case, \ + so the mixed-case path resolves by name" + ); + assert_eq!( + index.resolve_clone_path(&own("nel"), &path("runic_lang")), + Resolved::Ready(Some(repo)), + "the lowercased record key still resolves" + ); +} diff --git a/knot2/crates/knot-pack/src/lib.rs b/knot2/crates/knot-pack/src/lib.rs index 1abd02ff..de6198a9 100644 --- a/knot2/crates/knot-pack/src/lib.rs +++ b/knot2/crates/knot-pack/src/lib.rs @@ -30,7 +30,7 @@ use knot_messages::{Catalog, ErrorKey, FetchMessages}; use knot_resource::{PackSlots, SlotPermit}; use knot_runtime::Clock; use knot_types::{ - AccountDid, Handle, KnotHostname, OwnerDid, OwnerRef, ParseError, RepoDid, RepoRkey, + AccountDid, ClonePath, Handle, KnotHostname, OwnerDid, OwnerRef, ParseError, RepoDid, }; use std::sync::Arc; use tokio::sync::mpsc; @@ -276,7 +276,7 @@ const MAX_REQUEST_BYTES: usize = 16 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RepoTarget { Did(RepoDid), - OwnerRkey(OwnerDid, RepoRkey), + OwnerPath(OwnerDid, ClonePath), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -287,24 +287,6 @@ pub enum RepoLookup { } impl RepoLookup { - pub fn or_else(self, next: impl FnOnce() -> RepoLookup) -> RepoLookup { - match self { - RepoLookup::Unhosted => next(), - decided => decided, - } - } - - pub fn first( - candidates: impl IntoIterator, - resolve: impl Fn(RepoRkey) -> RepoLookup, - ) -> RepoLookup { - candidates - .into_iter() - .fold(RepoLookup::Unhosted, |acc, rkey| { - acc.or_else(|| resolve(rkey)) - }) - } - pub fn from_resolved( resolved: knot_index::Resolved>, found: impl FnOnce(T) -> RepoDid, @@ -531,14 +513,12 @@ fn resolve_named_did( owner: &OwnerDid, name: &str, ) -> Result { - lookup_did(RepoLookup::first( - RepoRkey::clone_path_candidates(name), - |rkey| { - state - .resolver - .resolve(&RepoTarget::OwnerRkey(owner.clone(), rkey)) - }, - )) + let path = ClonePath::parse(name).ok_or(PackError::NotFound)?; + lookup_did( + state + .resolver + .resolve(&RepoTarget::OwnerPath(owner.clone(), path)), + ) } fn resolve_did_did(state: &PackState, did: &str) -> Result { diff --git a/knot2/crates/knot-pack/tests/common/mod.rs b/knot2/crates/knot-pack/tests/common/mod.rs index b884f941..1d365762 100644 --- a/knot2/crates/knot-pack/tests/common/mod.rs +++ b/knot2/crates/knot-pack/tests/common/mod.rs @@ -235,7 +235,7 @@ pub fn incompressible(seed: u64, len: usize) -> Vec { pub fn serve_dids() -> Arc { Arc::new(|target: &RepoTarget| match target { RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()), - RepoTarget::OwnerRkey(_, _) => RepoLookup::Unhosted, + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, }) } diff --git a/knot2/crates/knot-pack/tests/git_client.rs b/knot2/crates/knot-pack/tests/git_client.rs index 51ed5914..d6c150eb 100644 --- a/knot2/crates/knot-pack/tests/git_client.rs +++ b/knot2/crates/knot-pack/tests/git_client.rs @@ -120,12 +120,16 @@ async fn http_routing_resolves_owner_rkey_and_dot_git_and_404s_the_unhosted() { let plain_did = plain_did.clone(); let literal_did = literal_did.clone(); Arc::new(move |target: &RepoTarget| match target { - RepoTarget::OwnerRkey(o, n) if *o == owner && n.as_str() == "anemone" => { - RepoLookup::Hosted(plain_did.clone()) - } - RepoTarget::OwnerRkey(o, n) if *o == owner && n.as_str() == "barnacle.git" => { + RepoTarget::OwnerPath(o, p) + if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "barnacle.git") => + { RepoLookup::Hosted(literal_did.clone()) } + RepoTarget::OwnerPath(o, p) + if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "anemone") => + { + RepoLookup::Hosted(plain_did.clone()) + } _ => RepoLookup::Unhosted, }) }; diff --git a/knot2/crates/knot-pack/tests/h3_conformance.rs b/knot2/crates/knot-pack/tests/h3_conformance.rs index 5794fc4f..6b23d34d 100644 --- a/knot2/crates/knot-pack/tests/h3_conformance.rs +++ b/knot2/crates/knot-pack/tests/h3_conformance.rs @@ -53,7 +53,7 @@ fn object_set(dir: &Path) -> BTreeSet { fn serve_dids() -> Arc { Arc::new(|target: &RepoTarget| match target { RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()), - RepoTarget::OwnerRkey(_, _) => RepoLookup::Unhosted, + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, }) } diff --git a/knot2/crates/knot-pack/tests/handle_owner.rs b/knot2/crates/knot-pack/tests/handle_owner.rs index 22711142..da47b68f 100644 --- a/knot2/crates/knot-pack/tests/handle_owner.rs +++ b/knot2/crates/knot-pack/tests/handle_owner.rs @@ -27,7 +27,9 @@ fn repo_resolver() -> Arc { let owner = OwnerDid::new("did:plc:nel").unwrap(); let repo = RepoDid::new("did:plc:whelk").unwrap(); Arc::new(move |target: &RepoTarget| match target { - RepoTarget::OwnerRkey(o, n) if *o == owner && n.as_str() == "squid" => { + RepoTarget::OwnerPath(o, p) + if *o == owner && p.rkeys().any(|rkey| rkey.as_str() == "squid") => + { RepoLookup::Hosted(repo.clone()) } RepoTarget::Did(d) if *d == repo => RepoLookup::Hosted(d.clone()), diff --git a/knot2/crates/knot-pack/tests/serving.rs b/knot2/crates/knot-pack/tests/serving.rs index cf5ebe01..0cbc26c2 100644 --- a/knot2/crates/knot-pack/tests/serving.rs +++ b/knot2/crates/knot-pack/tests/serving.rs @@ -18,7 +18,7 @@ use common::pkt; fn serve_dids() -> Arc { Arc::new(|target: &RepoTarget| match target { RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()), - RepoTarget::OwnerRkey(_, _) => RepoLookup::Unhosted, + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, }) } diff --git a/knot2/crates/knot-pack/tests/soak.rs b/knot2/crates/knot-pack/tests/soak.rs index d4efee51..9bc84214 100644 --- a/knot2/crates/knot-pack/tests/soak.rs +++ b/knot2/crates/knot-pack/tests/soak.rs @@ -15,7 +15,7 @@ use common::must; fn serve_dids() -> std::sync::Arc { std::sync::Arc::new(|target: &RepoTarget| match target { RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()), - RepoTarget::OwnerRkey(_, _) => RepoLookup::Unhosted, + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, }) } diff --git a/knot2/crates/knot-server/src/main.rs b/knot2/crates/knot-server/src/main.rs index 32d2934f..e550aa42 100644 --- a/knot2/crates/knot-server/src/main.rs +++ b/knot2/crates/knot-server/src/main.rs @@ -563,9 +563,10 @@ async fn main() -> anyhow::Result<()> { knot_pack::RepoTarget::Did(did) => { knot_pack::RepoLookup::from_resolved(index.owner_of(did), |_| did.clone()) } - knot_pack::RepoTarget::OwnerRkey(owner, rkey) => { - knot_pack::RepoLookup::from_resolved(index.resolve_repo(owner, rkey), |found| found) - } + knot_pack::RepoTarget::OwnerPath(owner, path) => knot_pack::RepoLookup::from_resolved( + index.resolve_clone_path(owner, path), + |found| found, + ), }) }; let receive_advertiser = knot_xrpc::receive_advertiser(Arc::clone(&xrpc_state)); diff --git a/knot2/crates/knot-sim/src/harness.rs b/knot2/crates/knot-sim/src/harness.rs index dc71b81b..46f36edb 100644 --- a/knot2/crates/knot-sim/src/harness.rs +++ b/knot2/crates/knot-sim/src/harness.rs @@ -781,8 +781,8 @@ fn assemble_router(parts: StateParts) -> Router { Resolved::Ready(None) => knot_pack::RepoLookup::Unhosted, Resolved::Warming => knot_pack::RepoLookup::Unavailable, }, - knot_pack::RepoTarget::OwnerRkey(owner, rkey) => { - match index.resolve_repo(owner, rkey) { + knot_pack::RepoTarget::OwnerPath(owner, path) => { + match index.resolve_clone_path(owner, path) { Resolved::Ready(Some(found)) => knot_pack::RepoLookup::Hosted(found), Resolved::Ready(None) => knot_pack::RepoLookup::Unhosted, Resolved::Warming => knot_pack::RepoLookup::Unavailable, diff --git a/knot2/crates/knot-sim/tests/h3.rs b/knot2/crates/knot-sim/tests/h3.rs index ffb47f50..a6a2513b 100644 --- a/knot2/crates/knot-sim/tests/h3.rs +++ b/knot2/crates/knot-sim/tests/h3.rs @@ -128,7 +128,7 @@ fn extract_pack(response: &[u8]) -> Vec { fn serve_dids() -> Arc { Arc::new(|target: &RepoTarget| match target { RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()), - RepoTarget::OwnerRkey(_, _) => RepoLookup::Unhosted, + RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted, }) } diff --git a/knot2/crates/knot-sim/tests/lfs_roundtrip.rs b/knot2/crates/knot-sim/tests/lfs_roundtrip.rs index 6a731614..5774b2eb 100644 --- a/knot2/crates/knot-sim/tests/lfs_roundtrip.rs +++ b/knot2/crates/knot-sim/tests/lfs_roundtrip.rs @@ -393,8 +393,8 @@ async fn spawn(published_line: String, with_h3: bool) -> World { knot_index::Resolved::Ready(None) => knot_pack::RepoLookup::Unhosted, knot_index::Resolved::Warming => knot_pack::RepoLookup::Unavailable, }, - knot_pack::RepoTarget::OwnerRkey(owner, rkey) => { - match index.resolve_repo(owner, rkey) { + knot_pack::RepoTarget::OwnerPath(owner, path) => { + match index.resolve_clone_path(owner, path) { knot_index::Resolved::Ready(Some(found)) => { knot_pack::RepoLookup::Hosted(found) } diff --git a/knot2/crates/knot-ssh/src/exec.rs b/knot2/crates/knot-ssh/src/exec.rs index 7b31f81a..09b59168 100644 --- a/knot2/crates/knot-ssh/src/exec.rs +++ b/knot2/crates/knot-ssh/src/exec.rs @@ -9,7 +9,7 @@ use knot_index::Resolved; use knot_lfs::TransferOp; use knot_pack::{PackError, PackLimits, RepoLookup}; use knot_runtime::{Clock, HttpTransport}; -use knot_types::{AccountDid, ObjectFormat, OfferedKey, OwnerDid, RepoDid, RepoRkey}; +use knot_types::{AccountDid, ClonePath, ObjectFormat, OfferedKey, OwnerDid, RepoDid}; use russh::Channel; use russh::server::Msg; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -49,13 +49,13 @@ enum ReadError { enum RepoRef { Did(RepoDid), - OwnerRkey(OwnerDid, Vec), - HandleRkey(knot_types::Handle, Vec), + OwnerPath(OwnerDid, ClonePath), + HandlePath(knot_types::Handle, ClonePath), } enum ResolvedRef { Did(RepoDid), - OwnerRkey(OwnerDid, Vec), + OwnerPath(OwnerDid, ClonePath), } fn parse_exec(command: &[u8]) -> Option<(Service, RepoRef)> { @@ -86,14 +86,11 @@ fn parse_repo_path(raw: &str) -> Option { .trim_start_matches('/'); match path.split_once('/') { Some((owner, name)) => { - let candidates: Vec = RepoRkey::clone_path_candidates(name).collect(); - if candidates.is_empty() { - return None; - } + let candidates = ClonePath::parse(name)?; match knot_types::OwnerRef::parse(owner)? { - knot_types::OwnerRef::Did(owner) => Some(RepoRef::OwnerRkey(owner, candidates)), + knot_types::OwnerRef::Did(owner) => Some(RepoRef::OwnerPath(owner, candidates)), knot_types::OwnerRef::Handle(handle) => { - Some(RepoRef::HandleRkey(handle, candidates)) + Some(RepoRef::HandlePath(handle, candidates)) } } } @@ -107,9 +104,10 @@ fn resolve_repo_ref( ) -> RepoLookup { let candidate = match repo_ref { ResolvedRef::Did(did) => RepoLookup::Hosted(did), - ResolvedRef::OwnerRkey(owner, candidates) => RepoLookup::first(candidates, |rkey| { - RepoLookup::from_resolved(state.index.resolve_repo(&owner, &rkey), |found| found) - }), + ResolvedRef::OwnerPath(owner, candidates) => RepoLookup::from_resolved( + state.index.resolve_clone_path(&owner, &candidates), + |found| found, + ), }; match candidate { RepoLookup::Hosted(did) => { @@ -151,15 +149,15 @@ pub(crate) async fn run_exec( }; let resolved_ref = match repo_ref { RepoRef::Did(did) => ResolvedRef::Did(did), - RepoRef::OwnerRkey(owner, candidates) => ResolvedRef::OwnerRkey(owner, candidates), - RepoRef::HandleRkey(owner_handle, candidates) => { + RepoRef::OwnerPath(owner, candidates) => ResolvedRef::OwnerPath(owner, candidates), + RepoRef::HandlePath(owner_handle, candidates) => { match state .atproto .resolve_handle_to_did(&owner_handle) .await .ok() { - Some(did) => ResolvedRef::OwnerRkey(did.into(), candidates), + Some(did) => ResolvedRef::OwnerPath(did.into(), candidates), None => { fail(channel, &state.catalog.ssh.repo_not_found.text()).await; return; @@ -949,11 +947,11 @@ mod tests { fn the_repo_path_parser_separates_dids_from_handles() { assert!(matches!( parse_repo_path("did:plc:nel/squid"), - Some(RepoRef::OwnerRkey(..)) + Some(RepoRef::OwnerPath(..)) )); assert!(matches!( parse_repo_path("nel.pet/squid"), - Some(RepoRef::HandleRkey(..)) + Some(RepoRef::HandlePath(..)) )); assert!(matches!( parse_repo_path("did:plc:barnacle"), diff --git a/knot2/crates/knot-ssh/tests/ssh_push.rs b/knot2/crates/knot-ssh/tests/ssh_push.rs index 6f890649..f50326e5 100644 --- a/knot2/crates/knot-ssh/tests/ssh_push.rs +++ b/knot2/crates/knot-ssh/tests/ssh_push.rs @@ -24,6 +24,9 @@ use url::Url; const REPO_DID: &str = "did:plc:squid"; const REPO_NAME: &str = "anemone"; const OWNER_DID: &str = "did:plc:nel"; +const TID_REPO_DID: &str = "did:plc:limpet"; +const TID_RKEY: &str = "3mizfnpxii522"; +const TID_REPO_NAME: &str = "periwinkle.cloud"; const PDS_HOST: &str = "pds.oyster.cafe"; fn git(cwd: &Path, env: &[(&str, &str)], args: &[&str]) -> (bool, String) { @@ -140,6 +143,8 @@ fn fake_http(published_line: String) -> impl knot_runtime::HttpTransport { list_records_body(&[&published_line]) } else if path.ends_with(REPO_DID) { did_document(&signer, REPO_DID, &pds) + } else if path.ends_with(TID_REPO_DID) { + did_document(&signer, TID_REPO_DID, &pds) } else if path.ends_with(OWNER_DID) { did_document(&signer, OWNER_DID, &pds) } else { @@ -225,9 +230,10 @@ async fn spawn_server_core( let signer = K256Signer::generate(&SeededEntropy::new(2)); let meta = Repo::open(&meta_path).unwrap(); let store = CobStore::new(&meta); - store + let home = CobHome::from(&KnotId::new("did:web:nel.pet").unwrap()); + let registry = store .create( - &CobHome::from(&KnotId::new("did:web:nel.pet").unwrap()), + &home, &RegistryChange::Register(Registration { owner: OwnerDid::new(OWNER_DID).unwrap(), rkey: RepoRkey::new(REPO_NAME).unwrap(), @@ -238,6 +244,25 @@ async fn spawn_server_core( &signer, UnixSeconds::new(1), ) + .unwrap() + .object; + + let tid_repo_did = RepoDid::new(TID_REPO_DID).unwrap(); + layout.create(&tid_repo_did).unwrap(); + store + .update( + &home, + registry, + &RegistryChange::Register(Registration { + owner: OwnerDid::new(OWNER_DID).unwrap(), + rkey: RepoRkey::new(TID_RKEY).unwrap(), + name: RepoName::new(TID_REPO_NAME).unwrap(), + repo: tid_repo_did, + created_at: UnixSeconds::new(2), + }), + &signer, + UnixSeconds::new(2), + ) .unwrap(); let index = Arc::new(Index::new(meta_path, layout.clone())); @@ -538,6 +563,50 @@ async fn incremental_fetch_over_ssh_completes() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_display_name_addresses_a_repo_whose_record_key_is_a_tid() { + let fx = fixture().await; + let head = seed_work(&fx.work); + let head_oid = Oid::from_hex(&head).unwrap(); + let port = fx.server.port; + let target = RepoDid::new(TID_REPO_DID).unwrap(); + let variants = [ + format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{TID_REPO_NAME}"), + format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{TID_REPO_NAME}.git"), + format!("ssh://git@127.0.0.1:{port}/nel.pet/{TID_REPO_NAME}"), + format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/{TID_RKEY}"), + ]; + let fx = &fx; + let target = ⌖ + futures::stream::iter(variants) + .for_each(|url| async move { + let (ok, out) = push(&fx.work, &url, &fx.key_path, &["main"]).await; + assert!( + ok, + "a PDS-minted record key leaves the display name as the only human \ + path, so {url} must resolve and push:\n{out}" + ); + assert_eq!( + main_tip(&fx.server.layout, target), + Some(head_oid), + "{url}: pushed commit must be the named repository's main tip" + ); + }) + .await; + + let (ok, out) = push( + &fx.work, + &format!("ssh://git@127.0.0.1:{port}/{OWNER_DID}/whelk"), + &fx.key_path, + &["main"], + ) + .await; + assert!( + !ok, + "a segment matching neither a record key nor a name stays unresolvable:\n{out}" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn addressing_variants_land() { let fx = fixture().await; diff --git a/knot2/crates/knot-types/src/ids.rs b/knot2/crates/knot-types/src/ids.rs index 0942854f..6dd0419f 100644 --- a/knot2/crates/knot-types/src/ids.rs +++ b/knot2/crates/knot-types/src/ids.rs @@ -542,11 +542,30 @@ impl From for AccountDid { } } -impl RepoRkey { - pub fn clone_path_candidates(raw: &str) -> impl Iterator + '_ { - std::iter::once(raw) - .chain(raw.strip_suffix(".git")) - .filter_map(|candidate| Self::new(candidate).ok()) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClonePath { + rkeys: Vec, + names: Vec, +} + +impl ClonePath { + pub fn parse(raw: &str) -> Option { + let segments = || std::iter::once(raw).chain(raw.strip_suffix(".git")); + let rkeys: Vec = segments() + .filter_map(|segment| RepoRkey::new(segment).ok()) + .collect(); + let names: Vec = segments() + .filter_map(|segment| RepoName::new(segment).ok()) + .collect(); + (!rkeys.is_empty() || !names.is_empty()).then_some(Self { rkeys, names }) + } + + pub fn rkeys(&self) -> impl Iterator { + self.rkeys.iter() + } + + pub fn names(&self) -> impl Iterator { + self.names.iter() } } @@ -1020,28 +1039,61 @@ mod tests { } #[test] - fn clone_path_candidates_try_the_exact_rkey_before_the_stripped_one() { - let suffixed: Vec = RepoRkey::clone_path_candidates("anemone.git").collect(); + fn clone_paths_try_the_exact_segment_before_the_stripped_one() { + let suffixed = ClonePath::parse("anemone.git").unwrap(); assert_eq!( - suffixed, + suffixed.rkeys().cloned().collect::>(), vec![ RepoRkey::new("anemone.git").unwrap(), RepoRkey::new("anemone").unwrap() ], "literal .git rkey wins over conventional suffix interpretation" ); + assert_eq!( + suffixed.names().cloned().collect::>(), + vec![ + RepoName::new("anemone.git").unwrap(), + RepoName::new("anemone").unwrap() + ] + ); - let plain: Vec = RepoRkey::clone_path_candidates("anemone").collect(); - assert_eq!(plain, vec![RepoRkey::new("anemone").unwrap()]); + let plain = ClonePath::parse("anemone").unwrap(); + assert_eq!( + plain.rkeys().cloned().collect::>(), + vec![RepoRkey::new("anemone").unwrap()] + ); - let bare: Vec = RepoRkey::clone_path_candidates(".git").collect(); + let bare = ClonePath::parse(".git").unwrap(); assert_eq!( - bare, + bare.rkeys().cloned().collect::>(), vec![RepoRkey::new(".git").unwrap()], "stripping .git from bare suffix leaves nothing valid to try" ); - assert_eq!(RepoRkey::clone_path_candidates("a/b.git").count(), 0); + assert!(ClonePath::parse("a/b.git").is_none()); + } + + #[test] + fn clone_paths_keep_segments_only_valid_as_one_of_the_two_kinds() { + let plus = ClonePath::parse("c++").unwrap(); + assert_eq!( + plus.rkeys().count(), + 0, + "a record key allows only [A-Za-z0-9._~:-]" + ); + assert_eq!( + plus.names().cloned().collect::>(), + vec![RepoName::new("c++").unwrap()], + "a repo name accepts the wider charset, so the segment resolves by name" + ); + + let long = "x".repeat(200); + let overlong = ClonePath::parse(&long).unwrap(); + assert_eq!(overlong.names().count(), 0, "a repo name is at most 100"); + assert_eq!( + overlong.rkeys().cloned().collect::>(), + vec![RepoRkey::new(&long).unwrap()] + ); } #[test] diff --git a/knot2/crates/knot-types/src/lib.rs b/knot2/crates/knot-types/src/lib.rs index 6b84634c..4fa5da36 100644 --- a/knot2/crates/knot-types/src/lib.rs +++ b/knot2/crates/knot-types/src/lib.rs @@ -6,9 +6,9 @@ pub use changes::{ChangedFiles, ChangedFilesBudget, Listing}; mod ids; pub use ids::{ - AccountDid, ActorId, AppviewEndpoint, AuthorName, BranchName, ChangeId, CiLogsAddr, CobId, - Email, HttpStatus, KnotHostname, KnotId, KnotServiceUrl, LanguageBytes, LanguageName, LogsHost, - LogsPort, ObjectCount, ObjectFormat, OfferedKey, Oid, OwnerDid, OwnerRef, ParseError, + AccountDid, ActorId, AppviewEndpoint, AuthorName, BranchName, ChangeId, CiLogsAddr, ClonePath, + CobId, Email, HttpStatus, KnotHostname, KnotId, KnotServiceUrl, LanguageBytes, LanguageName, + LogsHost, LogsPort, ObjectCount, ObjectFormat, OfferedKey, Oid, OwnerDid, OwnerRef, ParseError, PushOption, PushOptions, RefName, RefTransition, RepoDid, RepoName, RepoPath, RepoRkey, ServiceDid, TagName, TypeName, UnixMicros, UnixSeconds, }; diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index fa2ba881..7a4271f3 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -64,8 +64,8 @@ use knot_resource::Slots; use knot_runtime::{Clock, Entropy, HttpTransport}; use knot_secrets::SealedStore; use knot_types::{ - AccountDid, AdmissionPolicy, AppviewEndpoint, CiLogsAddr, KnotHostname, KnotId, KnotServiceUrl, - Nsid, OwnerDid, OwnerRef, RepoDid, RepoRkey, UnixSeconds, + AccountDid, AdmissionPolicy, AppviewEndpoint, CiLogsAddr, ClonePath, KnotHostname, KnotId, + KnotServiceUrl, Nsid, OwnerDid, OwnerRef, RepoDid, UnixSeconds, }; use base64::Engine; @@ -578,15 +578,15 @@ pub(crate) async fn resolve_repo_named( name: &RepoNameSegment, ) -> Result { let owner = resolve_owner_segment(state, owner).await?; - RepoRkey::clone_path_candidates(name.as_str()) - .find_map(|rkey| match state.index.resolve_repo(&owner, &rkey) { - Resolved::Ready(Some(did)) => Some(Ok(did)), - Resolved::Ready(None) => None, - Resolved::Warming => Some(Err(XrpcError::warming( - "registry projection is still warming, retry shortly", - ))), - }) - .unwrap_or_else(|| Err(XrpcError::not_found("repository not found"))) + let path = ClonePath::parse(name.as_str()) + .ok_or_else(|| XrpcError::not_found("repository not found"))?; + match state.index.resolve_clone_path(&owner, &path) { + Resolved::Ready(Some(did)) => Ok(did), + Resolved::Ready(None) => Err(XrpcError::not_found("repository not found")), + Resolved::Warming => Err(XrpcError::warming( + "registry projection is still warming, retry shortly", + )), + } } async fn resolve_owner_segment(