From b574f60c1ccef69982e8202ce0d397c267dae323 Mon Sep 17 00:00:00 2001 From: Lewis Date: Thu, 06 Aug 2026 11:43:27 +0000 Subject: [PATCH] knot2/{index,xrpc}: take repoDID on writes & drop forkStatus Lewis: May this revision serve well! --- knot2/crates/knot-index/src/lib.rs | 6 +++++- knot2/crates/knot-index/src/projections.rs | 21 +++++++++++++++++++++ knot2/crates/knot-xrpc/src/body.rs | 21 +-------------------- knot2/crates/knot-xrpc/src/branches.rs | 54 ++++++++++++++---------------------------------------- knot2/crates/knot-xrpc/src/forks.rs | 145 ++++++++++++------------------------------------------------------------------------------------------------------------------------------------- knot2/crates/knot-xrpc/src/lfs.rs | 4 ++-- knot2/crates/knot-xrpc/src/lib.rs | 1 - knot2/crates/knot-xrpc/src/merge.rs | 24 ++++++++++-------------- knot2/crates/knot-xrpc/src/reads.rs | 54 +++++++++++++++++++++++++++++++++++++++--------------- knot2/crates/knot-xrpc/src/repos.rs | 12 +++++------- knot2/crates/knot-xrpc/src/service.rs | 4 ++-- knot2/crates/knot-xrpc/src/tests.rs | 531 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ knot2/crates/knot-xrpc/tests/reads.rs | 29 ++++++++++++++--------------- 13 file(s) changed, 392 insertion(s)(+), 514 deletion(s)(-) diff --git a/knot2/crates/knot-index/src/lib.rs b/knot2/crates/knot-index/src/lib.rs --- a/knot2/crates/knot-index/src/lib.rs +++ b/knot2/crates/knot-index/src/lib.rs @@ -14,7 +14,7 @@ use knot_cob::{ChangePayload, CobStore}; use knot_cobs::{ BlocklistChange, BlocklistCob, CollaboratorsChange, CollaboratorsCob, Grant, MembersChange, - MembersCob, RegistryChange, RepoRegistryCob, + MembersCob, RegistryChange, RepoRef, RepoRegistryCob, }; use knot_git::{Layout, Repo}; use knot_types::{AccountDid, ClonePath, OwnerDid, RepoDid, RepoRkey, UnixSeconds}; @@ -524,6 +524,10 @@ pub fn rkey_of(&self, repo: &RepoDid) -> Resolved> { self.registry.rkey_of(&self.interner, repo) + } + + pub fn ownership_of(&self, repo: &RepoDid) -> Resolved> { + self.registry.ownership(&self.interner, repo) } pub fn hosted_repos(&self) -> Vec { diff --git a/knot2/crates/knot-index/src/projections.rs b/knot2/crates/knot-index/src/projections.rs --- a/knot2/crates/knot-index/src/projections.rs +++ b/knot2/crates/knot-index/src/projections.rs @@ -558,6 +558,27 @@ ) } + pub(crate) fn ownership( + &self, + interner: &Interner, + repo: &RepoDid, + ) -> Resolved> { + if self.coverage.get() == Coverage::Warming { + return Resolved::Warming; + } + let Some(target) = interner.repo(repo) else { + return Resolved::Ready(None); + }; + Resolved::Ready( + self.records + .read_sync(&target, |_, slot| (slot.owner, slot.rkey)) + .map(|(owner, rkey)| RepoRef { + owner: interner.resolve_owner(owner), + rkey: interner.resolve_rkey(rkey), + }), + ) + } + pub(crate) fn hosted_repos(&self, interner: &Interner) -> Vec { let mut repos = BTreeSet::new(); self.records.iter_sync(|repo, _| { diff --git a/knot2/crates/knot-xrpc/src/body.rs b/knot2/crates/knot-xrpc/src/body.rs --- a/knot2/crates/knot-xrpc/src/body.rs +++ b/knot2/crates/knot-xrpc/src/body.rs @@ -1,25 +1,8 @@ use serde::Deserialize; use serde::de::{self, Deserializer}; -use knot_types::{AtUri, RefName}; +use knot_types::RefName; use url::Url; - -pub(crate) struct RepoAtUri(AtUri); - -impl RepoAtUri { - pub(crate) fn at_uri(&self) -> &AtUri { - &self.0 - } -} - -impl<'de> Deserialize<'de> for RepoAtUri { - fn deserialize>(deserializer: D) -> Result { - let raw = String::deserialize(deserializer)?; - AtUri::new_owned(raw) - .map(RepoAtUri) - .map_err(|_| de::Error::custom("repo must be an at-uri")) - } -} #[derive(Clone)] pub(crate) struct SourceUrl(Url); @@ -69,8 +52,6 @@ pub(crate) struct CommitMessage(String) => verbatim; pub(crate) struct CommitBody(String) => verbatim; } - -pub(crate) use crate::query::Revspec; pub(crate) struct ForkRef(RefName); diff --git a/knot2/crates/knot-xrpc/src/branches.rs b/knot2/crates/knot-xrpc/src/branches.rs --- a/knot2/crates/knot-xrpc/src/branches.rs +++ b/knot2/crates/knot-xrpc/src/branches.rs @@ -8,58 +8,30 @@ use knot_events::GitRefUpdate; use knot_git::{GitError, RefUpdate}; -use knot_index::Resolved; use knot_runtime::{Clock, HttpTransport}; -use knot_types::{AtUri, BranchName, OwnerDid, RepoDid, RepoRkey}; +use knot_types::{BranchName, RepoDid}; -use crate::body::RepoAtUri; use crate::error::XrpcError; +use crate::reads::require_hosted; use crate::{XrpcState, decode, ok_empty, run_blocking}; pub(crate) const SET_DEFAULT_ROUTE: &str = "/xrpc/sh.tangled.repo.setDefaultBranch"; pub(crate) const DELETE_ROUTE: &str = "/xrpc/sh.tangled.repo.deleteBranch"; -const REPO_COLLECTION: &str = "sh.tangled.repo"; #[derive(Deserialize)] struct SetDefaultBranchInput { - repo: RepoAtUri, + repo: RepoDid, #[serde(rename = "defaultBranch")] default_branch: BranchName, } #[derive(Deserialize)] struct DeleteBranchInput { - repo: RepoAtUri, + repo: RepoDid, branch: BranchName, } const BRANCH_DENIED: &str = "only repository owner or a collaborator may change its branches"; - -pub(crate) fn resolve_at_uri( - state: &XrpcState, - at: &AtUri, -) -> Result { - let owner = OwnerDid::new(at.authority().as_str()) - .map_err(|_| XrpcError::invalid_request("at-uri authority must be a DID"))?; - if at - .collection() - .is_none_or(|collection| collection.as_str() != REPO_COLLECTION) - { - return Err(XrpcError::invalid_request( - "at-uri must address an sh.tangled.repo record", - )); - } - let rkey = at - .rkey() - .ok_or_else(|| XrpcError::invalid_request("at-uri must include a record key"))?; - let rkey = RepoRkey::new(rkey.as_str()) - .map_err(|_| XrpcError::invalid_request("at-uri record key isn't a valid rkey"))?; - match state.index.resolve_repo(&owner, &rkey) { - Resolved::Ready(Some(repo_did)) => Ok(repo_did), - Resolved::Ready(None) => Err(XrpcError::not_found("no such repository on this knot")), - Resolved::Warming => Err(XrpcError::warming("registry projection is still warming")), - } -} pub(crate) async fn set_default_branch( State(state): State>>, @@ -69,7 +41,7 @@ ) -> Result { let actor = state.authenticate(&headers, &method).await?; let input: SetDefaultBranchInput = decode(&body)?; - let repo_did = resolve_at_uri(&state, input.repo.at_uri())?; + let repo_did = require_hosted(&state, input.repo)?; crate::authorize_push(&state, &actor, &repo_did, BRANCH_DENIED).await?; let refname = input.default_branch.head_ref(); @@ -89,7 +61,7 @@ .await?; let owner = crate::current_owner(&state, &repo_did); - reservation.fulfill(&GitRefUpdate::new(repo_did, owner, actor)); + reservation.fulfill(&GitRefUpdate::new(repo_did.into_did(), owner, actor)); Ok(ok_empty()) } @@ -102,7 +74,7 @@ ) -> Result { let actor = state.authenticate(&headers, &method).await?; let input: DeleteBranchInput = decode(&body)?; - let repo_did = resolve_at_uri(&state, input.repo.at_uri())?; + let repo_did = require_hosted(&state, input.repo)?; crate::authorize_push(&state, &actor, &repo_did, BRANCH_DENIED).await?; let refname = input.branch.head_ref(); @@ -135,11 +107,13 @@ .await?; let owner = crate::current_owner(&state, &repo_did); - reservation.fulfill(&GitRefUpdate::new(repo_did, owner, actor).on_ref( - deleted_ref, - knot_types::RefTransition::Delete { old }, - format, - )); + reservation.fulfill( + &GitRefUpdate::new(repo_did.into_did(), owner, actor).on_ref( + deleted_ref, + knot_types::RefTransition::Delete { old }, + format, + ), + ); Ok(ok_empty()) } diff --git a/knot2/crates/knot-xrpc/src/forks.rs b/knot2/crates/knot-xrpc/src/forks.rs --- a/knot2/crates/knot-xrpc/src/forks.rs +++ b/knot2/crates/knot-xrpc/src/forks.rs @@ -10,24 +10,22 @@ use knot_events::Reservation; use knot_git::{Filter, GitError, Haves, RefUpdate, Repo, Staging, Wants}; -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, OriginUrl, OwnerDid, RefName, RepoDid, RepoName}; -use crate::body::{ForkRef, RemoteRef, RepoAtUri, Revspec, SourceUrl}; -use crate::branches::resolve_at_uri; +use crate::body::{ForkRef, RemoteRef, SourceUrl}; use crate::error::XrpcError; +use crate::reads::{HostedRepo, require_hosted}; use crate::{XrpcState, decode, ok_empty, run_blocking}; -pub(crate) const STATUS_ROUTE: &str = "/xrpc/sh.tangled.repo.forkStatus"; pub(crate) const SYNC_ROUTE: &str = "/xrpc/sh.tangled.repo.forkSync"; pub(crate) const HIDDEN_REF_ROUTE: &str = "/xrpc/sh.tangled.repo.hiddenRef"; #[derive(Clone)] pub(crate) enum Upstream { - Local(RepoDid), + Local(HostedRepo), Remote(Url), } @@ -93,15 +91,9 @@ fn resolve_local( state: &XrpcState, path: &LocalPath, -) -> Result { +) -> 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::Did(did) => require_hosted(state, did.clone()), LocalPath::Named { owner, name } => crate::merge::resolve_by_name(state, owner, name), } } @@ -420,7 +412,7 @@ async fn pull_upstream_branch( state: &Arc>, - repo_did: &RepoDid, + repo_did: &HostedRepo, source: SourceRef<'_>, target: TargetRef<'_>, ) -> Result { @@ -466,7 +458,7 @@ let lfs_missing = crate::lfs::mirror_fork_objects( Arc::clone(state), upstream, - repo_did.clone(), + repo_did.clone().into_did(), WantOids::new(vec![tip]), HaveOids::new(haves), ) @@ -492,8 +484,7 @@ #[derive(Deserialize)] struct ForkSyncInput { - did: OwnerDid, - name: RepoName, + repo: RepoDid, branch: BranchName, } @@ -505,7 +496,7 @@ ) -> 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)?; + let repo_did = require_hosted(&state, input.repo)?; crate::authorize_push(&state, &actor, &repo_did, FORK_DENIED).await?; let branch = input.branch.head_ref(); let sync = pull_upstream_branch( @@ -535,7 +526,7 @@ let post_actor = Actor { committer: actor, owner, - repo: event_repo, + repo: event_repo.into_did(), }; knot_postreceive::post_receive( &repo, @@ -568,7 +559,7 @@ #[derive(Deserialize)] struct HiddenRefInput { - repo: RepoAtUri, + repo: RepoDid, #[serde(rename = "forkRef")] fork_ref: ForkRef, #[serde(rename = "remoteRef")] @@ -592,7 +583,7 @@ ) -> Result { let actor = state.authenticate(&headers, &method).await?; let input: HiddenRefInput = decode(&body)?; - let repo_did = resolve_at_uri(&state, input.repo.at_uri())?; + let repo_did = require_hosted(&state, input.repo)?; crate::authorize_push(&state, &actor, &repo_did, FORK_DENIED).await?; let branch = input.remote_ref.head_ref(); let target = input @@ -614,118 +605,6 @@ success: true, ref_name: target, lfs_missing: sync.lfs_missing, - }), - ) - .into_response()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ForkStatus { - UpToDate, - FastForwardable, - Conflict, -} - -impl ForkStatus { - fn code(self) -> u8 { - match self { - ForkStatus::UpToDate => 0, - ForkStatus::FastForwardable => 1, - ForkStatus::Conflict => 2, - } - } -} - -#[derive(Deserialize)] -struct ForkStatusInput { - did: OwnerDid, - name: Option, - #[serde(default, deserialize_with = "crate::body::optional_source_url")] - source: Option, - branch: Revspec, - #[serde(rename = "hiddenRef")] - hidden_ref: Revspec, -} - -#[derive(Serialize)] -struct ForkStatusOutput { - status: u8, -} - -fn source_basename(source: &Url) -> Option { - source - .path_segments() - .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty())) - .and_then(|segment| RepoName::new(segment).ok()) -} - -pub(crate) async fn fork_status( - State(state): State>>, - headers: HeaderMap, - method: crate::Method, - body: Bytes, -) -> Result { - let actor = state.authenticate(&headers, &method).await?; - let input: ForkStatusInput = decode(&body)?; - let name = input - .name - .or_else(|| { - input - .source - .as_ref() - .and_then(|source| source_basename(source.as_url())) - }) - .ok_or_else(|| { - 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?; - - let layout = state.layout.clone(); - let status = run_blocking(move || { - let repo = layout.open(&repo_did)?; - let fork = repo - .resolve_revision(input.branch.as_str()) - .ok_or_else(|| { - XrpcError::invalid_request(format!( - "cannot resolve revision {}", - input.branch.as_str() - )) - }) - .and_then(|oid| { - repo.peel_to_commit(oid) - .map_err(|error| XrpcError::invalid_request(error.to_string())) - })?; - let source = repo - .resolve_revision(input.hidden_ref.as_str()) - .ok_or_else(|| { - XrpcError::invalid_request(format!( - "cannot resolve revision {}", - input.hidden_ref.as_str() - )) - }) - .and_then(|oid| { - repo.peel_to_commit(oid) - .map_err(|error| XrpcError::invalid_request(error.to_string())) - })?; - if fork == source { - return Ok(ForkStatus::UpToDate); - } - let base = repo.merge_base(fork, source)?; - Ok(match base { - Some(base) if base == fork => ForkStatus::FastForwardable, - Some(base) if base == source => ForkStatus::UpToDate, - _ => ForkStatus::Conflict, - }) - }) - .await?; - - Ok(( - http::StatusCode::OK, - Json(ForkStatusOutput { - status: status.code(), }), ) .into_response()) diff --git a/knot2/crates/knot-xrpc/src/lfs.rs b/knot2/crates/knot-xrpc/src/lfs.rs --- a/knot2/crates/knot-xrpc/src/lfs.rs +++ b/knot2/crates/knot-xrpc/src/lfs.rs @@ -798,8 +798,8 @@ } let missing = match upstream { - Upstream::Local(source) => { - let source = source.clone(); + Upstream::Local(hosted) => { + let source = hosted.clone().into_did(); let fork = fork.clone(); let store = Arc::clone(&store); let admission = Arc::clone(&admission); diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -287,7 +287,6 @@ branches::DELETE_ROUTE, post(branches::delete_branch::), ) - .route(forks::STATUS_ROUTE, post(forks::fork_status::)) .route(forks::SYNC_ROUTE, post(forks::fork_sync::)) .route(forks::HIDDEN_REF_ROUTE, post(forks::hidden_ref::)) .route(reads::TREE_ROUTE, get(reads::repo_tree::)) diff --git a/knot2/crates/knot-xrpc/src/merge.rs b/knot2/crates/knot-xrpc/src/merge.rs --- a/knot2/crates/knot-xrpc/src/merge.rs +++ b/knot2/crates/knot-xrpc/src/merge.rs @@ -13,7 +13,6 @@ PatchParseError, RefUpdate, Repo, StagedChange, Staging, is_format_patch, parse_mailbox_bounded, parse_patch_bounded, }; -use knot_index::Resolved; use knot_postreceive::{Actor, Ci}; use knot_runtime::{Clock, HttpTransport}; use knot_types::{ @@ -22,7 +21,8 @@ use crate::body::{CommitBody, CommitMessage, Patch}; use crate::error::XrpcError; -use crate::reads::{open, repo_not_found, warming}; +use crate::query::RepoArg; +use crate::reads::{HostedRepo, open, repo_not_found, require_hosted, resolve_repo}; use crate::{XrpcState, decode, ok_empty, run_blocking}; pub(crate) const MERGE_ROUTE: &str = "/xrpc/sh.tangled.repo.merge"; @@ -39,8 +39,7 @@ #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MergeInput { - did: OwnerDid, - name: RepoName, + repo: RepoDid, patch: Patch, branch: BranchName, author_name: Option, @@ -51,8 +50,7 @@ #[derive(Deserialize)] struct MergeCheckInput { - did: OwnerDid, - name: RepoName, + repo: RepoDid, patch: Patch, branch: BranchName, } @@ -157,12 +155,10 @@ state: &XrpcState, owner: &OwnerDid, name: &RepoName, -) -> Result { +) -> Result { 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()), - } + let owner = owner.clone(); + resolve_repo(state, &RepoArg::OwnerRkey { owner, rkey }) } fn branch_tip(repo: &Repo, refname: &RefName) -> Result { @@ -383,7 +379,7 @@ ) -> Result { let actor = state.authenticate(&headers, &method).await?; let input: MergeInput = decode(&body)?; - let repo_did = resolve_by_name(&state, &input.did, &input.name)?; + let repo_did = require_hosted(&state, input.repo.clone())?; crate::authorize_push( &state, &actor, @@ -444,7 +440,7 @@ let post_actor = Actor { committer: actor, owner, - repo: event_repo, + repo: event_repo.into_did(), }; knot_postreceive::post_receive( &repo, @@ -502,7 +498,7 @@ body: Bytes, ) -> Result { let input: MergeCheckInput = decode(&body)?; - let repo_did = resolve_by_name(&state, &input.did, &input.name)?; + let repo_did = require_hosted(&state, input.repo)?; 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/reads.rs b/knot2/crates/knot-xrpc/src/reads.rs --- a/knot2/crates/knot-xrpc/src/reads.rs +++ b/knot2/crates/knot-xrpc/src/reads.rs @@ -11,6 +11,7 @@ use tower::ServiceExt; use tower_http::services::ServeFile; +use knot_cobs::RepoRef; use knot_git::{ ArchiveFormat, Commit, CommitRange, EntryKind, Layout, LogLimit, LogSkip, Repo, SizedEntry, is_public_ref, screens_reserved, @@ -100,18 +101,46 @@ XrpcError::warming("registry projection is still warming") } -fn resolve_repo( +#[derive(Clone)] +pub(crate) struct HostedRepo(RepoDid); + +impl HostedRepo { + fn registered(did: RepoDid) -> Self { + Self(did) + } + + pub(crate) fn into_did(self) -> RepoDid { + self.0 + } +} + +impl std::ops::Deref for HostedRepo { + type Target = RepoDid; + + fn deref(&self) -> &RepoDid { + &self.0 + } +} + +pub(crate) fn require_hosted( + state: &XrpcState, + repo: RepoDid, +) -> Result { + match state.index.owner_of(&repo) { + Resolved::Ready(Some(_)) => Ok(HostedRepo::registered(repo)), + Resolved::Ready(None) => Err(repo_not_found()), + Resolved::Warming => Err(warming()), + } +} + +pub(crate) fn resolve_repo( state: &XrpcState, repo: &RepoArg, -) -> Result { +) -> Result { match repo { - RepoArg::Did(did) => match state.index.owner_of(did) { - Resolved::Ready(Some(_)) => Ok(did.clone()), - Resolved::Ready(None) => Err(repo_not_found()), - Resolved::Warming => Err(warming()), - }, + RepoArg::Did(did) => require_hosted(state, did.clone()), RepoArg::OwnerRkey { owner, rkey } => match state.index.resolve_repo(owner, rkey) { - Resolved::Ready(Some(did)) => Ok(did), + Resolved::Ready(Some(did)) => Ok(HostedRepo::registered(did)), Resolved::Ready(None) => Err(repo_not_found()), Resolved::Warming => Err(warming()), }, @@ -1510,13 +1539,8 @@ ValidatedQuery(params): ValidatedQuery, ) -> Result { let did = params.repo_did; - let owner = match state.index.owner_of(&did) { - Resolved::Ready(Some(owner)) => owner, - Resolved::Ready(None) => return Err(repo_not_found()), - Resolved::Warming => return Err(warming()), - }; - let rkey = match state.index.rkey_of(&did) { - Resolved::Ready(Some(rkey)) => rkey, + let RepoRef { owner, rkey } = match state.index.ownership_of(&did) { + Resolved::Ready(Some(found)) => found, Resolved::Ready(None) => return Err(repo_not_found()), Resolved::Warming => return Err(warming()), }; diff --git a/knot2/crates/knot-xrpc/src/repos.rs b/knot2/crates/knot-xrpc/src/repos.rs --- a/knot2/crates/knot-xrpc/src/repos.rs +++ b/knot2/crates/knot-xrpc/src/repos.rs @@ -53,10 +53,7 @@ #[derive(Deserialize)] struct DeleteInput { - did: OwnerDid, - rkey: RepoRkey, - #[serde(rename = "name")] - _name: RepoName, + repo: RepoDid, #[serde(default)] force: bool, } @@ -468,11 +465,12 @@ ) -> Result { let actor = state.authenticate(&headers, &method).await?; let DeleteInput { - did, rkey, force, .. + repo: repo_did, + force, } = decode(&body)?; - let repo_did = match state.index.resolve_repo(&did, &rkey) { - Resolved::Ready(Some(repo_did)) => repo_did, + let RepoRef { owner: did, rkey } = match state.index.ownership_of(&repo_did) { + Resolved::Ready(Some(found)) => found, Resolved::Ready(None) => return Ok(ok_empty()), Resolved::Warming => { return Err(XrpcError::warming("registry projection is still warming")); diff --git a/knot2/crates/knot-xrpc/src/service.rs b/knot2/crates/knot-xrpc/src/service.rs --- a/knot2/crates/knot-xrpc/src/service.rs +++ b/knot2/crates/knot-xrpc/src/service.rs @@ -19,7 +19,7 @@ #[derive(Serialize)] struct VersionWire { version: &'static str, - capabilities: [&'static str; 1], + capabilities: [&'static str; 2], } #[derive(Serialize)] @@ -53,7 +53,7 @@ pub(crate) async fn version() -> Response { Json(VersionWire { version: WIRE_VERSION, - capabilities: ["knot-acl"], + capabilities: ["knot-acl", "repo-did-input"], }) .into_response() } diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -45,6 +45,9 @@ const REMOVE_COLLAB: &str = "sh.tangled.repo.removeCollaborator"; const SET_DEFAULT: &str = "sh.tangled.repo.setDefaultBranch"; const DELETE_BRANCH: &str = "sh.tangled.repo.deleteBranch"; +const MERGE: &str = "sh.tangled.repo.merge"; +const FORK_SYNC: &str = "sh.tangled.repo.forkSync"; +const HIDDEN_REF: &str = "sh.tangled.repo.hiddenRef"; type Responder = Box Result + Send + Sync>; type SharedState = Arc, ManualClock>>; @@ -57,6 +60,18 @@ fn account(host: &str) -> AccountDid { AccountDid::new(format!("did:web:{host}")).unwrap() +} + +struct Actor { + signer: K256Signer, + did: AccountDid, +} + +fn actor(seed: u64, host: &str) -> Actor { + Actor { + signer: signer(seed), + did: account(host), + } } fn signer(seed: u64) -> K256Signer { @@ -100,22 +115,22 @@ ) } -fn mint(signer: &K256Signer, issuer: &AccountDid, method: &str) -> String { +fn mint(actor: &Actor, nsid: &str) -> String { let jti = JTI.fetch_add(1, Ordering::Relaxed); let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#); let payload = URL_SAFE_NO_PAD.encode( serde_json::to_vec(&json!({ - "iss": issuer.as_str(), + "iss": actor.did.as_str(), "aud": format!("did:web:{KNOT_HOST}"), "exp": 1_100, "iat": 999, "jti": format!("nonce-{jti}"), - "lxm": method, + "lxm": nsid, })) .unwrap(), ); let signing_input = format!("{header}.{payload}"); - let signature = signer.sign(signing_input.as_bytes()); + let signature = actor.signer.sign(signing_input.as_bytes()); format!( "{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature.as_bytes()) @@ -152,8 +167,7 @@ async fn call( world: &World, handler: F, - signer: &K256Signer, - host: &str, + actor: &Actor, nsid: &str, value: serde_json::Value, ) -> Response @@ -161,7 +175,7 @@ F: FnOnce(State, HeaderMap, crate::Method, Bytes) -> Fut, Fut: std::future::Future>, { - let token = mint(signer, &account(host), nsid); + let token = mint(actor, nsid); into_response( handler( world.state(), @@ -183,7 +197,7 @@ F: FnOnce(State, HeaderMap, crate::Method, Bytes) -> Fut, Fut: std::future::Future>, { - call(world, handler, &world.member, MEMBER_HOST, nsid, value).await + call(world, handler, &world.member, nsid, value).await } async fn as_admin( @@ -196,7 +210,7 @@ F: FnOnce(State, HeaderMap, crate::Method, Bytes) -> Fut, Fut: std::future::Future>, { - call(world, handler, &world.admin, ADMIN_HOST, nsid, value).await + call(world, handler, &world.admin, nsid, value).await } async fn as_stranger( @@ -209,7 +223,7 @@ F: FnOnce(State, HeaderMap, crate::Method, Bytes) -> Fut, Fut: std::future::Future>, { - call(world, handler, &world.stranger, STRANGER_HOST, nsid, value).await + call(world, handler, &world.stranger, nsid, value).await } fn member_owner() -> OwnerDid { @@ -423,9 +437,9 @@ _dir: TempDir, layout: Layout, state: SharedState, - admin: K256Signer, - member: K256Signer, - stranger: K256Signer, + admin: Actor, + member: Actor, + stranger: Actor, repo_docs: Arc>>, pds_records: Arc>>, } @@ -493,23 +507,23 @@ ) -> Self { let dir = tempfile::tempdir().unwrap(); let (layout, index, meta_path) = bootstrap(&dir, true, object_format); - let admin = signer(1); - let member = signer(2); - let stranger = signer(3); + let admin = actor(1, ADMIN_HOST); + let member = actor(2, MEMBER_HOST); + let stranger = actor(3, STRANGER_HOST); let repo_docs: Arc>> = Arc::new(Mutex::new(HashMap::new())); let pds_records: Arc>> = Arc::new(Mutex::new(HashSet::new())); let pubkeys = HashMap::from([ ( ADMIN_HOST.to_string(), - admin.public_key().as_bytes().to_vec(), + admin.signer.public_key().as_bytes().to_vec(), ), ( MEMBER_HOST.to_string(), - member.public_key().as_bytes().to_vec(), + member.signer.public_key().as_bytes().to_vec(), ), ( STRANGER_HOST.to_string(), - stranger.public_key().as_bytes().to_vec(), + stranger.signer.public_key().as_bytes().to_vec(), ), ]); let responder = world_responder(pubkeys, Arc::clone(&repo_docs), Arc::clone(&pds_records)); @@ -631,30 +645,17 @@ } } -async fn create_status( - world: &World, - signer: &K256Signer, - host: &str, - value: serde_json::Value, -) -> StatusCode { - call( - world, - crate::repos::create_repo, - signer, - host, - CREATE, - value, - ) - .await - .status() +async fn create_status(world: &World, actor: &Actor, value: serde_json::Value) -> StatusCode { + call(world, crate::repos::create_repo, actor, CREATE, value) + .await + .status() } -async fn reserve_status(world: &World, signer: &K256Signer, host: &str, did: &str) -> StatusCode { +async fn reserve_status(world: &World, actor: &Actor, did: &str) -> StatusCode { call( world, crate::repos::reserve_key, - signer, - host, + actor, RESERVE, json!({ "repoDid": did }), ) @@ -677,20 +678,13 @@ key } -async fn rename_repo_as( - world: &World, - signer: &K256Signer, - host: &str, - repo: &RepoDid, - rkey: &str, -) -> StatusCode { +async fn rename_repo_as(world: &World, actor: &Actor, repo: &RepoDid, rkey: &str) -> StatusCode { call( world, crate::repos::rename_repo, - signer, - host, + actor, RENAME, - json!({ "repo": repo.as_str(), "rkey": rkey, "name": rkey }), + json!({ "repo": repo, "rkey": rkey, "name": rkey }), ) .await .status() @@ -701,14 +695,14 @@ let closed = World::new(); let make = || json!({ "rkey": "anemone", "name": "anemone" }); assert_eq!( - create_status(&closed, &closed.stranger, STRANGER_HOST, make()).await, + create_status(&closed, &closed.stranger, make()).await, StatusCode::FORBIDDEN, "a closed knot denies a stranger" ); let open = World::open(); assert_eq!( - create_status(&open, &open.stranger, STRANGER_HOST, make()).await, + create_status(&open, &open.stranger, make()).await, StatusCode::OK ); assert!( @@ -745,7 +739,7 @@ Resolved::Ready(true) )); assert_eq!( - create_status(&world, &world.stranger, STRANGER_HOST, make()).await, + create_status(&world, &world.stranger, make()).await, StatusCode::FORBIDDEN, "a banned account cannot create" ); @@ -766,7 +760,7 @@ Resolved::Ready(false) )); assert_eq!( - create_status(&world, &world.stranger, STRANGER_HOST, make()).await, + create_status(&world, &world.stranger, make()).await, StatusCode::OK, "an unban restores creation" ); @@ -894,7 +888,7 @@ let world = World::new(); let lowercase = { - let token = mint(&world.admin, &account(ADMIN_HOST), ADD_MEMBER); + let token = mint(&world.admin, ADD_MEMBER); let mut headers = HeaderMap::new(); headers.insert( AUTHORIZATION, @@ -904,7 +898,7 @@ }; let cases = vec![ Case { - headers: bearer(&mint(&world.member, &account(MEMBER_HOST), ADD_MEMBER)), + headers: bearer(&mint(&world.member, ADD_MEMBER)), body: json!({ "subject": "did:web:olaren.dev" }), status: StatusCode::FORBIDDEN, why: "a non-admin cannot add a member", @@ -916,7 +910,7 @@ why: "a request without a token is unauthorized", }, Case { - headers: bearer(&mint(&world.admin, &account(ADMIN_HOST), ADD_MEMBER)), + headers: bearer(&mint(&world.admin, ADD_MEMBER)), body: json!({ "subject": "not-a-did" }), status: StatusCode::BAD_REQUEST, why: "an invalid DID is rejected at decode by the newtype Deserialize, never reaching a handler", @@ -957,8 +951,8 @@ }) }); let (_dir, state) = build_state(responder, true); - let admin = signer(1); - let token = mint(&admin, &account(ADMIN_HOST), ADD_MEMBER); + let admin = actor(1, ADMIN_HOST); + let token = mint(&admin, ADD_MEMBER); let response = into_response( crate::members::add_member( State(state), @@ -1026,8 +1020,10 @@ use knot_cobs::{Grant, MembersChange, MembersCob}; use knot_git::Repo; - let admin = signer(1); - let responder = doc_responder(admin.public_key().as_bytes().to_vec(), || StatusCode::OK); + let admin = actor(1, ADMIN_HOST); + let responder = doc_responder(admin.signer.public_key().as_bytes().to_vec(), || { + StatusCode::OK + }); let (_dir, state) = build_state(responder, false); let now = state.now(); @@ -1047,7 +1043,7 @@ ) .unwrap(); - let token = mint(&admin, &account(ADMIN_HOST), ADD_MEMBER); + let token = mint(&admin, ADD_MEMBER); assert_eq!( into_response( crate::members::add_member( @@ -1096,7 +1092,6 @@ create_status( &world, &world.member, - MEMBER_HOST, json!({ "rkey": "anemone", "name": "anemone" }) ) .await, @@ -1123,7 +1118,6 @@ create_status( &world, &world.member, - MEMBER_HOST, json!({ "rkey": "a", "name": "a", "repoDid": "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }) ) .await, @@ -1135,7 +1129,6 @@ create_status( &world, &world.member, - MEMBER_HOST, json!({ "rkey": "evil", "name": "evil", "repoDid": format!("did:web:{KNOT_HOST}") }) ) .await, @@ -1152,7 +1145,6 @@ create_status( &world, &world.member, - MEMBER_HOST, json!({ "rkey": "uni", "name": "uni", "repoDid": "did:web:uni.olaren.dev" }) ) .await, @@ -1166,20 +1158,14 @@ ); assert_eq!( - reserve_status( - &world, - &world.member, - MEMBER_HOST, - &format!("did:web:{KNOT_HOST}") - ) - .await, + reserve_status(&world, &world.member, &format!("did:web:{KNOT_HOST}")).await, StatusCode::BAD_REQUEST, "the knot's own DID cannot have a repo key reserved against it" ); let unpublished = "did:web:conch.olaren.dev"; assert_eq!( - reserve_status(&world, &world.member, MEMBER_HOST, unpublished).await, + reserve_status(&world, &world.member, unpublished).await, StatusCode::OK ); let impostor = knot_types::crypto::multikey(0xe7, signer(99).public_key().as_bytes()); @@ -1188,7 +1174,6 @@ create_status( &world, &world.member, - MEMBER_HOST, json!({ "rkey": "conch", "name": "conch", "repoDid": unpublished }) ) .await, @@ -1214,7 +1199,6 @@ create_status( &world, &world.admin, - ADMIN_HOST, json!({ "rkey": "victim", "name": "victim", "repoDid": victim }) ) .await, @@ -1231,7 +1215,6 @@ create_status( &world, &world.member, - MEMBER_HOST, json!({ "rkey": "doomed", "name": "doomed", "defaultBranch": "bad..name" }) ) .await, @@ -1311,7 +1294,6 @@ create_status( &world, &world.member, - MEMBER_HOST, json!({ "rkey": "anemone", "name": "anemone", "repoDid": squid }) ) .await, @@ -1325,11 +1307,11 @@ #[tokio::test] async fn a_rejected_plc_submission_is_a_bad_gateway() { - let admin = signer(1); - let key = admin.public_key().as_bytes().to_vec(); + let admin = actor(1, ADMIN_HOST); + let key = admin.signer.public_key().as_bytes().to_vec(); let responder = doc_responder(key, || StatusCode::BAD_REQUEST); let (_dir, state) = build_state(responder, true); - let token = mint(&admin, &account(ADMIN_HOST), CREATE); + let token = mint(&admin, CREATE); let status = into_response( crate::repos::create_repo( State(Arc::clone(&state)), @@ -1362,8 +1344,8 @@ #[tokio::test] async fn a_rejected_plc_submission_never_touches_the_registry() { - let admin = signer(1); - let key = admin.public_key().as_bytes().to_vec(); + let admin = actor(1, ADMIN_HOST); + let key = admin.signer.public_key().as_bytes().to_vec(); let reject_posts = Arc::new(std::sync::atomic::AtomicBool::new(false)); let reject = Arc::clone(&reject_posts); let responder = doc_responder(key, move || { @@ -1376,7 +1358,7 @@ let (_dir, state) = build_state(responder, true); let owner = OwnerDid::new(format!("did:web:{ADMIN_HOST}")).unwrap(); - let mint_create = || mint(&admin, &account(ADMIN_HOST), CREATE); + let mint_create = || mint(&admin, CREATE); let anemone = || body(json!({ "rkey": "anemone", "name": "anemone" })); assert_eq!( into_response( @@ -1399,7 +1381,7 @@ other => panic!("victim repo wasn't registered: {other:?}"), }; - let token = mint(&admin, &account(ADMIN_HOST), RENAME); + let token = mint(&admin, RENAME); assert_eq!( into_response( crate::repos::rename_repo( @@ -1454,8 +1436,8 @@ #[tokio::test] async fn resolve_by_name_matches_the_rkey_case_sensitively() { - let admin = signer(1); - let key = admin.public_key().as_bytes().to_vec(); + let admin = actor(1, ADMIN_HOST); + let key = admin.signer.public_key().as_bytes().to_vec(); let responder = doc_responder(key, || StatusCode::OK); let (_dir, state) = build_state(responder, true); let owner = OwnerDid::new(format!("did:web:{ADMIN_HOST}")).unwrap(); @@ -1464,7 +1446,7 @@ into_response( crate::repos::create_repo( State(Arc::clone(&state)), - bearer(&mint(&admin, &account(ADMIN_HOST), CREATE)), + bearer(&mint(&admin, CREATE)), crate::Method::from_nsid(CREATE), body(json!({ "rkey": "anemone", "name": "anemone" })), ) @@ -1490,17 +1472,17 @@ add_member_helper(&world).await; assert_eq!( - reserve_status(&world, &world.member, MEMBER_HOST, "did:web:p0.olaren.dev").await, + reserve_status(&world, &world.member, "did:web:p0.olaren.dev").await, StatusCode::OK, "first reservation is within the limit" ); assert_eq!( - reserve_status(&world, &world.member, MEMBER_HOST, "did:web:p1.olaren.dev").await, + reserve_status(&world, &world.member, "did:web:p1.olaren.dev").await, StatusCode::OK, "second reservation reaches the limit" ); assert_eq!( - reserve_status(&world, &world.member, MEMBER_HOST, "did:web:p2.olaren.dev").await, + reserve_status(&world, &world.member, "did:web:p2.olaren.dev").await, StatusCode::TOO_MANY_REQUESTS, "member cannot grow the sealed store without bound past the pending-reservation limit" ); @@ -1515,24 +1497,18 @@ futures::stream::iter(["did:web:m0.olaren.dev", "did:web:m1.olaren.dev"]) .for_each(|did| async move { assert_eq!( - reserve_status(world_ref, &world_ref.member, MEMBER_HOST, did).await, + reserve_status(world_ref, &world_ref.member, did).await, StatusCode::OK ); }) .await; assert_eq!( - reserve_status(&world, &world.member, MEMBER_HOST, "did:web:m2.olaren.dev").await, + reserve_status(&world, &world.member, "did:web:m2.olaren.dev").await, StatusCode::TOO_MANY_REQUESTS, "member is held to its per-actor reservation budget" ); assert_eq!( - reserve_status( - &world, - &world.admin, - ADMIN_HOST, - "did:web:admin0.olaren.dev" - ) - .await, + reserve_status(&world, &world.admin, "did:web:admin0.olaren.dev").await, StatusCode::OK, "a different account keeps its own budget while global capacity remains" ); @@ -1546,7 +1522,7 @@ let handles: Vec<_> = (0..24) .map(|i| { let state = world.state(); - let token = mint(&world.member, &account(MEMBER_HOST), RESERVE); + let token = mint(&world.member, RESERVE); let payload = body(json!({ "repoDid": format!("did:web:r{i}.olaren.dev") })); tokio::spawn(async move { into_response( @@ -1580,7 +1556,7 @@ let world = World::new(); add_member_helper(&world).await; let repo_did = create_repo_helper(&world, "scallop").await; - let subject = || json!({ "repo": repo_did.as_str(), "subject": "did:web:witchcraft.systems" }); + let subject = || json!({ "repo": repo_did, "subject": "did:web:witchcraft.systems" }); assert_eq!( as_member( @@ -1659,15 +1635,13 @@ let world = World::new(); add_member_helper(&world).await; let repo_did = create_repo_helper(&world, "squid").await; - let at = format!("at://did:web:{MEMBER_HOST}/sh.tangled.repo/squid"); - let did = format!("did:web:{MEMBER_HOST}"); assert_eq!( as_admin( &world, crate::collaborators::add_collaborator, ADD_COLLAB, - json!({ "repo": repo_did.as_str(), "subject": "did:web:isabelroses.com" }) + json!({ "repo": repo_did, "subject": "did:web:isabelroses.com" }) ) .await .status(), @@ -1679,7 +1653,7 @@ &world, crate::branches::set_default_branch, SET_DEFAULT, - json!({ "repo": at, "defaultBranch": "trunk" }) + json!({ "repo": repo_did, "defaultBranch": "trunk" }) ) .await .status(), @@ -1691,7 +1665,7 @@ &world, crate::branches::delete_branch, DELETE_BRANCH, - json!({ "repo": at, "branch": "trunk" }) + json!({ "repo": repo_did, "branch": "trunk" }) ) .await .status(), @@ -1703,7 +1677,7 @@ &world, crate::repos::delete_repo, DELETE, - json!({ "did": did, "name": "squid", "rkey": "squid" }) + json!({ "repo": repo_did }) ) .await .status(), @@ -1712,7 +1686,7 @@ ); assert_eq!( - rename_repo_as(&world, &world.stranger, STRANGER_HOST, &repo_did, "stolen").await, + rename_repo_as(&world, &world.stranger, &repo_did, "stolen").await, StatusCode::FORBIDDEN ); assert_eq!( @@ -1726,34 +1700,34 @@ &world, crate::collaborators::add_collaborator, ADD_COLLAB, - json!({ "repo": repo_did.as_str(), "subject": format!("did:web:{STRANGER_HOST}") }) + json!({ "repo": repo_did, "subject": format!("did:web:{STRANGER_HOST}") }) ) .await .status(), StatusCode::OK ); assert_eq!( - rename_repo_as( - &world, - &world.stranger, - STRANGER_HOST, - &repo_did, - "periwinkle" - ) - .await, + rename_repo_as(&world, &world.stranger, &repo_did, "periwinkle").await, StatusCode::OK, "rename is gated by can_push, so a collaborator may rename" ); } #[tokio::test] -async fn the_owner_sets_the_default_branch_through_an_at_uri() { +async fn the_owner_sets_the_default_branch_by_repo_did() { let world = World::new(); add_member_helper(&world).await; let repo_did = create_repo_helper(&world, "mussel").await; assert_eq!( - as_member(&world, crate::branches::set_default_branch, SET_DEFAULT, json!({ "repo": format!("at://did:web:{MEMBER_HOST}/sh.tangled.repo/mussel"), "defaultBranch": "trunk" })).await.status(), + as_member( + &world, + crate::branches::set_default_branch, + SET_DEFAULT, + json!({ "repo": repo_did, "defaultBranch": "trunk" }) + ) + .await + .status(), StatusCode::OK ); @@ -1808,12 +1782,26 @@ .unwrap(); assert_eq!( - as_member(&world, crate::branches::set_default_branch, SET_DEFAULT, json!({ "repo": format!("at://did:web:{MEMBER_HOST}/sh.tangled.notrepo/mussel"), "defaultBranch": "trunk" })).await.status(), - StatusCode::BAD_REQUEST, - "an at-uri addressing a collection other than sh.tangled.repo is rejected" + as_member( + &world, + crate::branches::set_default_branch, + SET_DEFAULT, + json!({ "repo": "did:web:squid.oyster.cafe", "defaultBranch": "trunk" }) + ) + .await + .status(), + StatusCode::NOT_FOUND, + "we'll reject a repo DID that this knot doesn't host" ); assert_eq!( - as_member(&world, crate::branches::set_default_branch, SET_DEFAULT, json!({ "repo": format!("at://did:web:{MEMBER_HOST}/sh.tangled.repo/mussel"), "defaultBranch": "ghost" })).await.status(), + as_member( + &world, + crate::branches::set_default_branch, + SET_DEFAULT, + json!({ "repo": repo_did, "defaultBranch": "ghost" }) + ) + .await + .status(), StatusCode::NOT_FOUND, "a populated repo rejects a default pointing at a branch that doesn't exist" ); @@ -1857,13 +1845,12 @@ git.set_head(&RefName::new("refs/heads/main").unwrap()) .unwrap(); - let at = format!("at://did:web:{MEMBER_HOST}/sh.tangled.repo/periwinkle"); assert_eq!( as_member( &world, crate::branches::delete_branch, DELETE_BRANCH, - json!({ "repo": at, "branch": "trunk" }) + json!({ "repo": repo_did, "branch": "trunk" }) ) .await .status(), @@ -1881,7 +1868,7 @@ &world, crate::branches::delete_branch, DELETE_BRANCH, - json!({ "repo": at, "branch": "main" }) + json!({ "repo": repo_did, "branch": "main" }) ) .await .status(), @@ -1913,7 +1900,6 @@ async fn delete_repo_lifecycle_and_guards() { let world = World::new(); add_member_helper(&world).await; - let did = format!("did:web:{MEMBER_HOST}"); let plain = create_repo_helper(&world, "whelk").await; assert_eq!( @@ -1921,7 +1907,7 @@ &world, crate::repos::delete_repo, DELETE, - json!({ "did": did, "name": "whelk", "rkey": "whelk" }) + json!({ "repo": plain }) ) .await .status(), @@ -1939,7 +1925,7 @@ let guarded = create_repo_helper(&world, "conch").await; world.publish_pds_record("conch"); - let delete_conch = || json!({ "did": did, "name": "conch", "rkey": "conch" }); + let delete_conch = || json!({ "repo": guarded }); assert_eq!( as_member(&world, crate::repos::delete_repo, DELETE, delete_conch()) .await @@ -1952,7 +1938,7 @@ "a refused delete left the repo intact on disk" ); - let force_conch = || json!({ "did": did, "name": "conch", "rkey": "conch", "force": true }); + let force_conch = || json!({ "repo": guarded, "force": true }); assert_eq!( as_member(&world, crate::repos::delete_repo, DELETE, force_conch()) .await @@ -1980,7 +1966,7 @@ let repo_a = create_repo_helper(&world, "alpha").await; assert_eq!( - rename_repo_as(&world, &world.member, MEMBER_HOST, &repo_a, "alphanew").await, + rename_repo_as(&world, &world.member, &repo_a, "alphanew").await, StatusCode::OK ); assert_eq!( @@ -1999,12 +1985,6 @@ "the new rkey is canonical" ); - assert_eq!( - as_member(&world, crate::branches::set_default_branch, SET_DEFAULT, json!({ "repo": format!("at://did:web:{MEMBER_HOST}/sh.tangled.repo/alpha"), "defaultBranch": "trunk" })).await.status(), - StatusCode::OK, - "an at-uri with the pre-rename rkey still reaches the repo" - ); - let repo_a2 = create_repo_helper(&world, "alpha").await; assert_ne!(repo_a2, repo_a, "a brand-new repo DID was minted"); assert_eq!( @@ -2020,7 +2000,7 @@ let repo_b = create_repo_helper(&world, "beta").await; assert_eq!( - rename_repo_as(&world, &world.member, MEMBER_HOST, &repo_b, "alpha").await, + rename_repo_as(&world, &world.member, &repo_b, "alpha").await, StatusCode::CONFLICT, "a rename cannot take the canonical rkey of another live repo" ); @@ -2032,11 +2012,18 @@ let repo_g = create_repo_helper(&world, "gamma").await; assert_eq!( - rename_repo_as(&world, &world.member, MEMBER_HOST, &repo_g, "gammanew").await, + rename_repo_as(&world, &world.member, &repo_g, "gammanew").await, StatusCode::OK ); assert_eq!( - as_member(&world, crate::repos::delete_repo, DELETE, json!({ "did": format!("did:web:{MEMBER_HOST}"), "name": "gammanew", "rkey": "gammanew" })).await.status(), + as_member( + &world, + crate::repos::delete_repo, + DELETE, + json!({ "repo": repo_g }) + ) + .await + .status(), StatusCode::OK ); assert!( @@ -2051,7 +2038,7 @@ let ghost = RepoDid::new("did:web:ghost.nel.pet").unwrap(); assert_eq!( - rename_repo_as(&world, &world.member, MEMBER_HOST, &ghost, "kelp").await, + rename_repo_as(&world, &world.member, &ghost, "kelp").await, StatusCode::NOT_FOUND, "renaming a repo this knot doesn't host is 404 instead of 403" ); @@ -2059,11 +2046,13 @@ #[tokio::test] async fn a_rename_against_a_warming_registry_is_unavailable_not_forbidden() { - let admin = signer(1); - let responder = doc_responder(admin.public_key().as_bytes().to_vec(), || StatusCode::OK); + let admin = actor(1, ADMIN_HOST); + let responder = doc_responder(admin.signer.public_key().as_bytes().to_vec(), || { + StatusCode::OK + }); let (_dir, state) = build_state(responder, false); - let token = mint(&admin, &account(ADMIN_HOST), RENAME); + let token = mint(&admin, RENAME); assert_eq!( into_response( crate::repos::rename_repo( @@ -2146,14 +2135,14 @@ request }; - let matched = mint(&world.admin, &account(ADMIN_HOST), ADD_MEMBER); + let matched = mint(&world.admin, ADD_MEMBER); assert_eq!( app.clone().oneshot(post(matched)).await.unwrap().status(), StatusCode::OK, "a token whose lxm is the route's own method authenticates" ); - let sibling = mint(&world.admin, &account(ADMIN_HOST), REMOVE_MEMBER); + let sibling = mint(&world.admin, REMOVE_MEMBER); assert_eq!( app.oneshot(post(sibling)).await.unwrap().status(), StatusCode::UNAUTHORIZED, @@ -2262,7 +2251,6 @@ use knot_types::{Oid, RefName, UnixSeconds}; const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; - const MERGE: &str = "sh.tangled.repo.merge"; const UNIFIED_PATCH: &str = concat!( "diff --git a/reef.txt b/reef.txt\n", @@ -2368,8 +2356,7 @@ crate::merge::merge, MERGE, json!({ - "did": format!("did:web:{MEMBER_HOST}"), - "name": "kelp", + "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH, "commitMessage": "Merge tide", @@ -2445,8 +2432,7 @@ crate::merge::merge, MERGE, json!({ - "did": format!("did:web:{MEMBER_HOST}"), - "name": "kelp", + "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH, "commitMessage": "Merge tide", @@ -2516,8 +2502,7 @@ crate::merge::merge, MERGE, json!({ - "did": format!("did:web:{MEMBER_HOST}"), - "name": "limpet", + "repo": repo_did, "branch": "main", "patch": mbox, }) @@ -2555,14 +2540,13 @@ add_member_helper(&world).await; let repo_did = create_repo_helper(&world, "scallop").await; let base = seed_main(&world, &repo_did, &[("reef.txt", "old line\n")]); - let did = format!("did:web:{MEMBER_HOST}"); assert_eq!( as_stranger( &world, crate::merge::merge, MERGE, - json!({ "did": did, "name": "scallop", "branch": "main", "patch": UNIFIED_PATCH }) + json!({ "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH }) ) .await .status(), @@ -2575,7 +2559,7 @@ &world, crate::merge::merge, MERGE, - json!({ "did": did, "name": "scallop", "branch": "main", "patch": UNIFIED_PATCH }) + json!({ "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH }) ) .await .status(), @@ -2589,12 +2573,12 @@ ); assert_eq!( - as_member(&world, crate::merge::merge, MERGE, json!({ "did": did, "name": "scallop", "branch": "driftwood", "patch": UNIFIED_PATCH, "commitMessage": "tide" })).await.status(), + as_member(&world, crate::merge::merge, MERGE, json!({ "repo": repo_did, "branch": "driftwood", "patch": UNIFIED_PATCH, "commitMessage": "tide" })).await.status(), StatusCode::BAD_REQUEST, "merging into a branch the repo lacks is an invalid request" ); - let response = as_member(&world, crate::merge::merge, MERGE, json!({ "did": did, "name": "scallop", "branch": "main", "patch": CONFLICTING_PATCH, "commitMessage": "tide" })).await; + let response = as_member(&world, crate::merge::merge, MERGE, json!({ "repo": repo_did, "branch": "main", "patch": CONFLICTING_PATCH, "commitMessage": "tide" })).await; assert_eq!(response.status(), StatusCode::CONFLICT); let json = json_of(response).await; assert_eq!(json["error"], "MergeConflict"); @@ -2623,8 +2607,7 @@ let input = |patch: &str| { body(json!({ - "did": format!("did:web:{MEMBER_HOST}"), - "name": "scallop", + "repo": repo_did, "branch": "main", "patch": patch, })) @@ -2665,6 +2648,64 @@ "merge check must write nothing into the object database" ); assert_eq!(main_tip(&world, &repo_did), base); + } + + #[tokio::test] + async fn a_repo_did_addresses_a_repo_whose_rkey_differs_from_its_name() { + let world = World::new(); + add_member_helper(&world).await; + assert_eq!( + as_member( + &world, + crate::repos::create_repo, + CREATE, + json!({ "rkey": "3mjmslfzgwb22", "name": "periwinkle" }) + ) + .await + .status(), + StatusCode::OK + ); + let repo_did = match resolve(&world, "3mjmslfzgwb22") { + Resolved::Ready(Some(did)) => did, + other => panic!("repo wasn't registered: {other:?}"), + }; + let base = seed_main(&world, &repo_did, &[("reef.txt", "old line\n")]); + + let rejections = [ + ( + json!({ "did": format!("did:web:{MEMBER_HOST}"), "name": "periwinkle", "branch": "main", "patch": UNIFIED_PATCH }), + StatusCode::BAD_REQUEST, + "we'll 400 an owner and a name instead of a repo DID", + ), + ( + json!({ "repo": "did:plc:limpet", "branch": "main", "patch": UNIFIED_PATCH }), + StatusCode::NOT_FOUND, + "we'll 404 a repo DID that this knot doesn't host", + ), + ( + json!({ "repo": "periwinkle", "branch": "main", "patch": UNIFIED_PATCH }), + StatusCode::BAD_REQUEST, + "we'll 400 a repo that isn't a DID", + ), + ]; + for (input, want, why) in rejections { + let response = + into_response(crate::merge::merge_check(world.state(), body(input)).await); + assert_eq!(response.status(), want, "{why}"); + } + + assert_eq!( + as_member( + &world, + crate::merge::merge, + MERGE, + json!({ "repo": repo_did, "branch": "main", "patch": UNIFIED_PATCH, "commitMessage": "tide" }) + ) + .await + .status(), + StatusCode::OK + ); + assert_ne!(main_tip(&world, &repo_did), base); } } @@ -2750,13 +2791,13 @@ format!("https://{KNOT_HOST}/did:web:{MEMBER_HOST}/{rkey}") } - async fn fork_repo(world: &World, source: &str, rkey: &str) -> RepoDid { + async fn fork_repo(world: &World, source: &str, rkey: &str, name: &str) -> RepoDid { assert_eq!( as_member( world, crate::repos::create_repo, CREATE, - json!({ "rkey": rkey, "name": rkey, "source": source }) + json!({ "rkey": rkey, "name": name, "source": source }) ) .await .status(), @@ -2800,7 +2841,7 @@ }) .unwrap(); }); - let fork_did = fork_repo(&world, &source_url("kelp"), "uni").await; + let fork_did = fork_repo(&world, &source_url("kelp"), "uni", "uni").await; ForkWorld { world, source_did, @@ -2809,60 +2850,41 @@ } } - async fn sync_fork(world: &World, signer: &K256Signer, host: &str, branch: &str) -> StatusCode { + async fn sync_fork( + world: &World, + actor: &Actor, + fork_did: &RepoDid, + branch: &str, + ) -> StatusCode { call( world, crate::forks::fork_sync, - signer, - host, - "sh.tangled.repo.forkSync", - json!({ - "did": format!("did:web:{MEMBER_HOST}"), - "name": "uni", - "source": format!("at://did:web:{MEMBER_HOST}/sh.tangled.repo/kelp"), - "branch": branch, - }), + actor, + FORK_SYNC, + json!({ "repo": fork_did, "branch": branch }), ) .await .status() } - async fn track_hidden(world: &World, fork_ref: &str, remote_ref: &str) -> StatusCode { + async fn track_hidden( + world: &World, + fork_did: &RepoDid, + fork_ref: &str, + remote_ref: &str, + ) -> StatusCode { as_member( world, crate::forks::hidden_ref, - "sh.tangled.repo.hiddenRef", + HIDDEN_REF, json!({ - "repo": format!("at://did:web:{MEMBER_HOST}/sh.tangled.repo/uni"), + "repo": fork_did, "forkRef": fork_ref, "remoteRef": remote_ref, }), ) .await .status() - } - - async fn fork_status( - world: &World, - branch: &str, - hidden_ref: &str, - ) -> (StatusCode, Option) { - let response = as_member( - world, - crate::forks::fork_status, - "sh.tangled.repo.forkStatus", - json!({ - "did": format!("did:web:{MEMBER_HOST}"), - "name": "uni", - "source": source_url("kelp"), - "branch": branch, - "hiddenRef": hidden_ref, - }), - ) - .await; - let status = response.status(); - let value = json_of(response).await; - (status, value["status"].as_u64()) } #[tokio::test] @@ -2918,7 +2940,7 @@ let new_tip = advance(&source, &main_ref(), "spray.txt", "salt\n", 1_002); assert_eq!( - sync_fork(&setup.world, &setup.world.member, MEMBER_HOST, "main").await, + sync_fork(&setup.world, &setup.world.member, &setup.fork_did, "main").await, StatusCode::OK ); let fork = setup.world.layout.open(&setup.fork_did).unwrap(); @@ -2936,7 +2958,7 @@ ); assert_eq!( - sync_fork(&setup.world, &setup.world.member, MEMBER_HOST, "main").await, + sync_fork(&setup.world, &setup.world.member, &setup.fork_did, "main").await, StatusCode::OK, "an up-to-date sync is a no-op" ); @@ -2947,14 +2969,47 @@ ); assert_eq!( - sync_fork(&setup.world, &setup.world.stranger, STRANGER_HOST, "main").await, + sync_fork(&setup.world, &setup.world.stranger, &setup.fork_did, "main").await, StatusCode::FORBIDDEN, "a stranger cannot sync a fork" ); assert_eq!( - sync_fork(&setup.world, &setup.world.member, MEMBER_HOST, "driftwood").await, + sync_fork( + &setup.world, + &setup.world.member, + &setup.fork_did, + "driftwood" + ) + .await, StatusCode::NOT_FOUND, "syncing a branch the upstream lacks isn't found" + ); + } + + #[tokio::test] + async fn a_repo_did_addresses_a_fork_whose_rkey_differs_from_its_name() { + let world = World::new(); + add_member_helper(&world).await; + let source_did = create_repo_helper(&world, "kelp").await; + let source = world.layout.open(&source_did).unwrap(); + advance(&source, &main_ref(), "reef.txt", "kelp forest\n", 1_000); + + let fork_did = fork_repo(&world, &source_url("kelp"), "3mjmslfzgwb22", "nautilus").await; + + let new_tip = advance(&source, &main_ref(), "spray.txt", "salt\n", 1_001); + assert_eq!( + sync_fork(&world, &world.member, &fork_did, "main").await, + StatusCode::OK + ); + assert_eq!( + world + .layout + .open(&fork_did) + .unwrap() + .find_ref(&main_ref()) + .unwrap(), + Some(new_tip), + "the fork will sync by repo DID, which the name never has to match" ); } @@ -2965,7 +3020,7 @@ let new_tip = advance(&source, &main_ref(), "spray.txt", "salt\n", 1_002); assert_eq!( - track_hidden(&setup.world, "feature", "main").await, + track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, StatusCode::OK ); @@ -2980,7 +3035,7 @@ "a hidden ref must stay out of the public advertisement" ); assert_eq!( - track_hidden(&setup.world, "feature", "main").await, + track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, StatusCode::OK, "tracking an already-tracked ref is idempotent" ); @@ -3004,7 +3059,7 @@ .unwrap(); let did_tip = advance(&source, &main_ref(), "spray.txt", "salt\n", 1_002); assert_eq!( - track_hidden(&setup.world, "feature", "main").await, + track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, StatusCode::OK ); assert_eq!( @@ -3030,7 +3085,7 @@ .unwrap(); let named_tip = advance(&source, &main_ref(), "swell.txt", "tide\n", 1_003); assert_eq!( - track_hidden(&setup.world, "feature", "main").await, + track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, StatusCode::OK ); assert_eq!( @@ -3054,7 +3109,7 @@ fork.set_origin_url(&OriginUrl::new("file:///home/git/did:plc:whelk")) .unwrap(); assert_eq!( - track_hidden(&setup.world, "feature", "main").await, + track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, StatusCode::NOT_FOUND, "the knot reports not found for a file origin with an unknown repo did" ); @@ -3062,7 +3117,7 @@ fork.set_origin_url(&OriginUrl::new("ssh://knot.nel.pet/did:plc:whelk/ghost")) .unwrap(); assert_eq!( - track_hidden(&setup.world, "feature", "main").await, + track_hidden(&setup.world, &setup.fork_did, "feature", "main").await, StatusCode::INTERNAL_SERVER_ERROR, "the knot reports an internal error for a stored origin scheme other than http, https, or file" ); @@ -3070,7 +3125,7 @@ fork.set_origin_url(&OriginUrl::new("file:///kelp")) .unwrap(); assert_eq!( - track_hidden(&setup.world, "feature", "main").await, + track_hidden(&setup.world, &setup.fork_did, "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" ); @@ -3115,58 +3170,6 @@ } #[tokio::test] - async fn fork_status_reports_up_to_date_fast_forwardable_and_conflict() { - let setup = forked_world().await; - assert_eq!( - track_hidden(&setup.world, "feature", "main").await, - StatusCode::OK - ); - assert_eq!( - fork_status(&setup.world, "main", "refs/hidden/feature/main").await, - (StatusCode::OK, Some(0)) - ); - - let source = setup.world.layout.open(&setup.source_did).unwrap(); - advance(&source, &main_ref(), "spray.txt", "salt\n", 1_002); - assert_eq!( - track_hidden(&setup.world, "feature", "main").await, - StatusCode::OK - ); - assert_eq!( - fork_status(&setup.world, "main", "refs/hidden/feature/main").await, - (StatusCode::OK, Some(1)) - ); - - let fork = setup.world.layout.open(&setup.fork_did).unwrap(); - advance(&fork, &main_ref(), "wreck.txt", "barnacle\n", 1_003); - assert_eq!( - fork_status(&setup.world, "main", "refs/hidden/feature/main").await, - (StatusCode::OK, Some(2)) - ); - - assert_eq!( - fork_status(&setup.world, "main", "refs/hidden/ghost/main").await, - (StatusCode::BAD_REQUEST, None), - "an unresolvable revision is an invalid request" - ); - } - - #[tokio::test] - async fn fork_status_reports_up_to_date_when_the_fork_is_ahead() { - let setup = forked_world().await; - assert_eq!( - track_hidden(&setup.world, "feature", "main").await, - StatusCode::OK - ); - let fork = setup.world.layout.open(&setup.fork_did).unwrap(); - advance(&fork, &main_ref(), "wreck.txt", "barnacle\n", 1_003); - assert_eq!( - fork_status(&setup.world, "main", "refs/hidden/feature/main").await, - (StatusCode::OK, Some(0)) - ); - } - - #[tokio::test] async fn a_fork_over_http_takes_the_upstream_object_format_and_conflicts_when_it_changes() { let upstream_dir = tempfile::tempdir().unwrap(); let upstream_path = upstream_dir.path().join("uni.git"); @@ -3204,7 +3207,7 @@ ); let remote = "https://barnacle.nel.pet/did:plc:squid/uni"; - let fork_did = fork_repo(&world, remote, "uni").await; + let fork_did = fork_repo(&world, remote, "uni", "uni").await; let fork = world.layout.open(&fork_did).unwrap(); assert_eq!( fork.object_format(), @@ -3232,7 +3235,7 @@ 1_002, ); assert_eq!( - sync_fork(&world, &world.member, MEMBER_HOST, "main").await, + sync_fork(&world, &world.member, &fork_did, "main").await, StatusCode::OK ); assert_eq!( @@ -3251,7 +3254,7 @@ advance(&sha256, &main_ref(), "reef.txt", "kelp forest\n", 1_000); *served.write().unwrap() = replaced; assert_eq!( - sync_fork(&world, &world.member, MEMBER_HOST, "main").await, + sync_fork(&world, &world.member, &fork_did, "main").await, StatusCode::CONFLICT, "the fork reports a format mismatch as a conflict" ); diff --git a/knot2/crates/knot-xrpc/tests/reads.rs b/knot2/crates/knot-xrpc/tests/reads.rs --- a/knot2/crates/knot-xrpc/tests/reads.rs +++ b/knot2/crates/knot-xrpc/tests/reads.rs @@ -1560,8 +1560,7 @@ &world, "/xrpc/sh.tangled.repo.mergeCheck", serde_json::json!({ - "did": OWNER, - "name": "periwinkle", + "repo": registered, "branch": "main", "patch": patch, }), @@ -1578,8 +1577,7 @@ &world, "/xrpc/sh.tangled.repo.mergeCheck", serde_json::json!({ - "did": OWNER, - "name": "periwinkle", + "repo": registered, "branch": "feature", "patch": patch, }), @@ -1765,7 +1763,10 @@ let world = World::new(); let wire = get_json(&world, "/xrpc/sh.tangled.knot.version").await; assert_eq!(wire["version"], "v1.15.0"); - assert_eq!(wire["capabilities"], serde_json::json!(["knot-acl"])); + assert_eq!( + wire["capabilities"], + serde_json::json!(["knot-acl", "repo-did-input"]) + ); let owner = get_json(&world, "/xrpc/sh.tangled.owner").await; assert_eq!(owner["owner"], OWNER); @@ -1905,7 +1906,7 @@ } #[tokio::test] -async fn set_default_branch_resolves_an_at_uri_repo_and_an_existing_branch() { +async fn set_default_branch_resolves_a_repo_did_and_an_existing_branch() { let world = World::new(); let (did, work) = seeded(&world, "coral"); let bare = world.layout.repo_path(&did).unwrap(); @@ -1920,7 +1921,7 @@ "/xrpc/sh.tangled.repo.setDefaultBranch", OWNER, serde_json::json!({ - "repo": format!("at://{OWNER}/sh.tangled.repo/coral"), + "repo": did, "defaultBranch": "release", }), ) @@ -1946,12 +1947,11 @@ &["push", "-q", bare.to_str().unwrap(), "refs/heads/feature"], ); - let at = format!("at://{OWNER}/sh.tangled.repo/kelp"); let (status, _) = post_authed( &world, "/xrpc/sh.tangled.repo.deleteBranch", OWNER, - serde_json::json!({ "repo": at, "branch": "feature" }), + serde_json::json!({ "repo": did, "branch": "feature" }), ) .await; assert_eq!(status, StatusCode::OK); @@ -1960,7 +1960,7 @@ &world, "/xrpc/sh.tangled.repo.deleteBranch", OWNER, - serde_json::json!({ "repo": at, "branch": "feature" }), + serde_json::json!({ "repo": did, "branch": "feature" }), ) .await; assert_eq!(status, StatusCode::NOT_FOUND, "second delete: {body}"); @@ -1975,18 +1975,18 @@ let cases: &[(&str, serde_json::Value)] = &[ ( "/xrpc/sh.tangled.repo.setDefaultBranch", - serde_json::json!({ "repo": "not-an-at-uri", "defaultBranch": "main" }), + serde_json::json!({ "repo": "not-a-repo-did", "defaultBranch": "main" }), ), ( "/xrpc/sh.tangled.repo.deleteBranch", serde_json::json!({ - "repo": format!("at://{OWNER}/sh.tangled.repo/kelp"), + "repo": "did:plc:kelpfixture", "branch": "bad branch", }), ), ( "/xrpc/sh.tangled.repo.forkSync", - serde_json::json!({ "did": OWNER, "name": "barnacle", "branch": "bad branch" }), + serde_json::json!({ "repo": "did:plc:barnaclefixture", "branch": "bad branch" }), ), ( "/xrpc/sh.tangled.repo.hiddenRef", @@ -2021,8 +2021,7 @@ "/xrpc/sh.tangled.repo.merge", OWNER, serde_json::json!({ - "did": OWNER, - "name": "mussel", + "repo": registered, "branch": "main", "patch": patch, "authorName": "Teq", -- tangled.sh