This repository has no description
0

Configure Feed

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

knot2/xrpc: tolerate old owner-name file remote

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

author did:plc:3fwecdnvtcscjnrx2p4n7a… date (Jul 29, 2026, 9:07 AM +0300) commit 0c162429 parent 3778d4e9 change-id nktyxsmu
+275 -96
+5 -5
knot2/crates/knot-git/src/repo.rs
··· 7 7 use gix::refs::{FullName, Target}; 8 8 use knot_cache::{Cache, Moka, Weight}; 9 9 use knot_types::{ 10 - BranchName, KnotId, ObjectFormat, Oid, RefName, RefTransition, RepoDid, UnixSeconds, 10 + BranchName, KnotId, ObjectFormat, Oid, OriginUrl, RefName, RefTransition, RepoDid, UnixSeconds, 11 11 }; 12 12 13 13 use crate::error::GitError; ··· 689 689 dirs.iter().try_for_each(|dir| fsync_if_present(dir)) 690 690 } 691 691 692 - pub fn origin_url(&self) -> Option<String> { 692 + pub fn origin_url(&self) -> Option<OriginUrl> { 693 693 self.git 694 694 .config_snapshot() 695 695 .string("remote.origin.url") 696 - .map(|value| value.to_string()) 696 + .map(|value| OriginUrl::new(value.to_string())) 697 697 } 698 698 699 - pub fn set_origin_url(&self, url: &str) -> Result<(), GitError> { 699 + pub fn set_origin_url(&self, url: &OriginUrl) -> Result<(), GitError> { 700 700 let path = self.git.git_dir().join("config"); 701 701 let report = |message: String| GitError::Config { 702 702 path: path.clone(), ··· 709 709 "remote", 710 710 Some(gix::bstr::BStr::new("origin")), 711 711 "url", 712 - gix::bstr::BStr::new(url), 712 + gix::bstr::BStr::new(url.as_str()), 713 713 ) 714 714 .map_err(|error| report(error.to_string()))?; 715 715 knot_resource::atomic_write(&path, knot_resource::FileMode::Inherited, |out| {
+3 -3
knot2/crates/knot-postreceive/tests/post_receive.rs
··· 6 6 use knot_postreceive::{Actor, Ci, LanguagesPushBudget, OwnerLabel, PullLink, post_receive}; 7 7 use knot_runtime::{ManualClock, UnixMicros}; 8 8 use knot_types::{ 9 - AccountDid, AppviewEndpoint, BranchName, CiLogsAddr, Handle, Oid, OwnerDid, PushOption, 10 - PushOptions, RefName, RepoDid, RepoRkey, 9 + AccountDid, AppviewEndpoint, BranchName, CiLogsAddr, Handle, Oid, OriginUrl, OwnerDid, 10 + PushOption, PushOptions, RefName, RepoDid, RepoRkey, 11 11 }; 12 12 13 13 const DID: &str = "did:plc:limpet"; ··· 636 636 ("new branch on a fork with an origin remote", |w| { 637 637 let head = create_feature(w); 638 638 w.repo 639 - .set_origin_url("https://oyster.cafe/did:plc:squid/anemone") 639 + .set_origin_url(&OriginUrl::new("https://oyster.cafe/did:plc:squid/anemone")) 640 640 .unwrap(); 641 641 created("feature", head) 642 642 }),
+4
knot2/crates/knot-types/src/ids.rs
··· 152 152 pub struct Email(String) => strip_control; 153 153 } 154 154 155 + crate::text_newtype! { 156 + pub struct OriginUrl(String) => verbatim; 157 + } 158 + 155 159 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] 156 160 pub struct LanguageName(&'static str); 157 161
+3 -3
knot2/crates/knot-types/src/lib.rs
··· 8 8 pub use ids::{ 9 9 AccountDid, ActorId, AppviewEndpoint, AuthorName, BranchName, ChangeId, CiLogsAddr, ClonePath, 10 10 CobId, Email, HttpStatus, KnotHostname, KnotId, KnotServiceUrl, LanguageBytes, LanguageName, 11 - LogsHost, LogsPort, ObjectCount, ObjectFormat, OfferedKey, Oid, OwnerDid, OwnerRef, ParseError, 12 - PushOption, PushOptions, RefName, RefTransition, RepoDid, RepoName, RepoPath, RepoRkey, 13 - ServiceDid, TagName, TypeName, UnixMicros, UnixSeconds, 11 + LogsHost, LogsPort, ObjectCount, ObjectFormat, OfferedKey, Oid, OriginUrl, OwnerDid, OwnerRef, 12 + ParseError, PushOption, PushOptions, RefName, RefTransition, RepoDid, RepoName, RepoPath, 13 + RepoRkey, ServiceDid, TagName, TypeName, UnixMicros, UnixSeconds, 14 14 }; 15 15 16 16 mod policy;
+7 -24
knot2/crates/knot-xrpc/src/body.rs
··· 1 1 use serde::Deserialize; 2 2 use serde::de::{self, Deserializer}; 3 3 4 - use knot_types::{AtUri, RefName, RepoName}; 4 + use knot_types::{AtUri, RefName}; 5 5 use url::Url; 6 6 7 7 pub(crate) struct RepoAtUri(AtUri<String>); ··· 21 21 } 22 22 } 23 23 24 - pub(crate) struct RepoNameArg(RepoName); 25 - 26 - impl RepoNameArg { 27 - pub(crate) fn as_str(&self) -> &str { 28 - self.0.as_str() 29 - } 30 - } 31 - 32 - impl<'de> Deserialize<'de> for RepoNameArg { 33 - fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 34 - let raw = String::deserialize(deserializer)?; 35 - RepoName::new(raw) 36 - .map(RepoNameArg) 37 - .map_err(de::Error::custom) 38 - } 39 - } 40 - 41 24 #[derive(Clone)] 42 25 pub(crate) struct SourceUrl(Url); 43 26 44 27 impl SourceUrl { 45 - pub(crate) fn parse(raw: &str) -> Result<Self, &'static str> { 46 - parse_source_url(raw) 28 + pub(crate) fn from_url(url: Url) -> Result<Self, &'static str> { 29 + match matches!(url.scheme(), "http" | "https") && url.has_host() { 30 + true => Ok(Self(url)), 31 + false => Err("source must be an http or https url"), 32 + } 47 33 } 48 34 49 35 pub(crate) fn as_str(&self) -> &str { ··· 64 50 65 51 fn parse_source_url(raw: &str) -> Result<SourceUrl, &'static str> { 66 52 let url = Url::parse(raw).map_err(|_| "source must be a valid url")?; 67 - match matches!(url.scheme(), "http" | "https") && url.has_host() { 68 - true => Ok(SourceUrl(url)), 69 - false => Err("source must be an http or https url"), 70 - } 53 + SourceUrl::from_url(url) 71 54 } 72 55 73 56 // A sourceless repo is a plain repo not a bad request necessarily,
+111 -44
knot2/crates/knot-xrpc/src/forks.rs
··· 14 14 use knot_pack::{FetchError, HaveOids, PackLimits, UpstreamRefs, WantOids}; 15 15 use knot_postreceive::{Actor, Ci}; 16 16 use knot_runtime::{Clock, HttpTransport}; 17 - use knot_types::{BranchName, ObjectFormat, Oid, OwnerDid, RefName, RepoDid}; 17 + use knot_types::{BranchName, ObjectFormat, Oid, OriginUrl, OwnerDid, RefName, RepoDid, RepoName}; 18 18 19 - use crate::body::{ForkRef, RemoteRef, RepoAtUri, RepoNameArg, Revspec, SourceUrl}; 19 + use crate::body::{ForkRef, RemoteRef, RepoAtUri, Revspec, SourceUrl}; 20 20 use crate::branches::resolve_at_uri; 21 21 use crate::error::XrpcError; 22 22 use crate::{XrpcState, decode, ok_empty, run_blocking}; ··· 39 39 } 40 40 } 41 41 42 - fn resolve_local_path<H: HttpTransport, C: Clock>( 43 - state: &XrpcState<H, C>, 44 - url: &Url, 45 - ) -> Result<RepoDid, XrpcError> { 46 - let segments: Vec<&str> = url 47 - .path_segments() 42 + fn path_segments(url: &Url) -> Vec<&str> { 43 + url.path_segments() 48 44 .map(|segments| segments.filter(|segment| !segment.is_empty()).collect()) 49 - .unwrap_or_default(); 50 - match segments.as_slice() { 51 - [did] => { 52 - let did = RepoDid::new(*did) 53 - .map_err(|_| XrpcError::invalid_request("fork source path isn't a DID"))?; 54 - match state.index.owner_of(&did) { 55 - Resolved::Ready(Some(_)) => Ok(did), 56 - Resolved::Ready(None) => Err(XrpcError::not_found( 57 - "fork source isn't hosted on this knot", 58 - )), 59 - Resolved::Warming => { 60 - Err(XrpcError::warming("registry projection is still warming")) 61 - } 62 - } 45 + .unwrap_or_default() 46 + } 47 + 48 + pub(crate) enum LocalPath { 49 + Did(RepoDid), 50 + Named { owner: OwnerDid, name: RepoName }, 51 + } 52 + 53 + impl LocalPath { 54 + // The go knot at one point cloned same-host forks like 55 + // `file:///home/git/<owner-did>/<name>` URLs, 56 + // so over here in the future what we're gonna do 57 + // instead of rewriting them all is take the trailing 58 + // segments, pretend /home/git doesn't exist, and voila, 59 + // we somewhat know which repo. 60 + pub(crate) fn parse_trailing(url: &Url) -> Result<Self, &'static str> { 61 + let segments = path_segments(url); 62 + match segments.as_slice() { 63 + [.., owner, name] => match OwnerDid::new(*owner) { 64 + Ok(owner) => Self::named(owner, name), 65 + Err(_) => RepoDid::new(*name) 66 + .map(Self::Did) 67 + .map_err(|_| "path ends in neither /owner-did/name or /repo-did"), 68 + }, 69 + other => Self::from_segments(other), 63 70 } 64 - [owner, name] => { 65 - let owner = OwnerDid::new(*owner) 66 - .map_err(|_| XrpcError::invalid_request("fork source owner segment isn't a DID"))?; 67 - let name = name.strip_suffix(".git").unwrap_or(name); 68 - crate::merge::resolve_by_name(state, &owner, name) 71 + } 72 + 73 + fn from_segments(segments: &[&str]) -> Result<Self, &'static str> { 74 + match segments { 75 + [did] => RepoDid::new(*did) 76 + .map(Self::Did) 77 + .map_err(|_| "path isn't a DID"), 78 + [owner, name] => OwnerDid::new(*owner) 79 + .map_err(|_| "owner segment isn't a DID") 80 + .and_then(|owner| Self::named(owner, name)), 81 + _ => Err("path must be /did or /owner/name"), 69 82 } 70 - _ => Err(XrpcError::invalid_request( 71 - "fork source path must be /did or /owner/name", 72 - )), 83 + } 84 + 85 + fn named(owner: OwnerDid, name: &str) -> Result<Self, &'static str> { 86 + let name = name.strip_suffix(".git").unwrap_or(name); 87 + RepoName::new(name) 88 + .map(|name| Self::Named { owner, name }) 89 + .map_err(|_| "name segment isn't a valid repo name") 90 + } 91 + } 92 + 93 + fn resolve_local<H: HttpTransport, C: Clock>( 94 + state: &XrpcState<H, C>, 95 + path: &LocalPath, 96 + ) -> Result<RepoDid, XrpcError> { 97 + match path { 98 + LocalPath::Did(did) => match state.index.owner_of(did) { 99 + Resolved::Ready(Some(_)) => Ok(did.clone()), 100 + Resolved::Ready(None) => Err(XrpcError::not_found( 101 + "fork source isn't hosted on this knot", 102 + )), 103 + Resolved::Warming => Err(XrpcError::warming("registry projection is still warming")), 104 + }, 105 + LocalPath::Named { owner, name } => crate::merge::resolve_by_name(state, owner, name), 73 106 } 74 107 } 75 108 ··· 79 112 ) -> Result<Upstream, XrpcError> { 80 113 let url = source.as_url(); 81 114 if url_authority(url) == state.knot_authority() { 82 - return resolve_local_path(state, url).map(Upstream::Local); 115 + return LocalPath::from_segments(&path_segments(url)) 116 + .map_err(|reason| XrpcError::invalid_request(format!("fork source {reason}"))) 117 + .and_then(|path| resolve_local(state, &path)) 118 + .map(Upstream::Local); 83 119 } 84 120 Ok(Upstream::Remote(url.clone())) 85 121 } 86 122 123 + enum ForkOrigin { 124 + Source(SourceUrl), 125 + File(Url), 126 + } 127 + 128 + impl ForkOrigin { 129 + fn parse(origin: &OriginUrl) -> Result<Self, &'static str> { 130 + let url = Url::parse(origin.as_str()).map_err(|_| "isn't a valid url")?; 131 + match url.scheme() { 132 + "file" => Ok(Self::File(url)), 133 + "http" | "https" => SourceUrl::from_url(url) 134 + .map(Self::Source) 135 + .map_err(|_| "has no host"), 136 + _ => Err("scheme isn't http, https, or file"), 137 + } 138 + } 139 + } 140 + 141 + fn resolve_origin<H: HttpTransport, C: Clock>( 142 + state: &XrpcState<H, C>, 143 + origin: &ForkOrigin, 144 + ) -> Result<Upstream, XrpcError> { 145 + match origin { 146 + ForkOrigin::Source(source) => resolve_upstream(state, source), 147 + ForkOrigin::File(url) => LocalPath::parse_trailing(url) 148 + .map_err(|reason| XrpcError::internal(format!("stored fork origin {reason}"))) 149 + .and_then(|path| resolve_local(state, &path)) 150 + .map(Upstream::Local), 151 + } 152 + } 153 + 87 154 pub(crate) struct ForkSource { 88 155 pub(crate) origin: SourceUrl, 89 156 pub(crate) upstream: Upstream, ··· 222 289 { 223 290 repo.set_head(head)?; 224 291 } 225 - repo.set_origin_url(origin.as_str()) 292 + repo.set_origin_url(&OriginUrl::new(origin.as_str())) 226 293 .map_err(XrpcError::from) 227 294 } 228 295 ··· 295 362 const FORK_DENIED: &str = "only repository owner or a collaborator may operate on this fork"; 296 363 297 364 struct ForkState { 298 - origin: SourceUrl, 365 + origin: ForkOrigin, 299 366 haves: Vec<Oid>, 300 367 object_format: ObjectFormat, 301 368 } ··· 304 371 let origin = repo.origin_url().ok_or_else(|| { 305 372 XrpcError::invalid_request("this repository isn't a fork and has no upstream") 306 373 })?; 307 - let origin = SourceUrl::parse(&origin) 308 - .map_err(|reason| XrpcError::internal(format!("stored fork origin: {reason}")))?; 374 + let origin = ForkOrigin::parse(&origin) 375 + .map_err(|reason| XrpcError::internal(format!("stored fork origin {reason}")))?; 309 376 let haves = repo 310 377 .references()? 311 378 .into_iter() ··· 367 434 }) 368 435 .await?; 369 436 370 - let upstream = resolve_upstream(state, &fork.origin)?; 437 + let upstream = resolve_origin(state, &fork.origin)?; 371 438 let refs = upstream_refs(state, &upstream, vec![branch.as_str().to_string()]).await?; 372 439 if refs.object_format != fork.object_format { 373 440 return Err(XrpcError::conflict(format!( ··· 426 493 #[derive(Deserialize)] 427 494 struct ForkSyncInput { 428 495 did: OwnerDid, 429 - name: RepoNameArg, 496 + name: RepoName, 430 497 branch: BranchName, 431 498 } 432 499 ··· 438 505 ) -> Result<Response, XrpcError> { 439 506 let actor = state.authenticate(&headers, &method).await?; 440 507 let input: ForkSyncInput = decode(&body)?; 441 - let repo_did = crate::merge::resolve_by_name(&state, &input.did, input.name.as_str())?; 508 + let repo_did = crate::merge::resolve_by_name(&state, &input.did, &input.name)?; 442 509 crate::authorize_push(&state, &actor, &repo_did, FORK_DENIED).await?; 443 510 let branch = input.branch.head_ref(); 444 511 let sync = pull_upstream_branch( ··· 572 639 #[derive(Deserialize)] 573 640 struct ForkStatusInput { 574 641 did: OwnerDid, 575 - name: Option<RepoNameArg>, 642 + name: Option<RepoName>, 576 643 #[serde(default, deserialize_with = "crate::body::optional_source_url")] 577 644 source: Option<SourceUrl>, 578 645 branch: Revspec, ··· 585 652 status: u8, 586 653 } 587 654 588 - fn source_basename(source: &Url) -> Option<String> { 655 + fn source_basename(source: &Url) -> Option<RepoName> { 589 656 source 590 657 .path_segments() 591 658 .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty())) 592 - .map(str::to_string) 659 + .and_then(|segment| RepoName::new(segment).ok()) 593 660 } 594 661 595 662 pub(crate) async fn fork_status<H: HttpTransport, C: Clock>( ··· 602 669 let input: ForkStatusInput = decode(&body)?; 603 670 let name = input 604 671 .name 605 - .as_ref() 606 - .map(|name| name.as_str().to_string()) 607 672 .or_else(|| { 608 673 input 609 674 .source ··· 611 676 .and_then(|source| source_basename(source.as_url())) 612 677 }) 613 678 .ok_or_else(|| { 614 - XrpcError::invalid_request("neither name nor a source url with path was supplied") 679 + XrpcError::invalid_request( 680 + "the request has neither a name or a source url ending in a repo name", 681 + ) 615 682 })?; 616 683 let repo_did = crate::merge::resolve_by_name(&state, &input.did, &name)?; 617 684 crate::authorize_push(&state, &actor, &repo_did, FORK_DENIED).await?;
+8 -8
knot2/crates/knot-xrpc/src/merge.rs
··· 17 17 use knot_postreceive::{Actor, Ci}; 18 18 use knot_runtime::{Clock, HttpTransport}; 19 19 use knot_types::{ 20 - AuthorName, BranchName, Email, Oid, OwnerDid, RefName, RepoDid, RepoRkey, UnixSeconds, 20 + AuthorName, BranchName, Email, Oid, OwnerDid, RefName, RepoDid, RepoName, RepoRkey, UnixSeconds, 21 21 }; 22 22 23 - use crate::body::{CommitBody, CommitMessage, Patch, RepoNameArg}; 23 + use crate::body::{CommitBody, CommitMessage, Patch}; 24 24 use crate::error::XrpcError; 25 25 use crate::reads::{open, repo_not_found, warming}; 26 26 use crate::{XrpcState, decode, ok_empty, run_blocking}; ··· 40 40 #[serde(rename_all = "camelCase")] 41 41 struct MergeInput { 42 42 did: OwnerDid, 43 - name: RepoNameArg, 43 + name: RepoName, 44 44 patch: Patch, 45 45 branch: BranchName, 46 46 author_name: Option<AuthorName>, ··· 52 52 #[derive(Deserialize)] 53 53 struct MergeCheckInput { 54 54 did: OwnerDid, 55 - name: RepoNameArg, 55 + name: RepoName, 56 56 patch: Patch, 57 57 branch: BranchName, 58 58 } ··· 156 156 pub(crate) fn resolve_by_name<H: HttpTransport, C: Clock>( 157 157 state: &XrpcState<H, C>, 158 158 owner: &OwnerDid, 159 - name: &str, 159 + name: &RepoName, 160 160 ) -> Result<RepoDid, XrpcError> { 161 - let rkey = RepoRkey::new(name).map_err(|_| repo_not_found())?; 161 + let rkey = RepoRkey::new(name.as_str()).map_err(|_| repo_not_found())?; 162 162 match state.index.resolve_repo(owner, &rkey) { 163 163 Resolved::Ready(found) => found.ok_or_else(repo_not_found), 164 164 Resolved::Warming => Err(warming()), ··· 383 383 ) -> Result<Response, XrpcError> { 384 384 let actor = state.authenticate(&headers, &method).await?; 385 385 let input: MergeInput = decode(&body)?; 386 - let repo_did = resolve_by_name(&state, &input.did, input.name.as_str())?; 386 + let repo_did = resolve_by_name(&state, &input.did, &input.name)?; 387 387 crate::authorize_push( 388 388 &state, 389 389 &actor, ··· 502 502 body: Bytes, 503 503 ) -> Result<Response, XrpcError> { 504 504 let input: MergeCheckInput = decode(&body)?; 505 - let repo_did = resolve_by_name(&state, &input.did, input.name.as_str())?; 505 + let repo_did = resolve_by_name(&state, &input.did, &input.name)?; 506 506 let refname = input.branch.head_ref(); 507 507 let layout = state.layout.clone(); 508 508 let max_patch_bytes = state.byte_limits.patch_decompressed.get();
+134 -9
knot2/crates/knot-xrpc/src/tests.rs
··· 22 22 }; 23 23 use knot_secrets::{MasterKey, SealedStore}; 24 24 use knot_types::{ 25 - AccountDid, AdmissionPolicy, AuthorName, Email, KnotHostname, KnotId, OwnerDid, RepoDid, 26 - RepoRkey, 25 + AccountDid, AdmissionPolicy, AuthorName, Email, KnotHostname, KnotId, OriginUrl, OwnerDid, 26 + RepoDid, RepoName, RepoRkey, 27 27 }; 28 28 29 29 use crate::XrpcState; ··· 1475 1475 ); 1476 1476 1477 1477 assert!( 1478 - crate::merge::resolve_by_name(&*state, &owner, "anemone").is_ok(), 1478 + crate::merge::resolve_by_name(&*state, &owner, &RepoName::new("anemone").unwrap()).is_ok(), 1479 1479 "the exact rkey resolves" 1480 1480 ); 1481 1481 assert!( 1482 - crate::merge::resolve_by_name(&*state, &owner, "Anemone").is_err(), 1482 + crate::merge::resolve_by_name(&*state, &owner, &RepoName::new("Anemone").unwrap()).is_err(), 1483 1483 "a differently-cased name must not resolve to a distinct rkey, atproto record keys are case-sensitive" 1484 1484 ); 1485 1485 } ··· 2878 2878 Some(setup.tip) 2879 2879 ); 2880 2880 assert_eq!(fork.default_branch().unwrap().as_str(), "refs/heads/main"); 2881 - assert_eq!( 2882 - fork.origin_url().as_deref(), 2883 - Some(source_url("kelp").as_str()) 2884 - ); 2881 + assert_eq!(fork.origin_url(), Some(OriginUrl::new(source_url("kelp")))); 2885 2882 assert!( 2886 2883 fork.references().unwrap().iter().all(|record| { 2887 2884 !record.name.as_str().starts_with("refs/cobs/") ··· 2990 2987 } 2991 2988 2992 2989 #[tokio::test] 2990 + async fn hidden_ref_resolves_a_file_origin_by_trailing_segments() { 2991 + let setup = forked_world().await; 2992 + let source = setup.world.layout.open(&setup.source_did).unwrap(); 2993 + let hidden = RefName::new("refs/hidden/feature/main").unwrap(); 2994 + 2995 + setup 2996 + .world 2997 + .layout 2998 + .open(&setup.fork_did) 2999 + .unwrap() 3000 + .set_origin_url(&OriginUrl::new(format!( 3001 + "file:///home/git/{}", 3002 + setup.source_did.as_str() 3003 + ))) 3004 + .unwrap(); 3005 + let did_tip = advance(&source, &main_ref(), "spray.txt", "salt\n", 1_002); 3006 + assert_eq!( 3007 + track_hidden(&setup.world, "feature", "main").await, 3008 + StatusCode::OK 3009 + ); 3010 + assert_eq!( 3011 + setup 3012 + .world 3013 + .layout 3014 + .open(&setup.fork_did) 3015 + .unwrap() 3016 + .find_ref(&hidden) 3017 + .unwrap(), 3018 + Some(did_tip), 3019 + "a trailing repo did resolves to the source repo" 3020 + ); 3021 + 3022 + setup 3023 + .world 3024 + .layout 3025 + .open(&setup.fork_did) 3026 + .unwrap() 3027 + .set_origin_url(&OriginUrl::new(format!( 3028 + "file:///home/git/did:web:{MEMBER_HOST}/kelp" 3029 + ))) 3030 + .unwrap(); 3031 + let named_tip = advance(&source, &main_ref(), "swell.txt", "tide\n", 1_003); 3032 + assert_eq!( 3033 + track_hidden(&setup.world, "feature", "main").await, 3034 + StatusCode::OK 3035 + ); 3036 + assert_eq!( 3037 + setup 3038 + .world 3039 + .layout 3040 + .open(&setup.fork_did) 3041 + .unwrap() 3042 + .find_ref(&hidden) 3043 + .unwrap(), 3044 + Some(named_tip), 3045 + "a trailing owner and name resolves to the source repo" 3046 + ); 3047 + } 3048 + 3049 + #[tokio::test] 3050 + async fn hidden_ref_rejects_a_stale_or_non_http_stored_origin() { 3051 + let setup = forked_world().await; 3052 + let fork = setup.world.layout.open(&setup.fork_did).unwrap(); 3053 + 3054 + fork.set_origin_url(&OriginUrl::new("file:///home/git/did:plc:whelk")) 3055 + .unwrap(); 3056 + assert_eq!( 3057 + track_hidden(&setup.world, "feature", "main").await, 3058 + StatusCode::NOT_FOUND, 3059 + "the knot reports not found for a file origin with an unknown repo did" 3060 + ); 3061 + 3062 + fork.set_origin_url(&OriginUrl::new("ssh://knot.nel.pet/did:plc:whelk/ghost")) 3063 + .unwrap(); 3064 + assert_eq!( 3065 + track_hidden(&setup.world, "feature", "main").await, 3066 + StatusCode::INTERNAL_SERVER_ERROR, 3067 + "the knot reports an internal error for a stored origin scheme other than http, https, or file" 3068 + ); 3069 + 3070 + fork.set_origin_url(&OriginUrl::new("file:///kelp")) 3071 + .unwrap(); 3072 + assert_eq!( 3073 + track_hidden(&setup.world, "feature", "main").await, 3074 + StatusCode::INTERNAL_SERVER_ERROR, 3075 + "the knot reports an internal error for a file origin whose only path segment isn't a DID" 3076 + ); 3077 + } 3078 + 3079 + #[test] 3080 + fn parse_trailing_resolves_each_stored_path_shape() { 3081 + let shape = |raw: &str| { 3082 + let url = url::Url::parse(raw).unwrap(); 3083 + match crate::forks::LocalPath::parse_trailing(&url) { 3084 + Ok(crate::forks::LocalPath::Did(did)) => format!("did {did}"), 3085 + Ok(crate::forks::LocalPath::Named { owner, name }) => { 3086 + format!("named {owner} {name}") 3087 + } 3088 + Err(reason) => format!("err {reason}"), 3089 + } 3090 + }; 3091 + [ 3092 + ( 3093 + "file:///home/git/did:web:oyster.cafe/kelp", 3094 + "named did:web:oyster.cafe kelp", 3095 + ), 3096 + ( 3097 + "file:///home/git/did:web:oyster.cafe/kelp.git", 3098 + "named did:web:oyster.cafe kelp", 3099 + ), 3100 + ( 3101 + "file:///home/git/did:web:oyster.cafe/did:plc:squid", 3102 + "named did:web:oyster.cafe did:plc:squid", 3103 + ), 3104 + ("file:///data/repos/did:plc:squid", "did did:plc:squid"), 3105 + ("file:///did:plc:squid", "did did:plc:squid"), 3106 + ( 3107 + "file:///home/git/kelp", 3108 + "err path ends in neither /owner-did/name or /repo-did", 3109 + ), 3110 + ("file:///kelp", "err path isn't a DID"), 3111 + ("file:///", "err path must be /did or /owner/name"), 3112 + ] 3113 + .into_iter() 3114 + .for_each(|(raw, expected)| assert_eq!(shape(raw), expected, "{raw}")); 3115 + } 3116 + 3117 + #[tokio::test] 2993 3118 async fn fork_status_reports_up_to_date_fast_forwardable_and_conflict() { 2994 3119 let setup = forked_world().await; 2995 3120 assert_eq!( ··· 3087 3212 "a fork of a sha1 upstream must be sha1 so the upstream's objects ingest" 3088 3213 ); 3089 3214 assert_eq!(fork.find_ref(&main_ref()).unwrap(), Some(tip)); 3090 - assert_eq!(fork.origin_url().as_deref(), Some(remote)); 3215 + assert_eq!(fork.origin_url(), Some(OriginUrl::new(remote))); 3091 3216 assert_eq!( 3092 3217 fork.read_blob( 3093 3218 fork.entry_at(tip, &knot_types::RepoPath::new("tide.txt").unwrap())