From 0c162429c2b7374653513ddc8dbd024ce450c3c5 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 29 Jul 2026 08:17:40 +0300 Subject: [PATCH] knot2/xrpc: tolerate old owner-name file remote Lewis: May this revision serve well! --- knot2/crates/knot-git/src/repo.rs | 10 +- .../knot-postreceive/tests/post_receive.rs | 6 +- knot2/crates/knot-types/src/ids.rs | 4 + knot2/crates/knot-types/src/lib.rs | 6 +- knot2/crates/knot-xrpc/src/body.rs | 31 +--- knot2/crates/knot-xrpc/src/forks.rs | 155 +++++++++++++----- knot2/crates/knot-xrpc/src/merge.rs | 16 +- knot2/crates/knot-xrpc/src/tests.rs | 143 +++++++++++++++- 8 files changed, 275 insertions(+), 96 deletions(-) diff --git a/knot2/crates/knot-git/src/repo.rs b/knot2/crates/knot-git/src/repo.rs index fb167510..b5ba8f35 100644 --- a/knot2/crates/knot-git/src/repo.rs +++ b/knot2/crates/knot-git/src/repo.rs @@ -7,7 +7,7 @@ use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; use gix::refs::{FullName, Target}; use knot_cache::{Cache, Moka, Weight}; use knot_types::{ - BranchName, KnotId, ObjectFormat, Oid, RefName, RefTransition, RepoDid, UnixSeconds, + BranchName, KnotId, ObjectFormat, Oid, OriginUrl, RefName, RefTransition, RepoDid, UnixSeconds, }; use crate::error::GitError; @@ -689,14 +689,14 @@ impl Repo { dirs.iter().try_for_each(|dir| fsync_if_present(dir)) } - pub fn origin_url(&self) -> Option { + pub fn origin_url(&self) -> Option { self.git .config_snapshot() .string("remote.origin.url") - .map(|value| value.to_string()) + .map(|value| OriginUrl::new(value.to_string())) } - pub fn set_origin_url(&self, url: &str) -> Result<(), GitError> { + pub fn set_origin_url(&self, url: &OriginUrl) -> Result<(), GitError> { let path = self.git.git_dir().join("config"); let report = |message: String| GitError::Config { path: path.clone(), @@ -709,7 +709,7 @@ impl Repo { "remote", Some(gix::bstr::BStr::new("origin")), "url", - gix::bstr::BStr::new(url), + gix::bstr::BStr::new(url.as_str()), ) .map_err(|error| report(error.to_string()))?; knot_resource::atomic_write(&path, knot_resource::FileMode::Inherited, |out| { diff --git a/knot2/crates/knot-postreceive/tests/post_receive.rs b/knot2/crates/knot-postreceive/tests/post_receive.rs index 1e18ddc0..96446517 100644 --- a/knot2/crates/knot-postreceive/tests/post_receive.rs +++ b/knot2/crates/knot-postreceive/tests/post_receive.rs @@ -6,8 +6,8 @@ use knot_git::{Layout, RefUpdate, Repo}; use knot_postreceive::{Actor, Ci, LanguagesPushBudget, OwnerLabel, PullLink, post_receive}; use knot_runtime::{ManualClock, UnixMicros}; use knot_types::{ - AccountDid, AppviewEndpoint, BranchName, CiLogsAddr, Handle, Oid, OwnerDid, PushOption, - PushOptions, RefName, RepoDid, RepoRkey, + AccountDid, AppviewEndpoint, BranchName, CiLogsAddr, Handle, Oid, OriginUrl, OwnerDid, + PushOption, PushOptions, RefName, RepoDid, RepoRkey, }; const DID: &str = "did:plc:limpet"; @@ -636,7 +636,7 @@ fn no_pull_request_link_for_default_existing_forked_or_rootless_branches() { ("new branch on a fork with an origin remote", |w| { let head = create_feature(w); w.repo - .set_origin_url("https://oyster.cafe/did:plc:squid/anemone") + .set_origin_url(&OriginUrl::new("https://oyster.cafe/did:plc:squid/anemone")) .unwrap(); created("feature", head) }), diff --git a/knot2/crates/knot-types/src/ids.rs b/knot2/crates/knot-types/src/ids.rs index 6dd0419f..2ae9efcb 100644 --- a/knot2/crates/knot-types/src/ids.rs +++ b/knot2/crates/knot-types/src/ids.rs @@ -152,6 +152,10 @@ crate::text_newtype! { pub struct Email(String) => strip_control; } +crate::text_newtype! { + pub struct OriginUrl(String) => verbatim; +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct LanguageName(&'static str); diff --git a/knot2/crates/knot-types/src/lib.rs b/knot2/crates/knot-types/src/lib.rs index 4fa5da36..1293857f 100644 --- a/knot2/crates/knot-types/src/lib.rs +++ b/knot2/crates/knot-types/src/lib.rs @@ -8,9 +8,9 @@ mod ids; pub use ids::{ 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, + LogsHost, LogsPort, ObjectCount, ObjectFormat, OfferedKey, Oid, OriginUrl, OwnerDid, OwnerRef, + ParseError, PushOption, PushOptions, RefName, RefTransition, RepoDid, RepoName, RepoPath, + RepoRkey, ServiceDid, TagName, TypeName, UnixMicros, UnixSeconds, }; mod policy; diff --git a/knot2/crates/knot-xrpc/src/body.rs b/knot2/crates/knot-xrpc/src/body.rs index 8825c2d8..19b4e9f7 100644 --- a/knot2/crates/knot-xrpc/src/body.rs +++ b/knot2/crates/knot-xrpc/src/body.rs @@ -1,7 +1,7 @@ use serde::Deserialize; use serde::de::{self, Deserializer}; -use knot_types::{AtUri, RefName, RepoName}; +use knot_types::{AtUri, RefName}; use url::Url; pub(crate) struct RepoAtUri(AtUri); @@ -21,29 +21,15 @@ impl<'de> Deserialize<'de> for RepoAtUri { } } -pub(crate) struct RepoNameArg(RepoName); - -impl RepoNameArg { - pub(crate) fn as_str(&self) -> &str { - self.0.as_str() - } -} - -impl<'de> Deserialize<'de> for RepoNameArg { - fn deserialize>(deserializer: D) -> Result { - let raw = String::deserialize(deserializer)?; - RepoName::new(raw) - .map(RepoNameArg) - .map_err(de::Error::custom) - } -} - #[derive(Clone)] pub(crate) struct SourceUrl(Url); impl SourceUrl { - pub(crate) fn parse(raw: &str) -> Result { - parse_source_url(raw) + pub(crate) fn from_url(url: Url) -> Result { + match matches!(url.scheme(), "http" | "https") && url.has_host() { + true => Ok(Self(url)), + false => Err("source must be an http or https url"), + } } pub(crate) fn as_str(&self) -> &str { @@ -64,10 +50,7 @@ impl<'de> Deserialize<'de> for SourceUrl { fn parse_source_url(raw: &str) -> Result { let url = Url::parse(raw).map_err(|_| "source must be a valid url")?; - match matches!(url.scheme(), "http" | "https") && url.has_host() { - true => Ok(SourceUrl(url)), - false => Err("source must be an http or https url"), - } + SourceUrl::from_url(url) } // A sourceless repo is a plain repo not a bad request necessarily, diff --git a/knot2/crates/knot-xrpc/src/forks.rs b/knot2/crates/knot-xrpc/src/forks.rs index 56c1adec..03b2e6fd 100644 --- a/knot2/crates/knot-xrpc/src/forks.rs +++ b/knot2/crates/knot-xrpc/src/forks.rs @@ -14,9 +14,9 @@ use knot_index::Resolved; use knot_pack::{FetchError, HaveOids, PackLimits, UpstreamRefs, WantOids}; use knot_postreceive::{Actor, Ci}; use knot_runtime::{Clock, HttpTransport}; -use knot_types::{BranchName, ObjectFormat, Oid, OwnerDid, RefName, RepoDid}; +use knot_types::{BranchName, ObjectFormat, Oid, OriginUrl, OwnerDid, RefName, RepoDid, RepoName}; -use crate::body::{ForkRef, RemoteRef, RepoAtUri, RepoNameArg, Revspec, SourceUrl}; +use crate::body::{ForkRef, RemoteRef, RepoAtUri, Revspec, SourceUrl}; use crate::branches::resolve_at_uri; use crate::error::XrpcError; use crate::{XrpcState, decode, ok_empty, run_blocking}; @@ -39,37 +39,70 @@ fn url_authority(url: &Url) -> String { } } -fn resolve_local_path( - state: &XrpcState, - url: &Url, -) -> Result { - let segments: Vec<&str> = url - .path_segments() +fn path_segments(url: &Url) -> Vec<&str> { + url.path_segments() .map(|segments| segments.filter(|segment| !segment.is_empty()).collect()) - .unwrap_or_default(); - match segments.as_slice() { - [did] => { - let did = RepoDid::new(*did) - .map_err(|_| XrpcError::invalid_request("fork source path isn't a DID"))?; - match state.index.owner_of(&did) { - Resolved::Ready(Some(_)) => Ok(did), - Resolved::Ready(None) => Err(XrpcError::not_found( - "fork source isn't hosted on this knot", - )), - Resolved::Warming => { - Err(XrpcError::warming("registry projection is still warming")) - } - } + .unwrap_or_default() +} + +pub(crate) enum LocalPath { + Did(RepoDid), + Named { owner: OwnerDid, name: RepoName }, +} + +impl LocalPath { + // The go knot at one point cloned same-host forks like + // `file:///home/git//` URLs, + // so over here in the future what we're gonna do + // instead of rewriting them all is take the trailing + // segments, pretend /home/git doesn't exist, and voila, + // we somewhat know which repo. + pub(crate) fn parse_trailing(url: &Url) -> Result { + let segments = path_segments(url); + match segments.as_slice() { + [.., owner, name] => match OwnerDid::new(*owner) { + Ok(owner) => Self::named(owner, name), + Err(_) => RepoDid::new(*name) + .map(Self::Did) + .map_err(|_| "path ends in neither /owner-did/name or /repo-did"), + }, + other => Self::from_segments(other), } - [owner, name] => { - let owner = OwnerDid::new(*owner) - .map_err(|_| XrpcError::invalid_request("fork source owner segment isn't a DID"))?; - let name = name.strip_suffix(".git").unwrap_or(name); - crate::merge::resolve_by_name(state, &owner, name) + } + + fn from_segments(segments: &[&str]) -> Result { + match segments { + [did] => RepoDid::new(*did) + .map(Self::Did) + .map_err(|_| "path isn't a DID"), + [owner, name] => OwnerDid::new(*owner) + .map_err(|_| "owner segment isn't a DID") + .and_then(|owner| Self::named(owner, name)), + _ => Err("path must be /did or /owner/name"), } - _ => Err(XrpcError::invalid_request( - "fork source path must be /did or /owner/name", - )), + } + + fn named(owner: OwnerDid, name: &str) -> Result { + let name = name.strip_suffix(".git").unwrap_or(name); + RepoName::new(name) + .map(|name| Self::Named { owner, name }) + .map_err(|_| "name segment isn't a valid repo name") + } +} + +fn resolve_local( + state: &XrpcState, + path: &LocalPath, +) -> Result { + match path { + LocalPath::Did(did) => match state.index.owner_of(did) { + Resolved::Ready(Some(_)) => Ok(did.clone()), + Resolved::Ready(None) => Err(XrpcError::not_found( + "fork source isn't hosted on this knot", + )), + Resolved::Warming => Err(XrpcError::warming("registry projection is still warming")), + }, + LocalPath::Named { owner, name } => crate::merge::resolve_by_name(state, owner, name), } } @@ -79,11 +112,45 @@ pub(crate) fn resolve_upstream( ) -> Result { let url = source.as_url(); if url_authority(url) == state.knot_authority() { - return resolve_local_path(state, url).map(Upstream::Local); + return LocalPath::from_segments(&path_segments(url)) + .map_err(|reason| XrpcError::invalid_request(format!("fork source {reason}"))) + .and_then(|path| resolve_local(state, &path)) + .map(Upstream::Local); } Ok(Upstream::Remote(url.clone())) } +enum ForkOrigin { + Source(SourceUrl), + File(Url), +} + +impl ForkOrigin { + fn parse(origin: &OriginUrl) -> Result { + let url = Url::parse(origin.as_str()).map_err(|_| "isn't a valid url")?; + match url.scheme() { + "file" => Ok(Self::File(url)), + "http" | "https" => SourceUrl::from_url(url) + .map(Self::Source) + .map_err(|_| "has no host"), + _ => Err("scheme isn't http, https, or file"), + } + } +} + +fn resolve_origin( + state: &XrpcState, + origin: &ForkOrigin, +) -> Result { + match origin { + ForkOrigin::Source(source) => resolve_upstream(state, source), + ForkOrigin::File(url) => LocalPath::parse_trailing(url) + .map_err(|reason| XrpcError::internal(format!("stored fork origin {reason}"))) + .and_then(|path| resolve_local(state, &path)) + .map(Upstream::Local), + } +} + pub(crate) struct ForkSource { pub(crate) origin: SourceUrl, pub(crate) upstream: Upstream, @@ -222,7 +289,7 @@ pub(crate) fn populate_fork( { repo.set_head(head)?; } - repo.set_origin_url(origin.as_str()) + repo.set_origin_url(&OriginUrl::new(origin.as_str())) .map_err(XrpcError::from) } @@ -295,7 +362,7 @@ fn force_ref( const FORK_DENIED: &str = "only repository owner or a collaborator may operate on this fork"; struct ForkState { - origin: SourceUrl, + origin: ForkOrigin, haves: Vec, object_format: ObjectFormat, } @@ -304,8 +371,8 @@ fn load_fork_state(repo: &Repo) -> Result { let origin = repo.origin_url().ok_or_else(|| { XrpcError::invalid_request("this repository isn't a fork and has no upstream") })?; - let origin = SourceUrl::parse(&origin) - .map_err(|reason| XrpcError::internal(format!("stored fork origin: {reason}")))?; + let origin = ForkOrigin::parse(&origin) + .map_err(|reason| XrpcError::internal(format!("stored fork origin {reason}")))?; let haves = repo .references()? .into_iter() @@ -367,7 +434,7 @@ async fn pull_upstream_branch( }) .await?; - let upstream = resolve_upstream(state, &fork.origin)?; + let upstream = resolve_origin(state, &fork.origin)?; let refs = upstream_refs(state, &upstream, vec![branch.as_str().to_string()]).await?; if refs.object_format != fork.object_format { return Err(XrpcError::conflict(format!( @@ -426,7 +493,7 @@ async fn pull_upstream_branch( #[derive(Deserialize)] struct ForkSyncInput { did: OwnerDid, - name: RepoNameArg, + name: RepoName, branch: BranchName, } @@ -438,7 +505,7 @@ pub(crate) async fn fork_sync( ) -> Result { let actor = state.authenticate(&headers, &method).await?; let input: ForkSyncInput = decode(&body)?; - let repo_did = crate::merge::resolve_by_name(&state, &input.did, input.name.as_str())?; + let repo_did = crate::merge::resolve_by_name(&state, &input.did, &input.name)?; crate::authorize_push(&state, &actor, &repo_did, FORK_DENIED).await?; let branch = input.branch.head_ref(); let sync = pull_upstream_branch( @@ -572,7 +639,7 @@ impl ForkStatus { #[derive(Deserialize)] struct ForkStatusInput { did: OwnerDid, - name: Option, + name: Option, #[serde(default, deserialize_with = "crate::body::optional_source_url")] source: Option, branch: Revspec, @@ -585,11 +652,11 @@ struct ForkStatusOutput { status: u8, } -fn source_basename(source: &Url) -> Option { +fn source_basename(source: &Url) -> Option { source .path_segments() .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty())) - .map(str::to_string) + .and_then(|segment| RepoName::new(segment).ok()) } pub(crate) async fn fork_status( @@ -602,8 +669,6 @@ pub(crate) async fn fork_status( let input: ForkStatusInput = decode(&body)?; let name = input .name - .as_ref() - .map(|name| name.as_str().to_string()) .or_else(|| { input .source @@ -611,7 +676,9 @@ pub(crate) async fn fork_status( .and_then(|source| source_basename(source.as_url())) }) .ok_or_else(|| { - XrpcError::invalid_request("neither name nor a source url with path was supplied") + XrpcError::invalid_request( + "the request has neither a name or a source url ending in a repo name", + ) })?; let repo_did = crate::merge::resolve_by_name(&state, &input.did, &name)?; crate::authorize_push(&state, &actor, &repo_did, FORK_DENIED).await?; diff --git a/knot2/crates/knot-xrpc/src/merge.rs b/knot2/crates/knot-xrpc/src/merge.rs index c50b34cc..6f3a618d 100644 --- a/knot2/crates/knot-xrpc/src/merge.rs +++ b/knot2/crates/knot-xrpc/src/merge.rs @@ -17,10 +17,10 @@ use knot_index::Resolved; use knot_postreceive::{Actor, Ci}; use knot_runtime::{Clock, HttpTransport}; use knot_types::{ - AuthorName, BranchName, Email, Oid, OwnerDid, RefName, RepoDid, RepoRkey, UnixSeconds, + AuthorName, BranchName, Email, Oid, OwnerDid, RefName, RepoDid, RepoName, RepoRkey, UnixSeconds, }; -use crate::body::{CommitBody, CommitMessage, Patch, RepoNameArg}; +use crate::body::{CommitBody, CommitMessage, Patch}; use crate::error::XrpcError; use crate::reads::{open, repo_not_found, warming}; use crate::{XrpcState, decode, ok_empty, run_blocking}; @@ -40,7 +40,7 @@ pub struct Committer { #[serde(rename_all = "camelCase")] struct MergeInput { did: OwnerDid, - name: RepoNameArg, + name: RepoName, patch: Patch, branch: BranchName, author_name: Option, @@ -52,7 +52,7 @@ struct MergeInput { #[derive(Deserialize)] struct MergeCheckInput { did: OwnerDid, - name: RepoNameArg, + name: RepoName, patch: Patch, branch: BranchName, } @@ -156,9 +156,9 @@ fn parse_specs( pub(crate) fn resolve_by_name( state: &XrpcState, owner: &OwnerDid, - name: &str, + name: &RepoName, ) -> Result { - let rkey = RepoRkey::new(name).map_err(|_| repo_not_found())?; + let rkey = RepoRkey::new(name.as_str()).map_err(|_| repo_not_found())?; match state.index.resolve_repo(owner, &rkey) { Resolved::Ready(found) => found.ok_or_else(repo_not_found), Resolved::Warming => Err(warming()), @@ -383,7 +383,7 @@ pub(crate) async fn merge( ) -> Result { let actor = state.authenticate(&headers, &method).await?; let input: MergeInput = decode(&body)?; - let repo_did = resolve_by_name(&state, &input.did, input.name.as_str())?; + let repo_did = resolve_by_name(&state, &input.did, &input.name)?; crate::authorize_push( &state, &actor, @@ -502,7 +502,7 @@ pub(crate) async fn merge_check( body: Bytes, ) -> Result { let input: MergeCheckInput = decode(&body)?; - let repo_did = resolve_by_name(&state, &input.did, input.name.as_str())?; + let repo_did = resolve_by_name(&state, &input.did, &input.name)?; let refname = input.branch.head_ref(); let layout = state.layout.clone(); let max_patch_bytes = state.byte_limits.patch_decompressed.get(); diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index f7d256c9..4de708a1 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -22,8 +22,8 @@ use knot_runtime::{ }; use knot_secrets::{MasterKey, SealedStore}; use knot_types::{ - AccountDid, AdmissionPolicy, AuthorName, Email, KnotHostname, KnotId, OwnerDid, RepoDid, - RepoRkey, + AccountDid, AdmissionPolicy, AuthorName, Email, KnotHostname, KnotId, OriginUrl, OwnerDid, + RepoDid, RepoName, RepoRkey, }; use crate::XrpcState; @@ -1475,11 +1475,11 @@ async fn resolve_by_name_matches_the_rkey_case_sensitively() { ); assert!( - crate::merge::resolve_by_name(&*state, &owner, "anemone").is_ok(), + crate::merge::resolve_by_name(&*state, &owner, &RepoName::new("anemone").unwrap()).is_ok(), "the exact rkey resolves" ); assert!( - crate::merge::resolve_by_name(&*state, &owner, "Anemone").is_err(), + crate::merge::resolve_by_name(&*state, &owner, &RepoName::new("Anemone").unwrap()).is_err(), "a differently-cased name must not resolve to a distinct rkey, atproto record keys are case-sensitive" ); } @@ -2878,10 +2878,7 @@ mod fork_endpoints { Some(setup.tip) ); assert_eq!(fork.default_branch().unwrap().as_str(), "refs/heads/main"); - assert_eq!( - fork.origin_url().as_deref(), - Some(source_url("kelp").as_str()) - ); + assert_eq!(fork.origin_url(), Some(OriginUrl::new(source_url("kelp")))); assert!( fork.references().unwrap().iter().all(|record| { !record.name.as_str().starts_with("refs/cobs/") @@ -2989,6 +2986,134 @@ mod fork_endpoints { ); } + #[tokio::test] + async fn hidden_ref_resolves_a_file_origin_by_trailing_segments() { + let setup = forked_world().await; + let source = setup.world.layout.open(&setup.source_did).unwrap(); + let hidden = RefName::new("refs/hidden/feature/main").unwrap(); + + setup + .world + .layout + .open(&setup.fork_did) + .unwrap() + .set_origin_url(&OriginUrl::new(format!( + "file:///home/git/{}", + setup.source_did.as_str() + ))) + .unwrap(); + let did_tip = advance(&source, &main_ref(), "spray.txt", "salt\n", 1_002); + assert_eq!( + track_hidden(&setup.world, "feature", "main").await, + StatusCode::OK + ); + assert_eq!( + setup + .world + .layout + .open(&setup.fork_did) + .unwrap() + .find_ref(&hidden) + .unwrap(), + Some(did_tip), + "a trailing repo did resolves to the source repo" + ); + + setup + .world + .layout + .open(&setup.fork_did) + .unwrap() + .set_origin_url(&OriginUrl::new(format!( + "file:///home/git/did:web:{MEMBER_HOST}/kelp" + ))) + .unwrap(); + let named_tip = advance(&source, &main_ref(), "swell.txt", "tide\n", 1_003); + assert_eq!( + track_hidden(&setup.world, "feature", "main").await, + StatusCode::OK + ); + assert_eq!( + setup + .world + .layout + .open(&setup.fork_did) + .unwrap() + .find_ref(&hidden) + .unwrap(), + Some(named_tip), + "a trailing owner and name resolves to the source repo" + ); + } + + #[tokio::test] + async fn hidden_ref_rejects_a_stale_or_non_http_stored_origin() { + let setup = forked_world().await; + let fork = setup.world.layout.open(&setup.fork_did).unwrap(); + + fork.set_origin_url(&OriginUrl::new("file:///home/git/did:plc:whelk")) + .unwrap(); + assert_eq!( + track_hidden(&setup.world, "feature", "main").await, + StatusCode::NOT_FOUND, + "the knot reports not found for a file origin with an unknown repo did" + ); + + fork.set_origin_url(&OriginUrl::new("ssh://knot.nel.pet/did:plc:whelk/ghost")) + .unwrap(); + assert_eq!( + track_hidden(&setup.world, "feature", "main").await, + StatusCode::INTERNAL_SERVER_ERROR, + "the knot reports an internal error for a stored origin scheme other than http, https, or file" + ); + + fork.set_origin_url(&OriginUrl::new("file:///kelp")) + .unwrap(); + assert_eq!( + track_hidden(&setup.world, "feature", "main").await, + StatusCode::INTERNAL_SERVER_ERROR, + "the knot reports an internal error for a file origin whose only path segment isn't a DID" + ); + } + + #[test] + fn parse_trailing_resolves_each_stored_path_shape() { + let shape = |raw: &str| { + let url = url::Url::parse(raw).unwrap(); + match crate::forks::LocalPath::parse_trailing(&url) { + Ok(crate::forks::LocalPath::Did(did)) => format!("did {did}"), + Ok(crate::forks::LocalPath::Named { owner, name }) => { + format!("named {owner} {name}") + } + Err(reason) => format!("err {reason}"), + } + }; + [ + ( + "file:///home/git/did:web:oyster.cafe/kelp", + "named did:web:oyster.cafe kelp", + ), + ( + "file:///home/git/did:web:oyster.cafe/kelp.git", + "named did:web:oyster.cafe kelp", + ), + ( + "file:///home/git/did:web:oyster.cafe/did:plc:squid", + "named did:web:oyster.cafe did:plc:squid", + ), + ("file:///data/repos/did:plc:squid", "did did:plc:squid"), + ("file:///did:plc:squid", "did did:plc:squid"), + ( + "file:///home/git/kelp", + "err path ends in neither /owner-did/name or /repo-did", + ), + ("file:///kelp", "err path isn't a DID"), + ("file:///", "err path must be /did or /owner/name"), + ] + .into_iter() + .for_each(|(raw, expected)| assert_eq!(shape(raw), expected, "{raw}")); + } + #[tokio::test] async fn fork_status_reports_up_to_date_fast_forwardable_and_conflict() { let setup = forked_world().await; @@ -3087,7 +3212,7 @@ mod fork_endpoints { "a fork of a sha1 upstream must be sha1 so the upstream's objects ingest" ); assert_eq!(fork.find_ref(&main_ref()).unwrap(), Some(tip)); - assert_eq!(fork.origin_url().as_deref(), Some(remote)); + assert_eq!(fork.origin_url(), Some(OriginUrl::new(remote))); assert_eq!( fork.read_blob( fork.entry_at(tip, &knot_types::RepoPath::new("tide.txt").unwrap()) -- 2.51.2