Now let's take a silly one
0

Configure Feed

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

better newtyping, a couple new read endpoints

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 23, 2026, 9:47 PM +0300) commit 63494ddd parent ab11b917 change-id wzusqvso
+1592 -365
+1
Cargo.lock
··· 4184 4184 "futures", 4185 4185 "http", 4186 4186 "httpdate", 4187 + "k256", 4187 4188 "knot-acl", 4188 4189 "knot-atproto", 4189 4190 "knot-cob",
+1
crates/knot-xrpc/Cargo.toml
··· 40 40 [dev-dependencies] 41 41 knot-config = { workspace = true } 42 42 bytes = { workspace = true } 43 + k256 = { workspace = true } 43 44 tokio-tungstenite = "0.29"
+100
lexicons/git/listRefs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.git.listRefs", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List every ref & its commit SHA for a git repo, equivalent to git ls-remote. Gives the full ref state of a repo.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["repo"], 11 + "properties": { 12 + "repo": { 13 + "type": "string", 14 + "format": "did", 15 + "description": "DID of the git repo as minted by the knot" 16 + }, 17 + "limit": { 18 + "type": "integer", 19 + "minimum": 1, 20 + "maximum": 1000, 21 + "default": 100, 22 + "description": "Maximum number of refs to return in this page" 23 + }, 24 + "cursor": { 25 + "type": "string", 26 + "description": "Pagination cursor" 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["refs"], 35 + "properties": { 36 + "refs": { 37 + "type": "array", 38 + "items": { "type": "ref", "ref": "#ref" }, 39 + "maxLength": 1000 40 + }, 41 + "cursor": { 42 + "type": "string", 43 + "description": "Cursor for the next page, absent when the last page is reached" 44 + }, 45 + "defaultBranch": { 46 + "type": "ref", 47 + "ref": "#defaultBranch" 48 + } 49 + } 50 + } 51 + }, 52 + "errors": [ 53 + { 54 + "name": "RepoNotFound", 55 + "description": "Repo is not registered on this knot" 56 + }, 57 + { 58 + "name": "InvalidRequest", 59 + "description": "Invalid request parameters" 60 + } 61 + ] 62 + }, 63 + "ref": { 64 + "type": "object", 65 + "required": ["ref", "sha"], 66 + "properties": { 67 + "ref": { 68 + "type": "string", 69 + "description": "Full ref name, eg. refs/heads/main or refs/tags/v1.0", 70 + "maxGraphemes": 256, 71 + "maxLength": 2560 72 + }, 73 + "sha": { 74 + "type": "string", 75 + "description": "Object SHA the ref points at. Width depends on the repo's git object-format.", 76 + "minLength": 40, 77 + "maxLength": 128 78 + } 79 + } 80 + }, 81 + "defaultBranch": { 82 + "type": "object", 83 + "required": ["ref"], 84 + "properties": { 85 + "ref": { 86 + "type": "string", 87 + "description": "Default branch ref name that HEAD points at, eg. refs/heads/main.", 88 + "maxGraphemes": 256, 89 + "maxLength": 2560 90 + }, 91 + "head": { 92 + "type": "string", 93 + "description": "Commit SHA at the tip of the default branch, for reconciling against a last-known state. Width depends on the repo's git object-format.", 94 + "minLength": 40, 95 + "maxLength": 128 96 + } 97 + } 98 + } 99 + } 100 + }
+1
lexicons/repo/describeRepo.json
··· 33 33 }, 34 34 "rkey": { 35 35 "type": "string", 36 + "format": "record-key", 36 37 "description": "Current rkey of the sh.tangled.repo record tracked by this knot" 37 38 } 38 39 }
+90
lexicons/sync/listRepos.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.sync.listRepos", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List git repos hosted on this service.", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "cursor": { 12 + "type": "string", 13 + "description": "Pagination cursor" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "minimum": 1, 18 + "maximum": 1000, 19 + "default": 50 20 + }, 21 + "order": { 22 + "type": "string", 23 + "knownValues": ["asc", "desc"], 24 + "default": "desc", 25 + "description": "Sort direction over the service's repo listing order." 26 + } 27 + } 28 + }, 29 + "output": { 30 + "encoding": "application/json", 31 + "schema": { 32 + "type": "object", 33 + "required": ["repos"], 34 + "properties": { 35 + "repos": { 36 + "type": "array", 37 + "items": { "type": "ref", "ref": "#repo" }, 38 + "maxLength": 1000 39 + }, 40 + "cursor": { "type": "string" } 41 + } 42 + } 43 + }, 44 + "errors": [ 45 + { 46 + "name": "InvalidRequest", 47 + "description": "Invalid request parameters" 48 + } 49 + ] 50 + }, 51 + "repo": { 52 + "type": "object", 53 + "required": ["repo", "status"], 54 + "properties": { 55 + "repo": { 56 + "type": "string", 57 + "format": "did", 58 + "description": "DID of the git repo as minted by the knot" 59 + }, 60 + "status": { 61 + "type": "string", 62 + "knownValues": ["active", "archived", "disabled"], 63 + "description": "Serving status of the repo according to the knot." 64 + }, 65 + "defaultBranch": { 66 + "type": "ref", 67 + "ref": "#defaultBranch" 68 + } 69 + } 70 + }, 71 + "defaultBranch": { 72 + "type": "object", 73 + "required": ["ref"], 74 + "properties": { 75 + "ref": { 76 + "type": "string", 77 + "description": "Default branch ref name, eg. refs/heads/main.", 78 + "maxGraphemes": 256, 79 + "maxLength": 2560 80 + }, 81 + "head": { 82 + "type": "string", 83 + "description": "Commit SHA at the tip of the default branch, for reconciling against a last-known state. Width depends on the repo's git object-format.", 84 + "minLength": 40, 85 + "maxLength": 128 86 + } 87 + } 88 + } 89 + } 90 + }
+1 -1
crates/knot-acl/src/lib.rs
··· 485 485 assert_eq!( 486 486 can_delete_repo(&acl, &acc("nel"), &repo("squid")), 487 487 Decision::Allow, 488 - "knot admin deletes any repo, matching the Go knot" 488 + "knot admin deletes any repo" 489 489 ); 490 490 assert_eq!( 491 491 can_delete_repo(&acl, &acc("lyna"), &repo("squid")),
+3 -3
crates/knot-events/src/lib.rs
··· 801 801 } 802 802 803 803 #[test] 804 - fn the_wire_event_matches_the_go_eventstream_shape() { 804 + fn the_wire_event_matches_the_eventstream_shape() { 805 805 let log = log(8); 806 806 log.publish(&update()); 807 807 let event = log.replay(EventCursor::START, 1).remove(0); ··· 958 958 } 959 959 960 960 #[test] 961 - fn a_member_update_matches_the_go_eventstream_shape() { 961 + fn a_member_update_matches_the_eventstream_shape() { 962 962 let log = log(8); 963 963 log.publish(&KnotMemberUpdate::added( 964 964 AccountDid::new("did:plc:nel").unwrap(), ··· 978 978 } 979 979 980 980 #[test] 981 - fn a_collaborator_update_matches_the_go_eventstream_shape() { 981 + fn a_collaborator_update_matches_the_eventstream_shape() { 982 982 let log = log(8); 983 983 log.publish(&RepoCollaboratorUpdate::added( 984 984 AccountDid::new("did:plc:nel").unwrap(),
+1 -1
crates/knot-git/src/patch_parse.rs
··· 881 881 use super::*; 882 882 883 883 #[test] 884 - fn format_patch_detection_matches_the_go_heuristic() { 884 + fn format_patch_detection_matches_the_mailbox_heuristic() { 885 885 assert!(is_format_patch( 886 886 "From 0123456789012345678901234567890123456789 Mon Sep 17 00:00:00 2001\nFrom: nel <nel@oyster.cafe>\n" 887 887 ));
+3 -3
crates/knot-git/tests/reads.rs
··· 450 450 assert_eq!( 451 451 bare.tree_entries_at(head, "a.txt").unwrap().unwrap(), 452 452 Vec::new(), 453 - "file path lists as empty, mirroring Go knot" 453 + "file path lists as empty" 454 454 ); 455 455 assert!(bare.tree_entries_at(head, "missing").unwrap().is_none()); 456 456 assert!(bare.tree_entries_at(head, "../escape").unwrap().is_none()); ··· 478 478 } 479 479 480 480 #[test] 481 - fn a_submodule_path_is_not_found_like_the_go_knot() { 481 + fn a_submodule_path_is_not_found() { 482 482 let (_scan, work_dir, layout, did) = seed_rich(); 483 483 let work = work_dir.path(); 484 484 let head = git(work, &["rev-parse", "HEAD"]); ··· 501 501 bare.tree_entries_at(linked, "vendor/dep") 502 502 .unwrap() 503 503 .is_none(), 504 - "submodule path is not found, mirroring Go knot" 504 + "submodule path is not found" 505 505 ); 506 506 assert!( 507 507 bare.tree_entries_at(linked, "vendor").unwrap().is_some(),
+39
crates/knot-xrpc/src/body.rs
··· 1 + use serde::Deserialize; 2 + use serde::de::{self, Deserializer}; 3 + 4 + use knot_types::RefName; 5 + 6 + use crate::query::string_newtype; 7 + 8 + pub(crate) struct BranchName(RefName); 9 + 10 + impl BranchName { 11 + pub(crate) fn into_ref(self) -> RefName { 12 + self.0 13 + } 14 + 15 + pub(crate) fn as_ref_name(&self) -> &RefName { 16 + &self.0 17 + } 18 + } 19 + 20 + impl<'de> Deserialize<'de> for BranchName { 21 + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 22 + let raw = String::deserialize(deserializer)?; 23 + RefName::new(format!("refs/heads/{raw}")) 24 + .map(BranchName) 25 + .map_err(|_| de::Error::custom("invalid branch name")) 26 + } 27 + } 28 + 29 + string_newtype!(RepoAtUri); 30 + string_newtype!(RepoNameArg); 31 + string_newtype!(Patch); 32 + string_newtype!(SourceUrl); 33 + string_newtype!(AuthorName); 34 + string_newtype!(AuthorEmail); 35 + string_newtype!(CommitMessage); 36 + string_newtype!(CommitBody); 37 + string_newtype!(ForkRef); 38 + string_newtype!(RemoteRef); 39 + string_newtype!(RevisionArg);
+10 -9
crates/knot-xrpc/src/branches.rs
··· 12 12 use knot_runtime::{Clock, HttpTransport}; 13 13 use knot_types::{AtUri, OwnerDid, RepoDid, RepoRkey}; 14 14 15 + use crate::body::{BranchName, RepoAtUri}; 15 16 use crate::error::XrpcError; 16 - use crate::{XrpcState, branch_ref, decode, ok_empty, run_blocking}; 17 + use crate::{XrpcState, decode, ok_empty, run_blocking}; 17 18 18 19 pub(crate) const SET_DEFAULT_ROUTE: &str = "/xrpc/sh.tangled.repo.setDefaultBranch"; 19 20 pub(crate) const DELETE_ROUTE: &str = "/xrpc/sh.tangled.repo.deleteBranch"; ··· 23 24 24 25 #[derive(Deserialize)] 25 26 struct SetDefaultBranchInput { 26 - repo: String, 27 + repo: RepoAtUri, 27 28 #[serde(rename = "defaultBranch")] 28 - default_branch: String, 29 + default_branch: BranchName, 29 30 } 30 31 31 32 #[derive(Deserialize)] 32 33 struct DeleteBranchInput { 33 - repo: String, 34 - branch: String, 34 + repo: RepoAtUri, 35 + branch: BranchName, 35 36 } 36 37 37 38 const BRANCH_DENIED: &str = "only repository owner or a collaborator may change its branches"; ··· 71 72 ) -> Result<Response, XrpcError> { 72 73 let actor = state.authenticate(&headers, SET_DEFAULT_NSID).await?; 73 74 let input: SetDefaultBranchInput = decode(&body)?; 74 - let repo_did = resolve_at_uri(&state, &input.repo)?; 75 + let repo_did = resolve_at_uri(&state, input.repo.as_str())?; 75 76 crate::authorize_push(&state, &actor, &repo_did, BRANCH_DENIED).await?; 76 - let refname = branch_ref(&input.default_branch)?; 77 + let refname = input.default_branch.into_ref(); 77 78 78 79 let layout = state.layout.clone(); 79 80 let target = repo_did.clone(); ··· 111 112 ) -> Result<Response, XrpcError> { 112 113 let actor = state.authenticate(&headers, DELETE_NSID).await?; 113 114 let input: DeleteBranchInput = decode(&body)?; 114 - let repo_did = resolve_at_uri(&state, &input.repo)?; 115 + let repo_did = resolve_at_uri(&state, input.repo.as_str())?; 115 116 crate::authorize_push(&state, &actor, &repo_did, BRANCH_DENIED).await?; 116 - let refname = branch_ref(&input.branch)?; 117 + let refname = input.branch.into_ref(); 117 118 118 119 let layout = state.layout.clone(); 119 120 let target = repo_did.clone();
+30 -19
crates/knot-xrpc/src/forks.rs
··· 16 16 use knot_runtime::{Clock, HttpTransport}; 17 17 use knot_types::{Oid, OwnerDid, RefName, RepoDid}; 18 18 19 + use crate::body::{BranchName, ForkRef, RemoteRef, RepoAtUri, RepoNameArg, RevisionArg, SourceUrl}; 19 20 use crate::branches::resolve_at_uri; 20 21 use crate::error::XrpcError; 21 22 use crate::{XrpcState, branch_ref, decode, ok_empty, run_blocking}; ··· 356 357 #[derive(Deserialize)] 357 358 struct ForkSyncInput { 358 359 did: OwnerDid, 359 - name: String, 360 - branch: String, 360 + name: RepoNameArg, 361 + branch: BranchName, 361 362 } 362 363 363 364 pub(crate) async fn fork_sync<H: HttpTransport, C: Clock>( ··· 367 368 ) -> Result<Response, XrpcError> { 368 369 let actor = state.authenticate(&headers, SYNC_NSID).await?; 369 370 let input: ForkSyncInput = decode(&body)?; 370 - let repo_did = crate::merge::resolve_by_name(&state, &input.did, &input.name)?; 371 + let repo_did = crate::merge::resolve_by_name(&state, &input.did, input.name.as_str())?; 371 372 crate::authorize_push(&state, &actor, &repo_did, FORK_DENIED).await?; 372 - let branch = branch_ref(&input.branch)?; 373 + let branch = input.branch.into_ref(); 373 374 let sync = pull_upstream_branch(&state, &repo_did, &branch, &branch).await?; 374 375 if let Some(reservation) = sync.reservation { 375 376 let owner = crate::current_owner(&state, &repo_did); ··· 416 417 417 418 #[derive(Deserialize)] 418 419 struct HiddenRefInput { 419 - repo: String, 420 + repo: RepoAtUri, 420 421 #[serde(rename = "forkRef")] 421 - fork_ref: String, 422 + fork_ref: ForkRef, 422 423 #[serde(rename = "remoteRef")] 423 - remote_ref: String, 424 + remote_ref: RemoteRef, 424 425 } 425 426 426 427 #[derive(Serialize)] ··· 437 438 ) -> Result<Response, XrpcError> { 438 439 let actor = state.authenticate(&headers, HIDDEN_REF_NSID).await?; 439 440 let input: HiddenRefInput = decode(&body)?; 440 - let repo_did = resolve_at_uri(&state, &input.repo)?; 441 + let repo_did = resolve_at_uri(&state, input.repo.as_str())?; 441 442 crate::authorize_push(&state, &actor, &repo_did, FORK_DENIED).await?; 442 - let branch = branch_ref(&input.remote_ref)?; 443 + let branch = branch_ref(input.remote_ref.as_str())?; 443 444 let target = RefName::new(format!( 444 445 "refs/hidden/{}/{}", 445 - input.fork_ref, input.remote_ref 446 + input.fork_ref.as_str(), 447 + input.remote_ref.as_str() 446 448 )) 447 449 .map_err(|_| XrpcError::invalid_request("forkRef and remoteRef do not form a valid ref"))?; 448 450 pull_upstream_branch(&state, &repo_did, &branch, &target).await?; ··· 476 478 #[derive(Deserialize)] 477 479 struct ForkStatusInput { 478 480 did: OwnerDid, 479 - name: Option<String>, 480 - source: String, 481 - branch: String, 481 + name: Option<RepoNameArg>, 482 + source: SourceUrl, 483 + branch: RevisionArg, 482 484 #[serde(rename = "hiddenRef")] 483 - hidden_ref: String, 485 + hidden_ref: RevisionArg, 484 486 } 485 487 486 488 #[derive(Serialize)] ··· 505 507 let input: ForkStatusInput = decode(&body)?; 506 508 let name = input 507 509 .name 510 + .as_ref() 511 + .map(|name| name.as_str()) 508 512 .filter(|name| !name.is_empty()) 509 - .or_else(|| source_basename(&input.source)) 513 + .map(str::to_string) 514 + .or_else(|| source_basename(input.source.as_str())) 510 515 .ok_or_else(|| { 511 516 XrpcError::invalid_request("neither name nor a source url with path was supplied") 512 517 })?; ··· 519 524 .open(&repo_did) 520 525 .map_err(|error| XrpcError::internal(error.to_string()))?; 521 526 let fork = repo 522 - .resolve_revision(&input.branch) 527 + .resolve_revision(input.branch.as_str()) 523 528 .ok_or_else(|| { 524 - XrpcError::invalid_request(format!("cannot resolve revision {}", input.branch)) 529 + XrpcError::invalid_request(format!( 530 + "cannot resolve revision {}", 531 + input.branch.as_str() 532 + )) 525 533 }) 526 534 .and_then(|oid| { 527 535 repo.peel_to_commit(oid) 528 536 .map_err(|error| XrpcError::invalid_request(error.to_string())) 529 537 })?; 530 538 let source = repo 531 - .resolve_revision(&input.hidden_ref) 539 + .resolve_revision(input.hidden_ref.as_str()) 532 540 .ok_or_else(|| { 533 - XrpcError::invalid_request(format!("cannot resolve revision {}", input.hidden_ref)) 541 + XrpcError::invalid_request(format!( 542 + "cannot resolve revision {}", 543 + input.hidden_ref.as_str() 544 + )) 534 545 }) 535 546 .and_then(|oid| { 536 547 repo.peel_to_commit(oid)
+4
crates/knot-xrpc/src/lib.rs
··· 1 1 mod blocklist; 2 + mod body; 2 3 mod branches; 3 4 mod cob; 4 5 mod collaborators; ··· 11 12 mod members; 12 13 mod merge; 13 14 mod patchtext; 15 + mod query; 14 16 mod reads; 15 17 mod repos; 16 18 mod reservations; ··· 189 191 reads::DESCRIBE_REPO_ROUTE, 190 192 get(reads::repo_describe_repo::<H, C>), 191 193 ) 194 + .route(reads::LIST_REFS_ROUTE, get(reads::git_list_refs::<H, C>)) 195 + .route(reads::LIST_REPOS_ROUTE, get(reads::sync_list_repos::<H, C>)) 192 196 .route(lists::LIST_MEMBERS_ROUTE, get(lists::list_members::<H, C>)) 193 197 .route( 194 198 lists::LIST_COLLABORATORS_ROUTE,
+62 -53
crates/knot-xrpc/src/lists.rs
··· 1 1 use std::sync::Arc; 2 2 3 3 use axum::Json; 4 - use axum::extract::{Query, State}; 4 + use axum::extract::{FromRequestParts, Query, State}; 5 5 use axum::response::{IntoResponse, Response}; 6 6 use http::StatusCode; 7 + use http::request::Parts; 7 8 use serde::{Deserialize, Serialize}; 8 9 9 10 use knot_cobs::Grant; ··· 13 14 14 15 use crate::XrpcState; 15 16 use crate::error::XrpcError; 17 + use crate::query::{Limit, Offset, Order, ValidatedQuery}; 16 18 use crate::wire::rfc3339; 17 19 18 20 pub(crate) const LIST_MEMBERS_ROUTE: &str = "/xrpc/sh.tangled.knot.listMembers"; 19 21 pub(crate) const LIST_COLLABORATORS_ROUTE: &str = "/xrpc/sh.tangled.repo.listCollaborators"; 20 22 21 - const DEFAULT_LIMIT: i64 = 50; 22 - const MAX_LIMIT: i64 = 1000; 23 + const DEFAULT_LIMIT: usize = 50; 24 + const MAX_LIMIT: usize = 1000; 23 25 24 26 #[derive(Deserialize)] 25 - pub(crate) struct ListQuery { 26 - subject: Option<String>, 27 - cursor: Option<String>, 28 - limit: Option<String>, 29 - order: Option<String>, 27 + pub(crate) struct Paging { 28 + #[serde(default)] 29 + limit: Limit<DEFAULT_LIMIT, MAX_LIMIT>, 30 + #[serde(default)] 31 + cursor: Offset, 32 + #[serde(default)] 33 + order: Order, 30 34 } 31 35 32 36 struct Window { ··· 35 39 descending: bool, 36 40 } 37 41 38 - fn window(query: &ListQuery) -> Result<Window, XrpcError> { 39 - let limit = match query.limit.as_deref().filter(|raw| !raw.is_empty()) { 40 - None => DEFAULT_LIMIT, 41 - Some(raw) => raw 42 - .parse::<i64>() 43 - .map_err(|_| XrpcError::invalid_request("limit must be an integer"))? 44 - .clamp(1, MAX_LIMIT), 45 - }; 46 - let offset = match query.cursor.as_deref().filter(|raw| !raw.is_empty()) { 47 - None => 0, 48 - Some(raw) => raw 49 - .parse::<usize>() 50 - .map_err(|_| XrpcError::invalid_request("cursor must be an integer"))?, 51 - }; 52 - let descending = match query.order.as_deref() { 53 - None | Some("" | "desc") => true, 54 - Some("asc") => false, 55 - Some(_) => { 56 - return Err(XrpcError::invalid_request("order must be 'asc' or 'desc'")); 42 + impl Paging { 43 + fn window(self) -> Window { 44 + Window { 45 + offset: self.cursor.get(), 46 + limit: self.limit.get(), 47 + descending: self.order.descending(), 57 48 } 58 - }; 59 - Ok(Window { 60 - offset, 61 - limit: limit as usize, 62 - descending, 63 - }) 49 + } 64 50 } 65 51 66 - fn subject(query: &ListQuery) -> Result<&str, XrpcError> { 67 - query 52 + #[derive(Deserialize)] 53 + struct SubjectQuery { 54 + subject: Option<String>, 55 + } 56 + 57 + fn subject_param(parts: &Parts) -> Result<String, XrpcError> { 58 + Query::<SubjectQuery>::try_from_uri(&parts.uri) 59 + .map_err(|rejection| XrpcError::invalid_request(rejection.body_text()))? 60 + .0 68 61 .subject 69 - .as_deref() 70 62 .filter(|raw| !raw.is_empty()) 71 63 .ok_or_else(|| XrpcError::invalid_request("missing subject parameter")) 64 + } 65 + 66 + pub(crate) struct MemberSubject; 67 + 68 + impl<S: Send + Sync> FromRequestParts<S> for MemberSubject { 69 + type Rejection = XrpcError; 70 + 71 + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { 72 + subject_param(parts).map(|_| MemberSubject) 73 + } 74 + } 75 + 76 + pub(crate) struct CollaboratorRepo(RepoDid); 77 + 78 + impl<S: Send + Sync> FromRequestParts<S> for CollaboratorRepo { 79 + type Rejection = XrpcError; 80 + 81 + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { 82 + let subject = subject_param(parts)?; 83 + RepoDid::new(subject).map(CollaboratorRepo).map_err(|_| { 84 + XrpcError::named( 85 + StatusCode::BAD_REQUEST, 86 + "InvalidRepo", 87 + "subject must be a repo DID", 88 + ) 89 + }) 90 + } 72 91 } 73 92 74 93 #[derive(Serialize)] ··· 107 126 created_at: rfc3339(grant.created_at.get(), 0), 108 127 }) 109 128 .collect(); 110 - let cursor = window 111 - .offset 112 - .checked_add(window.limit) 113 - .filter(|&end| end < total) 114 - .map(|end| end.to_string()); 129 + let cursor = crate::query::next_cursor(window.offset, window.limit, total); 115 130 Json(PageWire { items, cursor }).into_response() 116 131 } 117 132 ··· 125 140 126 141 pub(crate) async fn list_members<H: HttpTransport, C: Clock>( 127 142 State(state): State<Arc<XrpcState<H, C>>>, 128 - Query(query): Query<ListQuery>, 143 + _subject: MemberSubject, 144 + ValidatedQuery(paging): ValidatedQuery<Paging>, 129 145 ) -> Result<Response, XrpcError> { 130 - subject(&query)?; 131 - let window = window(&query)?; 146 + let window = paging.window(); 132 147 match state.index.member_entries() { 133 148 Resolved::Warming => Err(members_warming()), 134 149 Resolved::Ready(entries) => Ok(respond(entries, window)), ··· 137 152 138 153 pub(crate) async fn list_collaborators<H: HttpTransport, C: Clock>( 139 154 State(state): State<Arc<XrpcState<H, C>>>, 140 - Query(query): Query<ListQuery>, 155 + CollaboratorRepo(repo): CollaboratorRepo, 156 + ValidatedQuery(paging): ValidatedQuery<Paging>, 141 157 ) -> Result<Response, XrpcError> { 142 - let repo = RepoDid::new(subject(&query)?).map_err(|_| { 143 - XrpcError::named( 144 - StatusCode::BAD_REQUEST, 145 - "InvalidRepo", 146 - "subject must be a repo DID", 147 - ) 148 - })?; 149 - let window = window(&query)?; 158 + let window = paging.window(); 150 159 match state.index.owner_of(&repo) { 151 160 Resolved::Warming => return Err(crate::reads::warming()), 152 161 Resolved::Ready(None) => return Ok(respond(Vec::new(), window)),
+35 -20
crates/knot-xrpc/src/merge.rs
··· 18 18 use knot_runtime::{Clock, HttpTransport}; 19 19 use knot_types::{Oid, OwnerDid, RefName, RepoDid, RepoRkey, UnixSeconds}; 20 20 21 + use crate::body::{ 22 + AuthorEmail, AuthorName, BranchName, CommitBody, CommitMessage, Patch, RepoNameArg, 23 + }; 21 24 use crate::error::XrpcError; 22 25 use crate::reads::{open, repo_not_found, warming}; 23 - use crate::{XrpcState, branch_ref, decode, ok_empty, run_blocking}; 26 + use crate::{XrpcState, decode, ok_empty, run_blocking}; 24 27 25 28 pub(crate) const MERGE_ROUTE: &str = "/xrpc/sh.tangled.repo.merge"; 26 29 pub(crate) const MERGE_CHECK_ROUTE: &str = "/xrpc/sh.tangled.repo.mergeCheck"; ··· 38 41 #[serde(rename_all = "camelCase")] 39 42 struct MergeInput { 40 43 did: OwnerDid, 41 - name: String, 42 - patch: String, 43 - branch: String, 44 - author_name: Option<String>, 45 - author_email: Option<String>, 46 - commit_message: Option<String>, 47 - commit_body: Option<String>, 44 + name: RepoNameArg, 45 + patch: Patch, 46 + branch: BranchName, 47 + author_name: Option<AuthorName>, 48 + author_email: Option<AuthorEmail>, 49 + commit_message: Option<CommitMessage>, 50 + commit_body: Option<CommitBody>, 48 51 } 49 52 50 53 #[derive(Deserialize)] 51 54 struct MergeCheckInput { 52 55 did: OwnerDid, 53 - name: String, 54 - patch: String, 55 - branch: String, 56 + name: RepoNameArg, 57 + patch: Patch, 58 + branch: BranchName, 56 59 } 57 60 58 61 #[derive(Serialize)] ··· 389 392 ) -> Result<Response, XrpcError> { 390 393 let actor = state.authenticate(&headers, MERGE_NSID).await?; 391 394 let input: MergeInput = decode(&body)?; 392 - let repo_did = resolve_by_name(&state, &input.did, &input.name)?; 395 + let repo_did = resolve_by_name(&state, &input.did, input.name.as_str())?; 393 396 crate::authorize_push( 394 397 &state, 395 398 &actor, ··· 397 400 "only repository owner or a collaborator may merge", 398 401 ) 399 402 .await?; 400 - let refname = branch_ref(&input.branch)?; 403 + let refname = input.branch.as_ref_name().clone(); 401 404 let committer = state.committer.clone(); 402 405 let now = state.now(); 403 406 let layout = state.layout.clone(); ··· 408 411 let events = Arc::clone(&state.events); 409 412 let outcome = run_blocking(move || { 410 413 let specs = parse_specs( 411 - &input.patch, 414 + input.patch.as_str(), 412 415 unified_message(&input), 413 416 unified_author(&input), 414 417 max_patch_bytes, ··· 481 484 } 482 485 483 486 fn unified_message(input: &MergeInput) -> String { 484 - let message = input.commit_message.clone().unwrap_or_default(); 485 - match input.commit_body.as_deref().filter(|body| !body.is_empty()) { 487 + let message = input 488 + .commit_message 489 + .as_ref() 490 + .map(|message| message.as_str().to_string()) 491 + .unwrap_or_default(); 492 + match input 493 + .commit_body 494 + .as_ref() 495 + .map(|body| body.as_str()) 496 + .filter(|body| !body.is_empty()) 497 + { 486 498 Some(body) => format!("{message}\n\n{body}"), 487 499 None => message, 488 500 } 489 501 } 490 502 491 503 fn unified_author(input: &MergeInput) -> Option<MailAuthor> { 492 - match (input.author_name.as_deref(), input.author_email.as_deref()) { 504 + match ( 505 + input.author_name.as_ref().map(|name| name.as_str()), 506 + input.author_email.as_ref().map(|email| email.as_str()), 507 + ) { 493 508 (Some(name), Some(email)) if !name.is_empty() && !email.is_empty() => Some(MailAuthor { 494 509 name: name.to_string(), 495 510 email: email.to_string(), ··· 504 519 body: Bytes, 505 520 ) -> Result<Response, XrpcError> { 506 521 let input: MergeCheckInput = decode(&body)?; 507 - let repo_did = resolve_by_name(&state, &input.did, &input.name)?; 508 - let refname = branch_ref(&input.branch)?; 522 + let repo_did = resolve_by_name(&state, &input.did, input.name.as_str())?; 523 + let refname = input.branch.as_ref_name().clone(); 509 524 let layout = state.layout.clone(); 510 525 let max_patch_bytes = state.max_patch_decompressed_bytes; 511 526 512 527 let output = run_blocking(move || { 513 - let specs = match parse_specs(&input.patch, String::new(), None, max_patch_bytes) { 528 + let specs = match parse_specs(input.patch.as_str(), String::new(), None, max_patch_bytes) { 514 529 Ok(specs) => specs, 515 530 Err(error) => return Ok(MergeCheckOutput::broken(error.to_string())), 516 531 };
+176
crates/knot-xrpc/src/query.rs
··· 1 + use axum::extract::{FromRequestParts, Query}; 2 + use http::request::Parts; 3 + use serde::de::{self, Deserialize, DeserializeOwned, Deserializer}; 4 + 5 + use crate::error::XrpcError; 6 + 7 + pub(crate) struct ValidatedQuery<T>(pub(crate) T); 8 + 9 + impl<T, S> FromRequestParts<S> for ValidatedQuery<T> 10 + where 11 + T: DeserializeOwned, 12 + S: Send + Sync, 13 + { 14 + type Rejection = XrpcError; 15 + 16 + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { 17 + Query::<T>::try_from_uri(&parts.uri) 18 + .map(|query| ValidatedQuery(query.0)) 19 + .map_err(|rejection| XrpcError::invalid_request(rejection.body_text())) 20 + } 21 + } 22 + 23 + #[derive(Clone, Copy)] 24 + pub(crate) struct Limit<const DEFAULT: usize, const MAX: usize>(usize); 25 + 26 + impl<const DEFAULT: usize, const MAX: usize> Limit<DEFAULT, MAX> { 27 + pub(crate) fn get(self) -> usize { 28 + self.0 29 + } 30 + } 31 + 32 + impl<const DEFAULT: usize, const MAX: usize> Default for Limit<DEFAULT, MAX> { 33 + fn default() -> Self { 34 + Limit(DEFAULT) 35 + } 36 + } 37 + 38 + impl<'de, const DEFAULT: usize, const MAX: usize> Deserialize<'de> for Limit<DEFAULT, MAX> { 39 + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 40 + let raw = String::deserialize(deserializer)?; 41 + if raw.is_empty() { 42 + return Ok(Limit(DEFAULT)); 43 + } 44 + let value = raw 45 + .parse::<i64>() 46 + .map_err(|_| de::Error::custom("limit must be an integer"))?; 47 + Ok(Limit(usize::try_from(value).unwrap_or(0).min(MAX).max(1))) 48 + } 49 + } 50 + 51 + #[derive(Clone, Copy, Default)] 52 + pub(crate) struct Offset(usize); 53 + 54 + impl Offset { 55 + pub(crate) fn get(self) -> usize { 56 + self.0 57 + } 58 + } 59 + 60 + impl<'de> Deserialize<'de> for Offset { 61 + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 62 + let raw = String::deserialize(deserializer)?; 63 + if raw.is_empty() { 64 + return Ok(Offset(0)); 65 + } 66 + raw.parse::<usize>() 67 + .map(Offset) 68 + .map_err(|_| de::Error::custom("cursor must be an integer")) 69 + } 70 + } 71 + 72 + pub(crate) fn next_cursor(offset: usize, limit: usize, total: usize) -> Option<String> { 73 + offset 74 + .checked_add(limit) 75 + .filter(|&end| end < total) 76 + .map(|end| end.to_string()) 77 + } 78 + 79 + #[derive(Clone, Copy, Default, PartialEq, Eq)] 80 + pub(crate) enum Order { 81 + #[default] 82 + Desc, 83 + Asc, 84 + } 85 + 86 + impl Order { 87 + pub(crate) fn descending(self) -> bool { 88 + matches!(self, Order::Desc) 89 + } 90 + } 91 + 92 + impl<'de> Deserialize<'de> for Order { 93 + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 94 + match String::deserialize(deserializer)?.as_str() { 95 + "" | "desc" => Ok(Order::Desc), 96 + "asc" => Ok(Order::Asc), 97 + _ => Err(de::Error::custom("order must be 'asc' or 'desc'")), 98 + } 99 + } 100 + } 101 + 102 + pub(crate) enum RepoArg { 103 + Did(String), 104 + OwnerRkey { owner: String, rkey: String }, 105 + } 106 + 107 + impl RepoArg { 108 + pub(crate) fn basename(&self) -> &str { 109 + match self { 110 + RepoArg::Did(did) => did, 111 + RepoArg::OwnerRkey { rkey, .. } => rkey, 112 + } 113 + } 114 + 115 + pub(crate) fn to_param(&self) -> String { 116 + match self { 117 + RepoArg::Did(did) => did.clone(), 118 + RepoArg::OwnerRkey { owner, rkey } => format!("{owner}/{rkey}"), 119 + } 120 + } 121 + } 122 + 123 + impl<'de> Deserialize<'de> for RepoArg { 124 + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 125 + let raw = String::deserialize(deserializer)?; 126 + if !raw.starts_with("did:") { 127 + return Err(de::Error::custom( 128 + "missing or invalid repo parameter, expected repo DID", 129 + )); 130 + } 131 + Ok(match raw.split_once('/') { 132 + None => RepoArg::Did(raw), 133 + Some((owner, rkey)) => RepoArg::OwnerRkey { 134 + owner: owner.to_string(), 135 + rkey: rkey.to_string(), 136 + }, 137 + }) 138 + } 139 + } 140 + 141 + macro_rules! string_newtype { 142 + ($name:ident) => { 143 + #[derive(Default, serde::Deserialize)] 144 + #[serde(transparent)] 145 + pub(crate) struct $name(String); 146 + 147 + impl $name { 148 + pub(crate) fn as_str(&self) -> &str { 149 + &self.0 150 + } 151 + } 152 + }; 153 + } 154 + 155 + pub(crate) use string_newtype; 156 + 157 + string_newtype!(RefArg); 158 + string_newtype!(Revision); 159 + string_newtype!(TreePath); 160 + string_newtype!(BranchArg); 161 + string_newtype!(TagArg); 162 + 163 + #[derive(Default)] 164 + pub(crate) struct RawFlag(bool); 165 + 166 + impl RawFlag { 167 + pub(crate) fn requested(&self) -> bool { 168 + self.0 169 + } 170 + } 171 + 172 + impl<'de> Deserialize<'de> for RawFlag { 173 + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 174 + Ok(RawFlag(String::deserialize(deserializer)? == "true")) 175 + } 176 + }
+368 -187
crates/knot-xrpc/src/reads.rs
··· 6 6 use axum::extract::{Query, Request, State}; 7 7 use axum::response::{IntoResponse, Response}; 8 8 use http::{HeaderMap, HeaderValue, StatusCode, header}; 9 + use serde::de::{self, Deserializer}; 9 10 use serde::{Deserialize, Serialize}; 10 11 use sha2::{Digest, Sha256}; 11 12 use tower::ServiceExt; 12 13 use tower_http::services::ServeFile; 13 14 14 - use knot_git::{ArchiveFormat, Commit, EntryKind, Layout, Repo, SizedEntry, is_reserved}; 15 - use knot_index::Resolved; 15 + use knot_git::{ 16 + ArchiveFormat, Commit, EntryKind, Layout, Repo, SizedEntry, is_public_ref, is_reserved, 17 + }; 18 + use knot_index::{Coverage, Resolved}; 16 19 use knot_runtime::{Clock, HttpTransport}; 17 20 use knot_types::{Oid, OwnerDid, RepoDid, RepoRkey}; 18 21 19 22 use crate::error::XrpcError; 20 23 use crate::patchtext::{render_format_patch, render_patches}; 24 + use crate::query::{ 25 + BranchArg, Limit, Offset, Order, RawFlag, RefArg, RepoArg, Revision, TagArg, TreePath, 26 + ValidatedQuery, next_cursor, 27 + }; 21 28 use crate::wire::{ 22 - BranchWire, FormatPatchWire, GoCommit, GoFile, PatchIdentityWire, TagWire, ZERO_TIME, 29 + BranchWire, CommitWire, FileWire, FormatPatchWire, PatchIdentityWire, TagWire, ZERO_TIME, 23 30 fold_subject, message_body, nice_diff, normalize_message_section, rfc2822, rfc3339, 24 31 }; 25 32 use crate::{XrpcState, run_blocking, sniff}; ··· 37 44 pub(crate) const LANGUAGES_ROUTE: &str = "/xrpc/sh.tangled.repo.languages"; 38 45 pub(crate) const GET_DEFAULT_BRANCH_ROUTE: &str = "/xrpc/sh.tangled.repo.getDefaultBranch"; 39 46 pub(crate) const DESCRIBE_REPO_ROUTE: &str = "/xrpc/sh.tangled.repo.describeRepo"; 47 + pub(crate) const LIST_REFS_ROUTE: &str = "/xrpc/sh.tangled.git.listRefs"; 48 + pub(crate) const LIST_REPOS_ROUTE: &str = "/xrpc/sh.tangled.sync.listRepos"; 40 49 41 50 const DEFAULT_PAGE: usize = 50; 42 51 const MAX_PAGE: usize = 100; 52 + const LIST_REFS_DEFAULT: usize = 100; 53 + const LIST_REFS_MAX: usize = 1000; 54 + const LIST_REPOS_DEFAULT: usize = 50; 55 + const LIST_REPOS_MAX: usize = 1000; 43 56 const MAX_BLOB_BYTES: u64 = 25 * 1024 * 1024; 44 57 const MAX_COMPARE_COMMITS: usize = 500; 45 58 const ARCHIVE_CAP_MESSAGE: &str = "archive exceeds configured maximum size"; ··· 88 101 XrpcError::warming("registry projection is still warming") 89 102 } 90 103 91 - fn resolve_repo_param<H: HttpTransport, C: Clock>( 104 + fn resolve_repo<H: HttpTransport, C: Clock>( 92 105 state: &XrpcState<H, C>, 93 - repo: &str, 106 + repo: &RepoArg, 94 107 ) -> Result<RepoDid, XrpcError> { 95 - if !repo.starts_with("did:") { 96 - return Err(XrpcError::invalid_request( 97 - "missing or invalid repo parameter, expected repo DID", 98 - )); 99 - } 100 - match repo.split_once('/') { 101 - None => { 102 - let did = RepoDid::new(repo).map_err(|_| repo_not_found())?; 108 + match repo { 109 + RepoArg::Did(did) => { 110 + let did = RepoDid::new(did).map_err(|_| repo_not_found())?; 103 111 match state.index.owner_of(&did) { 104 112 Resolved::Ready(Some(_)) => Ok(did), 105 113 Resolved::Ready(None) => Err(repo_not_found()), 106 114 Resolved::Warming => Err(warming()), 107 115 } 108 116 } 109 - Some((owner, rkey)) => { 117 + RepoArg::OwnerRkey { owner, rkey } => { 110 118 let owner = OwnerDid::new(owner).map_err(|_| repo_not_found())?; 111 119 let rkey = RepoRkey::new(rkey).map_err(|_| repo_not_found())?; 112 120 match state.index.resolve_repo(&owner, &rkey) { ··· 139 147 Ok(false) => Err(ref_not_found()), 140 148 Err(error) => Err(XrpcError::internal(error.to_string())), 141 149 } 142 - } 143 - 144 - fn page_window(limit: &Option<String>, cursor: &Option<String>) -> (usize, usize) { 145 - let limit = limit 146 - .as_deref() 147 - .and_then(|raw| raw.parse::<usize>().ok()) 148 - .filter(|parsed| (1..=MAX_PAGE).contains(parsed)) 149 - .unwrap_or(DEFAULT_PAGE); 150 - let offset = cursor 151 - .as_deref() 152 - .and_then(|raw| raw.parse::<usize>().ok()) 153 - .unwrap_or(0); 154 - (offset, limit) 155 150 } 156 151 157 152 struct CapWriter { ··· 199 194 200 195 #[derive(Deserialize)] 201 196 pub(crate) struct TreeParams { 202 - repo: String, 197 + repo: RepoArg, 203 198 #[serde(rename = "ref", default)] 204 - refspec: String, 199 + refspec: RefArg, 205 200 #[serde(default)] 206 - path: String, 201 + path: TreePath, 207 202 } 208 203 209 204 #[derive(Serialize)] ··· 293 288 294 289 pub(crate) async fn repo_tree<H: HttpTransport, C: Clock>( 295 290 State(state): State<Arc<XrpcState<H, C>>>, 296 - Query(params): Query<TreeParams>, 291 + ValidatedQuery(params): ValidatedQuery<TreeParams>, 297 292 ) -> Result<Response, XrpcError> { 298 - let did = resolve_repo_param(&state, &params.repo)?; 293 + let did = resolve_repo(&state, &params.repo)?; 299 294 let layout = state.layout.clone(); 300 295 let cap = state.max_response_bytes; 301 296 let tree_deadline = state.tree_last_commit_budget.deadline(); 302 297 run_blocking(move || { 303 298 let repo = open(&layout, &did)?; 304 - let commit = commit_for(&repo, &params.refspec)?; 299 + let commit = commit_for(&repo, params.refspec.as_str())?; 300 + let path = params.path.as_str(); 305 301 let entries = repo 306 - .tree_entries_at(commit, &params.path) 302 + .tree_entries_at(commit, path) 307 303 .map_err(|error| XrpcError::internal(error.to_string()))? 308 304 .ok_or_else(|| { 309 305 XrpcError::named( ··· 314 310 })?; 315 311 let names: Vec<String> = entries.iter().map(|entry| entry.name.clone()).collect(); 316 312 let attributed = repo 317 - .last_commits(commit, &params.path, &names, tree_deadline) 313 + .last_commits(commit, path, &names, tree_deadline) 318 314 .unwrap_or_default(); 319 315 let files: Vec<TreeEntryOut> = entries 320 316 .iter() ··· 341 337 when: String::new(), 342 338 }), 343 339 }); 344 - let readme = readme_of(&repo, commit, &params.path, &entries, cap); 345 - let parent = (!params.path.is_empty()).then(|| params.path.clone()); 346 - let dotdot = (!params.path.is_empty()) 347 - .then(|| { 348 - params 349 - .path 350 - .rsplit_once('/') 351 - .map(|(parent, _)| parent.to_string()) 352 - }) 340 + let readme = readme_of(&repo, commit, path, &entries, cap); 341 + let parent = (!path.is_empty()).then(|| path.to_string()); 342 + let dotdot = (!path.is_empty()) 343 + .then(|| path.rsplit_once('/').map(|(parent, _)| parent.to_string())) 353 344 .flatten(); 354 345 json( 355 346 TreeOut { 356 - refspec: params.refspec, 347 + refspec: params.refspec.as_str().to_string(), 357 348 parent, 358 349 dotdot, 359 350 files, ··· 368 359 369 360 #[derive(Deserialize)] 370 361 pub(crate) struct LogParams { 371 - repo: String, 362 + repo: RepoArg, 372 363 #[serde(rename = "ref", default)] 373 - refspec: String, 364 + refspec: RefArg, 374 365 #[serde(default)] 375 - path: String, 376 - limit: Option<String>, 377 - cursor: Option<String>, 366 + path: TreePath, 367 + #[serde(default)] 368 + limit: Limit<DEFAULT_PAGE, MAX_PAGE>, 369 + #[serde(default)] 370 + cursor: Offset, 378 371 } 379 372 380 373 #[derive(Serialize)] 381 374 struct LogOut { 382 375 #[serde(skip_serializing_if = "Vec::is_empty")] 383 - commits: Vec<GoCommit>, 376 + commits: Vec<CommitWire>, 384 377 #[serde(rename = "ref", skip_serializing_if = "String::is_empty")] 385 378 refspec: String, 386 379 #[serde(skip_serializing_if = "String::is_empty")] ··· 398 391 399 392 pub(crate) async fn repo_log<H: HttpTransport, C: Clock>( 400 393 State(state): State<Arc<XrpcState<H, C>>>, 401 - Query(params): Query<LogParams>, 394 + ValidatedQuery(params): ValidatedQuery<LogParams>, 402 395 ) -> Result<Response, XrpcError> { 403 - let did = resolve_repo_param(&state, &params.repo)?; 404 - let (offset, limit) = page_window(&params.limit, &params.cursor); 396 + let did = resolve_repo(&state, &params.repo)?; 397 + let offset = params.cursor.get(); 398 + let limit = params.limit.get(); 405 399 let layout = state.layout.clone(); 406 400 let cap = state.max_response_bytes; 407 401 run_blocking(move || { 408 402 let repo = open(&layout, &did)?; 409 - let start = commit_for(&repo, &params.refspec)?; 403 + let start = commit_for(&repo, params.refspec.as_str())?; 410 404 let (commits, total) = repo 411 405 .log_window(start, offset, limit) 412 406 .map_err(|error| XrpcError::internal(error.to_string()))?; 413 407 json( 414 408 LogOut { 415 - commits: commits.iter().map(GoCommit::of).collect(), 416 - refspec: params.refspec, 417 - description: params.path, 409 + commits: commits.iter().map(CommitWire::of).collect(), 410 + refspec: params.refspec.as_str().to_string(), 411 + description: params.path.as_str().to_string(), 418 412 log: true, 419 413 total, 420 414 page: (offset / limit) + 1, ··· 428 422 429 423 #[derive(Deserialize)] 430 424 pub(crate) struct BranchesParams { 431 - repo: String, 432 - limit: Option<String>, 433 - cursor: Option<String>, 425 + repo: RepoArg, 426 + #[serde(default)] 427 + limit: Limit<DEFAULT_PAGE, MAX_PAGE>, 428 + #[serde(default)] 429 + cursor: Offset, 434 430 } 435 431 436 432 #[derive(Serialize)] ··· 441 437 442 438 pub(crate) async fn repo_branches<H: HttpTransport, C: Clock>( 443 439 State(state): State<Arc<XrpcState<H, C>>>, 444 - Query(params): Query<BranchesParams>, 440 + ValidatedQuery(params): ValidatedQuery<BranchesParams>, 445 441 ) -> Result<Response, XrpcError> { 446 - let did = resolve_repo_param(&state, &params.repo)?; 447 - let (offset, limit) = page_window(&params.limit, &params.cursor); 442 + let did = resolve_repo(&state, &params.repo)?; 443 + let offset = params.cursor.get(); 444 + let limit = params.limit.get(); 448 445 let layout = state.layout.clone(); 449 446 let cap = state.max_response_bytes; 450 447 run_blocking(move || { ··· 482 479 483 480 #[derive(Deserialize)] 484 481 pub(crate) struct BranchParams { 485 - repo: String, 486 - name: String, 482 + repo: RepoArg, 483 + #[serde(default)] 484 + name: BranchArg, 487 485 } 488 486 489 487 #[derive(Serialize)] ··· 502 500 503 501 pub(crate) async fn repo_branch<H: HttpTransport, C: Clock>( 504 502 State(state): State<Arc<XrpcState<H, C>>>, 505 - Query(params): Query<BranchParams>, 503 + ValidatedQuery(params): ValidatedQuery<BranchParams>, 506 504 ) -> Result<Response, XrpcError> { 507 - let did = resolve_repo_param(&state, &params.repo)?; 508 - if params.name.is_empty() { 505 + let did = resolve_repo(&state, &params.repo)?; 506 + if params.name.as_str().is_empty() { 509 507 return Err(XrpcError::invalid_request("missing name parameter")); 510 508 } 511 509 let layout = state.layout.clone(); 512 510 let cap = state.max_response_bytes; 513 511 run_blocking(move || { 514 512 let repo = open(&layout, &did)?; 513 + let name = params.name.as_str(); 515 514 let branch_not_found = 516 515 || XrpcError::named(StatusCode::NOT_FOUND, "BranchNotFound", "branch not found"); 517 - let target = crate::branch_ref(&params.name) 516 + let target = crate::branch_ref(name) 518 517 .ok() 519 518 .and_then(|name| repo.find_ref(&name).ok().flatten()) 520 519 .ok_or_else(branch_not_found)?; ··· 525 524 let hash = target.to_hex(); 526 525 json( 527 526 BranchOut { 528 - name: params.name.clone(), 527 + name: name.to_string(), 529 528 short_hash: hash[..7].to_string(), 530 529 hash, 531 530 when: rfc3339(commit.author.time.get(), commit.author.offset_seconds), ··· 535 534 email: commit.author.email.clone(), 536 535 when: rfc3339(commit.author.time.get(), commit.author.offset_seconds), 537 536 }, 538 - is_default: default.as_deref() == Some(params.name.as_str()), 537 + is_default: default.as_deref() == Some(name), 539 538 }, 540 539 cap, 541 540 ) ··· 545 544 546 545 #[derive(Deserialize)] 547 546 pub(crate) struct TagsParams { 548 - repo: String, 549 - limit: Option<String>, 550 - cursor: Option<String>, 547 + repo: RepoArg, 548 + #[serde(default)] 549 + limit: Limit<DEFAULT_PAGE, MAX_PAGE>, 550 + #[serde(default)] 551 + cursor: Offset, 551 552 } 552 553 553 554 #[derive(Serialize)] ··· 558 559 559 560 pub(crate) async fn repo_tags<H: HttpTransport, C: Clock>( 560 561 State(state): State<Arc<XrpcState<H, C>>>, 561 - Query(params): Query<TagsParams>, 562 + ValidatedQuery(params): ValidatedQuery<TagsParams>, 562 563 ) -> Result<Response, XrpcError> { 563 - let did = resolve_repo_param(&state, &params.repo)?; 564 - let (offset, limit) = page_window(&params.limit, &params.cursor); 564 + let did = resolve_repo(&state, &params.repo)?; 565 + let offset = params.cursor.get(); 566 + let limit = params.limit.get(); 565 567 let layout = state.layout.clone(); 566 568 let cap = state.max_response_bytes; 567 569 run_blocking(move || { ··· 587 589 588 590 #[derive(Deserialize)] 589 591 pub(crate) struct TagParams { 590 - repo: String, 591 - tag: String, 592 + repo: RepoArg, 593 + #[serde(default)] 594 + tag: TagArg, 592 595 } 593 596 594 597 #[derive(Serialize)] ··· 598 601 599 602 pub(crate) async fn repo_tag<H: HttpTransport, C: Clock>( 600 603 State(state): State<Arc<XrpcState<H, C>>>, 601 - Query(params): Query<TagParams>, 604 + ValidatedQuery(params): ValidatedQuery<TagParams>, 602 605 ) -> Result<Response, XrpcError> { 603 - let did = resolve_repo_param(&state, &params.repo)?; 604 - if params.tag.is_empty() { 606 + let did = resolve_repo(&state, &params.repo)?; 607 + if params.tag.as_str().is_empty() { 605 608 return Err(XrpcError::invalid_request("missing tag parameter")); 606 609 } 607 610 let layout = state.layout.clone(); 608 611 let cap = state.max_response_bytes; 609 612 run_blocking(move || { 610 613 let repo = open(&layout, &did)?; 611 - let name = params.tag.strip_prefix("refs/tags/").unwrap_or(&params.tag); 614 + let tag = params.tag.as_str(); 615 + let name = tag.strip_prefix("refs/tags/").unwrap_or(tag); 612 616 let info = repo 613 617 .tag_list() 614 618 .map_err(|error| XrpcError::internal(error.to_string()))? ··· 629 633 630 634 #[derive(Deserialize)] 631 635 pub(crate) struct BlobParams { 632 - repo: String, 636 + repo: RepoArg, 633 637 #[serde(rename = "ref", default)] 634 - refspec: String, 638 + refspec: RefArg, 635 639 #[serde(default)] 636 - path: String, 637 - raw: Option<String>, 640 + path: TreePath, 641 + #[serde(default)] 642 + raw: RawFlag, 638 643 } 639 644 640 645 #[derive(Serialize)] ··· 729 734 730 735 pub(crate) async fn repo_blob<H: HttpTransport, C: Clock>( 731 736 State(state): State<Arc<XrpcState<H, C>>>, 732 - Query(params): Query<BlobParams>, 737 + ValidatedQuery(params): ValidatedQuery<BlobParams>, 733 738 headers: HeaderMap, 734 739 ) -> Result<Response, XrpcError> { 735 - let did = resolve_repo_param(&state, &params.repo)?; 736 - if params.path.is_empty() { 740 + let did = resolve_repo(&state, &params.repo)?; 741 + if params.path.as_str().is_empty() { 737 742 return Err(XrpcError::invalid_request("missing path parameter")); 738 743 } 739 744 let layout = state.layout.clone(); 740 745 let cap = state.max_response_bytes; 741 746 let blob_deadline = state.blob_last_commit_budget.deadline(); 742 747 run_blocking(move || { 748 + let refspec = params.refspec.as_str().to_string(); 749 + let path = params.path.as_str().to_string(); 750 + let raw = params.raw.requested(); 743 751 let repo = open(&layout, &did)?; 744 - let commit = commit_for(&repo, &params.refspec)?; 752 + let commit = commit_for(&repo, &refspec)?; 745 753 let submodule = repo 746 754 .submodules(commit) 747 755 .unwrap_or_default() 748 756 .into_iter() 749 - .find(|submodule| submodule.path == params.path); 757 + .find(|submodule| submodule.path == path); 750 758 if let Some(submodule) = submodule { 751 759 return json( 752 760 BlobOut { 753 - refspec: params.refspec, 754 - path: params.path, 761 + refspec, 762 + path, 755 763 content: None, 756 764 encoding: None, 757 765 size: None, ··· 775 783 ) 776 784 }; 777 785 let entry = repo 778 - .entry_at(commit, &params.path) 786 + .entry_at(commit, &path) 779 787 .map_err(|error| XrpcError::internal(error.to_string()))? 780 788 .filter(|entry| { 781 789 matches!( ··· 784 792 ) 785 793 }) 786 794 .ok_or_else(file_not_found)?; 787 - let raw = params.raw.as_deref() == Some("true"); 788 795 if repo.blob_size(entry.oid).map_err(|_| file_not_found())? > blob_serving_limit(raw, cap) { 789 796 return Err(blob_too_large()); 790 797 } 791 798 let contents = repo.read_blob(entry.oid).map_err(|_| file_not_found())?; 792 - let mime = 793 - sniff::override_by_extension(&params.path, sniff::detect_content_type(&contents)); 799 + let mime = sniff::override_by_extension(&path, sniff::detect_content_type(&contents)); 794 800 795 801 if raw { 796 802 return serve_raw(&headers, mime, contents); ··· 805 811 ), 806 812 false => (String::from_utf8_lossy(&contents).into_owned(), "utf-8"), 807 813 }; 808 - let (dir, name) = params 809 - .path 814 + let (dir, name) = path 810 815 .rsplit_once('/') 811 816 .map(|(dir, name)| (dir.to_string(), name.to_string())) 812 - .unwrap_or((String::new(), params.path.clone())); 817 + .unwrap_or((String::new(), path.clone())); 813 818 let last_commit = repo 814 819 .last_commits(commit, &dir, std::slice::from_ref(&name), blob_deadline) 815 820 .ok() ··· 826 831 }); 827 832 json( 828 833 BlobOut { 829 - refspec: params.refspec, 830 - path: params.path, 834 + refspec, 835 + path, 831 836 content: Some(content), 832 837 encoding: Some(encoding), 833 838 size: Some(size), ··· 844 849 845 850 #[derive(Deserialize)] 846 851 pub(crate) struct DiffParams { 847 - repo: String, 852 + repo: RepoArg, 848 853 #[serde(rename = "ref", default)] 849 - refspec: String, 854 + refspec: RefArg, 850 855 } 851 856 852 857 #[derive(Serialize)] ··· 858 863 859 864 pub(crate) async fn repo_diff<H: HttpTransport, C: Clock>( 860 865 State(state): State<Arc<XrpcState<H, C>>>, 861 - Query(params): Query<DiffParams>, 866 + ValidatedQuery(params): ValidatedQuery<DiffParams>, 862 867 ) -> Result<Response, XrpcError> { 863 - let did = resolve_repo_param(&state, &params.repo)?; 868 + let did = resolve_repo(&state, &params.repo)?; 864 869 let layout = state.layout.clone(); 865 870 let cap = state.max_response_bytes; 866 871 run_blocking(move || { 867 872 let repo = open(&layout, &did)?; 868 - let target = commit_for(&repo, &params.refspec)?; 873 + let target = commit_for(&repo, params.refspec.as_str())?; 869 874 let commit = repo 870 875 .find_commit(target) 871 876 .map_err(|error| XrpcError::internal(error.to_string()))?; ··· 874 879 .map_err(|error| XrpcError::internal(error.to_string()))?; 875 880 json( 876 881 DiffOut { 877 - refspec: params.refspec, 882 + refspec: params.refspec.as_str().to_string(), 878 883 diff: nice_diff(&commit, &patches), 879 884 }, 880 885 cap, ··· 885 890 886 891 #[derive(Deserialize)] 887 892 pub(crate) struct CompareParams { 888 - repo: String, 889 - rev1: Option<String>, 890 - rev2: Option<String>, 893 + repo: RepoArg, 894 + #[serde(default)] 895 + rev1: Revision, 896 + #[serde(default)] 897 + rev2: Revision, 891 898 } 892 899 893 900 #[derive(Serialize)] ··· 899 906 #[serde(rename = "patch", skip_serializing_if = "String::is_empty")] 900 907 patch_raw: String, 901 908 #[serde(skip_serializing_if = "Option::is_none")] 902 - combined_patch: Option<Vec<GoFile>>, 909 + combined_patch: Option<Vec<FileWire>>, 903 910 #[serde(skip_serializing_if = "Option::is_none")] 904 911 combined_patch_raw: Option<String>, 905 912 } ··· 927 934 if let Some(change_id) = commit.change_id() { 928 935 raw_headers.insert("Change-Id".to_string(), vec![change_id.to_string()]); 929 936 } 930 - let files: Vec<GoFile> = patches.iter().map(GoFile::of).collect(); 937 + let files: Vec<FileWire> = patches.iter().map(FileWire::of).collect(); 931 938 FormatPatchWire { 932 939 files: (!files.is_empty()).then_some(files), 933 940 sha: commit.id.to_hex(), ··· 959 966 960 967 pub(crate) async fn repo_compare<H: HttpTransport, C: Clock>( 961 968 State(state): State<Arc<XrpcState<H, C>>>, 962 - Query(params): Query<CompareParams>, 969 + ValidatedQuery(params): ValidatedQuery<CompareParams>, 963 970 ) -> Result<Response, XrpcError> { 964 - let did = resolve_repo_param(&state, &params.repo)?; 965 - let rev1 = params 966 - .rev1 967 - .filter(|rev| !rev.is_empty()) 968 - .ok_or_else(|| XrpcError::invalid_request("missing rev1 parameter"))?; 969 - let rev2 = params 970 - .rev2 971 - .filter(|rev| !rev.is_empty()) 972 - .ok_or_else(|| XrpcError::invalid_request("missing rev2 parameter"))?; 971 + let did = resolve_repo(&state, &params.repo)?; 972 + let rev1 = params.rev1.as_str().to_string(); 973 + if rev1.is_empty() { 974 + return Err(XrpcError::invalid_request("missing rev1 parameter")); 975 + } 976 + let rev2 = params.rev2.as_str().to_string(); 977 + if rev2.is_empty() { 978 + return Err(XrpcError::invalid_request("missing rev2 parameter")); 979 + } 973 980 let layout = state.layout.clone(); 974 981 let cap = state.max_response_bytes; 975 982 run_blocking(move || { ··· 1045 1052 .ok() 1046 1053 .map(|patches| { 1047 1054 ( 1048 - Some(patches.iter().map(GoFile::of).collect::<Vec<_>>()), 1055 + Some(patches.iter().map(FileWire::of).collect::<Vec<_>>()), 1049 1056 Some(render_patches(&patches)), 1050 1057 ) 1051 1058 }) ··· 1068 1075 .await 1069 1076 } 1070 1077 1078 + #[derive(Clone, Copy)] 1079 + struct ArchiveFormatArg(ArchiveFormat); 1080 + 1081 + impl Default for ArchiveFormatArg { 1082 + fn default() -> Self { 1083 + ArchiveFormatArg(ArchiveFormat::TarGz) 1084 + } 1085 + } 1086 + 1087 + impl ArchiveFormatArg { 1088 + fn format(self) -> ArchiveFormat { 1089 + self.0 1090 + } 1091 + 1092 + fn name(self) -> &'static str { 1093 + match self.0 { 1094 + ArchiveFormat::Zip => "zip", 1095 + _ => "tar.gz", 1096 + } 1097 + } 1098 + 1099 + fn content_type(self) -> &'static str { 1100 + match self.0 { 1101 + ArchiveFormat::Zip => "application/zip", 1102 + _ => "application/gzip", 1103 + } 1104 + } 1105 + } 1106 + 1107 + impl<'de> Deserialize<'de> for ArchiveFormatArg { 1108 + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 1109 + match String::deserialize(deserializer)?.as_str() { 1110 + "" | "tar.gz" => Ok(ArchiveFormatArg(ArchiveFormat::TarGz)), 1111 + "zip" => Ok(ArchiveFormatArg(ArchiveFormat::Zip)), 1112 + _ => Err(de::Error::custom( 1113 + "only tar.gz and zip formats are supported", 1114 + )), 1115 + } 1116 + } 1117 + } 1118 + 1119 + #[derive(Default)] 1120 + struct ArchivePrefix(String); 1121 + 1122 + impl ArchivePrefix { 1123 + fn as_str(&self) -> &str { 1124 + &self.0 1125 + } 1126 + } 1127 + 1128 + impl<'de> Deserialize<'de> for ArchivePrefix { 1129 + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 1130 + let raw = String::deserialize(deserializer)?; 1131 + match raw.is_empty() || knot_git::safe_archive_prefix(&raw) { 1132 + true => Ok(ArchivePrefix(raw)), 1133 + false => Err(de::Error::custom( 1134 + "archive prefix must not escape archive root", 1135 + )), 1136 + } 1137 + } 1138 + } 1139 + 1071 1140 #[derive(Deserialize)] 1072 1141 pub(crate) struct ArchiveParams { 1073 - repo: String, 1142 + repo: RepoArg, 1074 1143 #[serde(rename = "ref", default)] 1075 - refspec: String, 1076 - format: Option<String>, 1077 - prefix: Option<String>, 1144 + refspec: RefArg, 1145 + #[serde(default)] 1146 + format: ArchiveFormatArg, 1147 + #[serde(default)] 1148 + prefix: ArchivePrefix, 1078 1149 } 1079 1150 1080 1151 fn short_ref(refspec: &str) -> String { ··· 1212 1283 mut request: Request, 1213 1284 ) -> Result<Response, XrpcError> { 1214 1285 let params = Query::<ArchiveParams>::try_from_uri(request.uri()) 1215 - .map_err(|_| XrpcError::invalid_request("invalid archive query parameters"))? 1286 + .map_err(|rejection| XrpcError::invalid_request(rejection.body_text()))? 1216 1287 .0; 1217 - let did = resolve_repo_param(&state, &params.repo)?; 1218 - let format_name = params 1219 - .format 1220 - .clone() 1221 - .unwrap_or_else(|| "tar.gz".to_string()); 1222 - let format = match format_name.as_str() { 1223 - "tar.gz" => ArchiveFormat::TarGz, 1224 - "zip" => ArchiveFormat::Zip, 1225 - _ => { 1226 - return Err(XrpcError::invalid_request( 1227 - "only tar.gz and zip formats are supported", 1228 - )); 1229 - } 1288 + let did = resolve_repo(&state, &params.repo)?; 1289 + let format = params.format; 1290 + let format_name = format.name(); 1291 + let repo_name = params.repo.basename().to_string(); 1292 + let safe_ref = short_ref(params.refspec.as_str()); 1293 + let archive_prefix = match params.prefix.as_str().is_empty() { 1294 + true => format!("{repo_name}-{safe_ref}"), 1295 + false => params.prefix.as_str().to_string(), 1230 1296 }; 1231 - if params 1232 - .prefix 1233 - .as_deref() 1234 - .is_some_and(|prefix| !prefix.is_empty() && !knot_git::safe_archive_prefix(prefix)) 1235 - { 1236 - return Err(XrpcError::invalid_request( 1237 - "archive prefix must not escape archive root", 1238 - )); 1239 - } 1240 - let repo_name = params 1241 - .repo 1242 - .rsplit('/') 1243 - .next() 1244 - .unwrap_or_default() 1245 - .to_string(); 1246 - let safe_ref = short_ref(&params.refspec); 1247 - let archive_prefix = params 1248 - .prefix 1249 - .clone() 1250 - .filter(|prefix| !prefix.is_empty()) 1251 - .unwrap_or_else(|| format!("{repo_name}-{safe_ref}")); 1252 1297 1253 1298 let (resolved, modified_secs) = run_blocking({ 1254 1299 let layout = state.layout.clone(); 1255 1300 let did = did.clone(); 1256 - let refspec = params.refspec.clone(); 1301 + let refspec = params.refspec.as_str().to_string(); 1257 1302 move || { 1258 1303 let repo = open(&layout, &did)?; 1259 1304 let commit = commit_for(&repo, &refspec)?; ··· 1266 1311 }) 1267 1312 .await?; 1268 1313 1269 - let etag = archive_etag(&did, resolved, &format_name, &archive_prefix); 1314 + let etag = archive_etag(&did, resolved, format_name, &archive_prefix); 1270 1315 if etag_matches(request.headers(), &etag) { 1271 1316 return Ok(( 1272 1317 StatusCode::NOT_MODIFIED, ··· 1299 1344 cap: archive_cap, 1300 1345 tripped: false, 1301 1346 }; 1302 - repo.write_archive(tree, format, Some(&tree_prefix), &mut spool) 1347 + repo.write_archive(tree, format.format(), Some(&tree_prefix), &mut spool) 1303 1348 .map_err(|error| match spool.tripped { 1304 1349 true => XrpcError::request_too_large(ARCHIVE_CAP_MESSAGE), 1305 1350 false => XrpcError::named( ··· 1318 1363 1319 1364 let immutable = { 1320 1365 let mut query = url::form_urlencoded::Serializer::new(String::new()); 1321 - query.append_pair("format", &format_name); 1366 + query.append_pair("format", format_name); 1322 1367 query.append_pair("prefix", &archive_prefix); 1323 1368 query.append_pair("ref", &resolved.to_hex()); 1324 - query.append_pair("repo", &params.repo); 1369 + query.append_pair("repo", &params.repo.to_param()); 1325 1370 format!( 1326 1371 "{}/xrpc/sh.tangled.repo.archive?{}", 1327 1372 state.knot_service_url.trim_end_matches('/'), 1328 1373 query.finish() 1329 1374 ) 1330 1375 }; 1331 - let content_type = match format { 1332 - ArchiveFormat::Zip => "application/zip", 1333 - _ => "application/gzip", 1334 - }; 1376 + let content_type = format.content_type(); 1335 1377 let disposition = content_disposition(&format!("{repo_name}-{safe_ref}.{format_name}")); 1336 1378 1337 1379 reconcile_if_range(&mut request, &etag, modified_secs); ··· 1364 1406 1365 1407 #[derive(Deserialize)] 1366 1408 pub(crate) struct LanguagesParams { 1367 - repo: String, 1409 + repo: RepoArg, 1368 1410 #[serde(rename = "ref", default)] 1369 - refspec: String, 1411 + refspec: RefArg, 1370 1412 } 1371 1413 1372 1414 #[derive(Serialize)] ··· 1389 1431 1390 1432 pub(crate) async fn repo_languages<H: HttpTransport, C: Clock>( 1391 1433 State(state): State<Arc<XrpcState<H, C>>>, 1392 - Query(params): Query<LanguagesParams>, 1434 + ValidatedQuery(params): ValidatedQuery<LanguagesParams>, 1393 1435 ) -> Result<Response, XrpcError> { 1394 - let did = resolve_repo_param(&state, &params.repo)?; 1436 + let did = resolve_repo(&state, &params.repo)?; 1395 1437 let layout = state.layout.clone(); 1396 1438 let cap = state.max_response_bytes; 1397 1439 let languages_deadline = state.languages_budget.deadline(); 1398 1440 run_blocking(move || { 1399 1441 let repo = open(&layout, &did)?; 1400 - let commit = commit_for(&repo, &params.refspec)?; 1442 + let commit = commit_for(&repo, params.refspec.as_str())?; 1401 1443 let sizes = knot_langs::analyze(&repo, commit, languages_deadline) 1402 1444 .map_err(|error| XrpcError::internal(error.to_string()))?; 1403 1445 let total: i64 = sizes.values().sum(); ··· 1414 1456 let count = languages.len() as i64; 1415 1457 json( 1416 1458 LanguagesOut { 1417 - refspec: params.refspec, 1459 + refspec: params.refspec.as_str().to_string(), 1418 1460 languages: (!languages.is_empty()).then_some(languages), 1419 1461 total_size: (total > 0).then_some(total), 1420 1462 total_files: (total > 0).then_some(count), ··· 1427 1469 1428 1470 #[derive(Deserialize)] 1429 1471 pub(crate) struct DefaultBranchParams { 1430 - repo: String, 1472 + repo: RepoArg, 1431 1473 } 1432 1474 1433 1475 #[derive(Serialize)] ··· 1439 1481 1440 1482 pub(crate) async fn repo_get_default_branch<H: HttpTransport, C: Clock>( 1441 1483 State(state): State<Arc<XrpcState<H, C>>>, 1442 - Query(params): Query<DefaultBranchParams>, 1484 + ValidatedQuery(params): ValidatedQuery<DefaultBranchParams>, 1443 1485 ) -> Result<Response, XrpcError> { 1444 - let did = resolve_repo_param(&state, &params.repo)?; 1486 + let did = resolve_repo(&state, &params.repo)?; 1445 1487 let layout = state.layout.clone(); 1446 1488 let cap = state.max_response_bytes; 1447 1489 run_blocking(move || { ··· 1471 1513 #[derive(Deserialize)] 1472 1514 pub(crate) struct DescribeRepoParams { 1473 1515 #[serde(rename = "repoDid")] 1474 - repo_did: String, 1516 + repo_did: RepoDid, 1475 1517 } 1476 1518 1477 1519 #[derive(Serialize)] ··· 1485 1527 1486 1528 pub(crate) async fn repo_describe_repo<H: HttpTransport, C: Clock>( 1487 1529 State(state): State<Arc<XrpcState<H, C>>>, 1488 - Query(params): Query<DescribeRepoParams>, 1530 + ValidatedQuery(params): ValidatedQuery<DescribeRepoParams>, 1489 1531 ) -> Result<Response, XrpcError> { 1490 - let did = RepoDid::new(&params.repo_did) 1491 - .map_err(|_| XrpcError::invalid_request("missing or invalid repoDid parameter"))?; 1532 + let did = params.repo_did; 1492 1533 let owner = match state.index.owner_of(&did) { 1493 1534 Resolved::Ready(Some(owner)) => owner, 1494 1535 Resolved::Ready(None) => return Err(repo_not_found()), ··· 1507 1548 }, 1508 1549 state.max_response_bytes, 1509 1550 ) 1551 + } 1552 + 1553 + #[derive(Serialize)] 1554 + struct DefaultBranchWire { 1555 + #[serde(rename = "ref")] 1556 + name: String, 1557 + #[serde(skip_serializing_if = "Option::is_none")] 1558 + head: Option<String>, 1559 + } 1560 + 1561 + #[derive(Deserialize)] 1562 + pub(crate) struct ListRefsParams { 1563 + repo: RepoArg, 1564 + #[serde(default)] 1565 + limit: Limit<LIST_REFS_DEFAULT, LIST_REFS_MAX>, 1566 + #[serde(default)] 1567 + cursor: Offset, 1568 + } 1569 + 1570 + #[derive(Serialize)] 1571 + struct RefWire { 1572 + #[serde(rename = "ref")] 1573 + name: String, 1574 + sha: String, 1575 + } 1576 + 1577 + #[derive(Serialize)] 1578 + struct ListRefsOut { 1579 + refs: Vec<RefWire>, 1580 + #[serde(skip_serializing_if = "Option::is_none")] 1581 + cursor: Option<String>, 1582 + #[serde(rename = "defaultBranch", skip_serializing_if = "Option::is_none")] 1583 + default_branch: Option<DefaultBranchWire>, 1584 + } 1585 + 1586 + pub(crate) async fn git_list_refs<H: HttpTransport, C: Clock>( 1587 + State(state): State<Arc<XrpcState<H, C>>>, 1588 + ValidatedQuery(params): ValidatedQuery<ListRefsParams>, 1589 + ) -> Result<Response, XrpcError> { 1590 + let did = resolve_repo(&state, &params.repo)?; 1591 + let offset = params.cursor.get(); 1592 + let limit = params.limit.get(); 1593 + let layout = state.layout.clone(); 1594 + let cap = state.max_response_bytes; 1595 + run_blocking(move || { 1596 + let repo = open(&layout, &did)?; 1597 + let mut refs: Vec<_> = repo 1598 + .references() 1599 + .map_err(|error| XrpcError::internal(error.to_string()))? 1600 + .into_iter() 1601 + .filter(|record| is_public_ref(record.name.as_str())) 1602 + .collect(); 1603 + refs.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str())); 1604 + let total = refs.len(); 1605 + let window: Vec<RefWire> = refs 1606 + .iter() 1607 + .skip(offset) 1608 + .take(limit) 1609 + .map(|record| RefWire { 1610 + name: record.name.as_str().to_string(), 1611 + sha: record.target.to_hex(), 1612 + }) 1613 + .collect(); 1614 + let cursor = next_cursor(offset, limit, total); 1615 + let default_branch = repo.head().map(|head| DefaultBranchWire { 1616 + name: head.name.as_str().to_string(), 1617 + head: Some(head.target.to_hex()), 1618 + }); 1619 + json( 1620 + ListRefsOut { 1621 + refs: window, 1622 + cursor, 1623 + default_branch, 1624 + }, 1625 + cap, 1626 + ) 1627 + }) 1628 + .await 1629 + } 1630 + 1631 + #[derive(Deserialize)] 1632 + pub(crate) struct ListReposParams { 1633 + #[serde(default)] 1634 + limit: Limit<LIST_REPOS_DEFAULT, LIST_REPOS_MAX>, 1635 + #[serde(default)] 1636 + cursor: Offset, 1637 + #[serde(default)] 1638 + order: Order, 1639 + } 1640 + 1641 + #[derive(Serialize)] 1642 + struct RepoWire { 1643 + repo: String, 1644 + status: &'static str, 1645 + #[serde(rename = "defaultBranch", skip_serializing_if = "Option::is_none")] 1646 + default_branch: Option<DefaultBranchWire>, 1647 + } 1648 + 1649 + #[derive(Serialize)] 1650 + struct ListReposOut { 1651 + repos: Vec<RepoWire>, 1652 + #[serde(skip_serializing_if = "Option::is_none")] 1653 + cursor: Option<String>, 1654 + } 1655 + 1656 + pub(crate) async fn sync_list_repos<H: HttpTransport, C: Clock>( 1657 + State(state): State<Arc<XrpcState<H, C>>>, 1658 + ValidatedQuery(params): ValidatedQuery<ListReposParams>, 1659 + ) -> Result<Response, XrpcError> { 1660 + if matches!(state.index.coverage().registry, Coverage::Warming) { 1661 + return Err(warming()); 1662 + } 1663 + let offset = params.cursor.get(); 1664 + let limit = params.limit.get(); 1665 + let mut repos = state.index.hosted_repos(); 1666 + if params.order.descending() { 1667 + repos.reverse(); 1668 + } 1669 + let total = repos.len(); 1670 + let page: Vec<RepoDid> = repos.into_iter().skip(offset).take(limit).collect(); 1671 + let cursor = next_cursor(offset, limit, total); 1672 + let layout = state.layout.clone(); 1673 + let cap = state.max_response_bytes; 1674 + run_blocking(move || { 1675 + let repos: Vec<RepoWire> = 1676 + page.iter() 1677 + .map(|did| RepoWire { 1678 + repo: did.as_str().to_string(), 1679 + status: "active", 1680 + default_branch: open(&layout, did).ok().and_then(|repo| repo.head()).map( 1681 + |head| DefaultBranchWire { 1682 + name: head.name.as_str().to_string(), 1683 + head: Some(head.target.to_hex()), 1684 + }, 1685 + ), 1686 + }) 1687 + .collect(); 1688 + json(ListReposOut { repos, cursor }, cap) 1689 + }) 1690 + .await 1510 1691 } 1511 1692 1512 1693 #[cfg(test)]
+7 -10
crates/knot-xrpc/src/repos.rs
··· 19 19 use knot_runtime::{Clock, HttpTransport, Signer}; 20 20 use knot_types::{AccountDid, OwnerDid, RefName, RepoDid, RepoName, RepoRkey, UnixSeconds}; 21 21 22 + use crate::body::{BranchName, SourceUrl}; 22 23 use crate::cob::cob_error; 23 24 use crate::error::XrpcError; 24 25 use crate::reservations::ReserveDecision; 25 - use crate::{XrpcState, branch_ref, decode, map_atproto, map_identity, ok_empty, run_blocking}; 26 + use crate::{XrpcState, decode, map_atproto, map_identity, ok_empty, run_blocking}; 26 27 27 28 pub(crate) const CREATE_ROUTE: &str = "/xrpc/sh.tangled.repo.create"; 28 29 pub(crate) const DELETE_ROUTE: &str = "/xrpc/sh.tangled.repo.delete"; ··· 38 39 rkey: RepoRkey, 39 40 name: RepoName, 40 41 #[serde(rename = "defaultBranch")] 41 - default_branch: Option<String>, 42 - source: Option<String>, 42 + default_branch: Option<BranchName>, 43 + source: Option<SourceUrl>, 43 44 #[serde(rename = "repoDid")] 44 45 repo_did: Option<RepoDid>, 45 46 } ··· 174 175 let input: CreateInput = decode(&body)?; 175 176 let source = input 176 177 .source 177 - .as_deref() 178 + .as_ref() 179 + .map(|source| source.as_str()) 178 180 .filter(|source| !source.is_empty()) 179 181 .map(|raw| { 180 182 crate::forks::resolve_upstream(&state, raw).map(|upstream| (raw.to_string(), upstream)) 181 183 }) 182 184 .transpose()?; 183 - let head = input 184 - .default_branch 185 - .as_deref() 186 - .filter(|branch| !branch.is_empty()) 187 - .map(branch_ref) 188 - .transpose()?; 185 + let head = input.default_branch.map(|branch| branch.into_ref()); 189 186 190 187 let owner = OwnerDid::new(actor.as_str()).expect("account DID is always a valid owner DID"); 191 188 match state.index.resolve_repo(&owner, &input.rkey) {
+2 -2
crates/knot-xrpc/src/service.rs
··· 14 14 pub(crate) const OWNER_ROUTE: &str = "/xrpc/sh.tangled.owner"; 15 15 pub(crate) const HEALTH_ROUTE: &str = "/xrpc/_health"; 16 16 17 - const GO_PARITY_VERSION: &str = "v1.15.0"; 17 + const WIRE_VERSION: &str = "v1.15.0"; 18 18 19 19 #[derive(Serialize)] 20 20 struct VersionWire { ··· 41 41 42 42 pub(crate) async fn version() -> Response { 43 43 Json(VersionWire { 44 - version: format!("{GO_PARITY_VERSION} (knot2 {})", env!("CARGO_PKG_VERSION")), 44 + version: format!("{WIRE_VERSION} (knot2 {})", env!("CARGO_PKG_VERSION")), 45 45 capabilities: ["knot-acl"], 46 46 }) 47 47 .into_response()
+1 -1
crates/knot-xrpc/src/sniff.rs
··· 243 243 use super::detect_content_type; 244 244 245 245 #[test] 246 - fn detects_the_signatures_go_detects() { 246 + fn detects_common_content_signatures() { 247 247 assert_eq!( 248 248 detect_content_type(b" <!DOCTYPE html>\n"), 249 249 "text/html; charset=utf-8"
+1 -1
crates/knot-xrpc/src/tests.rs
··· 1851 1851 assert_eq!( 1852 1852 event.payload["oldSha"], 1853 1853 oid.to_string(), 1854 - "deletion carries the branch's old tip, richer than the Go knot's empty event" 1854 + "deletion carries the branch's old tip in the event" 1855 1855 ); 1856 1856 assert_eq!(event.payload["newSha"], "", "deletion has no new sha"); 1857 1857 assert_eq!(
+42 -43
crates/knot-xrpc/src/wire.rs
··· 50 50 } 51 51 52 52 #[derive(Serialize)] 53 - pub(crate) struct GoSignature { 53 + pub(crate) struct SignatureWire { 54 54 #[serde(rename = "Name")] 55 55 pub name: String, 56 56 #[serde(rename = "Email")] ··· 59 59 pub when: String, 60 60 } 61 61 62 - impl GoSignature { 62 + impl SignatureWire { 63 63 pub fn of(identity: &Identity) -> Self { 64 64 Self { 65 65 name: identity.name.clone(), ··· 86 86 } 87 87 88 88 #[derive(Serialize)] 89 - pub(crate) struct GoCommit { 89 + pub(crate) struct CommitWire { 90 90 pub hash: HashBytes, 91 - pub author: GoSignature, 92 - pub committer: GoSignature, 91 + pub author: SignatureWire, 92 + pub committer: SignatureWire, 93 93 pub message: String, 94 94 pub tree: String, 95 95 #[serde(skip_serializing_if = "Vec::is_empty")] ··· 107 107 pub parent: Option<String>, 108 108 } 109 109 110 - impl GoCommit { 110 + impl CommitWire { 111 111 pub fn of(commit: &Commit) -> Self { 112 112 let extra_headers: std::collections::BTreeMap<String, Base64Bytes> = commit 113 113 .extra_headers ··· 116 116 .collect(); 117 117 Self { 118 118 hash: HashBytes(commit.id), 119 - author: GoSignature::of(&commit.author), 120 - committer: GoSignature::of(&commit.committer), 119 + author: SignatureWire::of(&commit.author), 120 + committer: SignatureWire::of(&commit.committer), 121 121 message: commit.message.clone(), 122 122 tree: commit.tree.to_hex(), 123 123 parent_hashes: commit.parents.iter().map(|oid| HashBytes(*oid)).collect(), ··· 132 132 } 133 133 134 134 #[derive(Serialize)] 135 - pub(crate) struct GoGitCommit { 135 + pub(crate) struct BranchCommitWire { 136 136 #[serde(rename = "Hash")] 137 137 pub hash: HashBytes, 138 138 #[serde(rename = "Author")] 139 - pub author: GoSignature, 139 + pub author: SignatureWire, 140 140 #[serde(rename = "Committer")] 141 - pub committer: GoSignature, 141 + pub committer: SignatureWire, 142 142 #[serde(rename = "MergeTag")] 143 143 pub merge_tag: String, 144 144 #[serde(rename = "PGPSignature")] ··· 164 164 #[derive(Serialize)] 165 165 pub(crate) struct BranchWire { 166 166 pub reference: Reference, 167 - pub commit: GoGitCommit, 167 + pub commit: BranchCommitWire, 168 168 #[serde(skip_serializing_if = "std::ops::Not::not")] 169 169 pub is_default: bool, 170 170 } ··· 177 177 hash: branch.tip.id().to_hex(), 178 178 }, 179 179 commit: match &branch.tip { 180 - BranchTip::Commit(commit) => GoGitCommit { 180 + BranchTip::Commit(commit) => BranchCommitWire { 181 181 hash: HashBytes(commit.id), 182 - author: GoSignature::utc(&commit.author), 183 - committer: GoSignature::utc(&commit.committer), 182 + author: SignatureWire::utc(&commit.author), 183 + committer: SignatureWire::utc(&commit.committer), 184 184 merge_tag: String::new(), 185 185 pgp_signature: String::new(), 186 186 message: commit.message.trim_end().to_string(), ··· 192 192 encoding: String::new(), 193 193 extra_headers: None, 194 194 }, 195 - BranchTip::Opaque { id, message, .. } => GoGitCommit { 195 + BranchTip::Opaque { id, message, .. } => BranchCommitWire { 196 196 hash: HashBytes(*id), 197 - author: GoSignature::zero(), 198 - committer: GoSignature::zero(), 197 + author: SignatureWire::zero(), 198 + committer: SignatureWire::zero(), 199 199 merge_tag: String::new(), 200 200 pgp_signature: String::new(), 201 201 message: message.trim_end().to_string(), ··· 211 211 } 212 212 213 213 #[derive(Serialize)] 214 - pub(crate) struct GoTag { 214 + pub(crate) struct TagObjectWire { 215 215 #[serde(rename = "Hash")] 216 216 pub hash: HashBytes, 217 217 #[serde(rename = "Name")] 218 218 pub name: String, 219 219 #[serde(rename = "Tagger")] 220 - pub tagger: GoSignature, 220 + pub tagger: SignatureWire, 221 221 #[serde(rename = "Message")] 222 222 pub message: String, 223 223 #[serde(rename = "PGPSignature")] ··· 233 233 pub name: String, 234 234 pub hash: String, 235 235 #[serde(skip_serializing_if = "Option::is_none")] 236 - pub tag: Option<GoTag>, 236 + pub tag: Option<TagObjectWire>, 237 237 #[serde(skip_serializing_if = "String::is_empty")] 238 238 pub message: String, 239 239 } ··· 243 243 impl TagWire { 244 244 pub fn of(info: &TagInfo) -> Self { 245 245 let message = recombine_message(&info.message); 246 - let tag = info.annotated.as_ref().map(|annotated| GoTag { 247 - hash: HashBytes(info.id), 248 - name: info.name.clone(), 249 - tagger: annotated 250 - .tagger 251 - .as_ref() 252 - .map(GoSignature::utc) 253 - .unwrap_or(GoSignature { 254 - name: String::new(), 255 - email: String::new(), 256 - when: rfc3339(0, 0), 257 - }), 258 - message: message.clone(), 259 - pgp_signature: annotated.pgp_signature.clone().unwrap_or_default(), 260 - target_type: TARGET_TYPE_TAG, 261 - target: HashBytes(annotated.target), 262 - }); 246 + let tag = 247 + info.annotated.as_ref().map(|annotated| TagObjectWire { 248 + hash: HashBytes(info.id), 249 + name: info.name.clone(), 250 + tagger: annotated.tagger.as_ref().map(SignatureWire::utc).unwrap_or( 251 + SignatureWire { 252 + name: String::new(), 253 + email: String::new(), 254 + when: rfc3339(0, 0), 255 + }, 256 + ), 257 + message: message.clone(), 258 + pgp_signature: annotated.pgp_signature.clone().unwrap_or_default(), 259 + target_type: TARGET_TYPE_TAG, 260 + target: HashBytes(annotated.target), 261 + }); 263 262 Self { 264 263 name: info.name.clone(), 265 264 hash: info.id.to_hex(), ··· 416 415 417 416 #[derive(Serialize)] 418 417 pub(crate) struct NiceDiffWire { 419 - pub commit: GoCommit, 418 + pub commit: CommitWire, 420 419 pub stat: DiffStatWire, 421 420 pub diff: Option<Vec<DiffWire>>, 422 421 } ··· 437 436 files_changed: patches.len() as i64, 438 437 }; 439 438 NiceDiffWire { 440 - commit: GoCommit::of(commit), 439 + commit: CommitWire::of(commit), 441 440 stat, 442 441 diff: (!diffs.is_empty()).then_some(diffs), 443 442 } ··· 454 453 #[derive(Serialize)] 455 454 pub(crate) struct FormatPatchWire { 456 455 #[serde(rename = "Files")] 457 - pub files: Option<Vec<GoFile>>, 456 + pub files: Option<Vec<FileWire>>, 458 457 #[serde(rename = "SHA")] 459 458 pub sha: String, 460 459 #[serde(rename = "Author")] ··· 513 512 } 514 513 515 514 #[derive(Serialize)] 516 - pub(crate) struct GoFile { 515 + pub(crate) struct FileWire { 517 516 #[serde(rename = "OldName")] 518 517 pub old_name: String, 519 518 #[serde(rename = "NewName")] ··· 546 545 pub reverse_binary_fragment: Option<()>, 547 546 } 548 547 549 - impl GoFile { 548 + impl FileWire { 550 549 pub fn of(patch: &FilePatch) -> Self { 551 550 let fragments: Vec<TextFragmentWire> = 552 551 patch.hunks.iter().map(TextFragmentWire::of).collect();
+614 -12
crates/knot-xrpc/tests/reads.rs
··· 6 6 use std::sync::Arc; 7 7 use std::time::{Duration, Instant}; 8 8 9 + use std::sync::atomic::{AtomicU64, Ordering}; 10 + 9 11 use axum::Router; 10 12 use axum::body::{Body, Bytes}; 13 + use base64::Engine; 14 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 11 15 use futures::StreamExt; 12 16 use http::{HeaderMap, StatusCode, header}; 17 + use k256::ecdsa::signature::Signer; 18 + use k256::ecdsa::{Signature, SigningKey}; 13 19 use knot_events::{EventCursor, GitRefUpdate}; 14 20 use tokio_tungstenite::tungstenite; 15 21 use tower::ServiceExt; ··· 105 111 index.rebuild().unwrap(); 106 112 } 107 113 108 - let responder: Responder = Box::new(|_| { 114 + let responder: Responder = Box::new(|request| { 115 + let host = request.url.host_str().unwrap_or_default(); 116 + let did = match host { 117 + "plc.directory" => request.url.path().trim_start_matches('/').to_string(), 118 + host if request.url.path().ends_with("/.well-known/did.json") => { 119 + format!("did:web:{host}") 120 + } 121 + _ => String::new(), 122 + }; 123 + let body = match did.starts_with("did:") { 124 + true => did_doc_for(&did), 125 + false => Bytes::new(), 126 + }; 109 127 Ok(HttpResponse { 110 - status: StatusCode::NOT_FOUND, 128 + status: StatusCode::OK, 111 129 headers: http::HeaderMap::new(), 112 - body: Bytes::new(), 130 + body, 113 131 }) 114 132 }); 115 133 let atproto = Arc::new(Atproto::new( ··· 268 286 added_by: AccountDid::new(added_by).unwrap(), 269 287 created_at: UnixSeconds::new(at), 270 288 } 289 + } 290 + 291 + fn actor_key() -> SigningKey { 292 + SigningKey::from_bytes(&[9u8; 32].into()).unwrap() 293 + } 294 + 295 + fn did_doc_for(did: &str) -> Bytes { 296 + let sec1 = actor_key() 297 + .verifying_key() 298 + .to_encoded_point(true) 299 + .as_bytes() 300 + .to_vec(); 301 + let multikey = knot_types::crypto::multikey(0xe7, &sec1); 302 + let body = serde_json::json!({ 303 + "id": did, 304 + "alsoKnownAs": [], 305 + "verificationMethod": [{ 306 + "id": format!("{did}#atproto"), 307 + "type": "Multikey", 308 + "controller": did, 309 + "publicKeyMultibase": multikey 310 + }], 311 + "service": [{ 312 + "id": "#atproto_pds", 313 + "type": "AtprotoPersonalDataServer", 314 + "serviceEndpoint": "https://pds.oyster.cafe" 315 + }] 316 + }); 317 + Bytes::from(serde_json::to_vec(&body).unwrap()) 318 + } 319 + 320 + static JTI: AtomicU64 = AtomicU64::new(0); 321 + 322 + fn service_jwt(nsid: &str, actor: &str) -> String { 323 + let nonce = JTI.fetch_add(1, Ordering::SeqCst); 324 + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#); 325 + let claims = serde_json::json!({ 326 + "iss": actor, 327 + "aud": format!("did:web:{KNOT_HOST}"), 328 + "exp": 1_001, 329 + "iat": 999, 330 + "jti": format!("nonce-{nonce}"), 331 + "lxm": nsid, 332 + }); 333 + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); 334 + let signing_input = format!("{header}.{payload}"); 335 + let signature: Signature = actor_key().sign(signing_input.as_bytes()); 336 + format!( 337 + "{signing_input}.{}", 338 + URL_SAFE_NO_PAD.encode(signature.to_bytes()) 339 + ) 340 + } 341 + 342 + async fn post_authed( 343 + world: &World, 344 + path: &str, 345 + nsid: &str, 346 + actor: &str, 347 + value: serde_json::Value, 348 + ) -> (StatusCode, serde_json::Value) { 349 + let token = service_jwt(nsid, actor); 350 + let request = http::Request::builder() 351 + .method("POST") 352 + .uri(path) 353 + .header(header::CONTENT_TYPE, "application/json") 354 + .header(header::AUTHORIZATION, format!("Bearer {token}")) 355 + .body(Body::from(serde_json::to_vec(&value).unwrap())) 356 + .unwrap(); 357 + let response = world.router.clone().oneshot(request).await.unwrap(); 358 + let status = response.status(); 359 + let body = axum::body::to_bytes(response.into_body(), usize::MAX) 360 + .await 361 + .unwrap(); 362 + (status, serde_json::from_slice(&body).unwrap()) 271 363 } 272 364 273 365 fn git_run(cwd: &Path, when: &str, author: (&str, &str), args: &[&str]) -> String { ··· 521 613 let reported = src["last_commit"]["hash"].as_str().unwrap(); 522 614 assert_eq!( 523 615 reported, newer, 524 - "knot2 reports the newest commit touching the subtree, where Go reports oldest" 616 + "the newest commit that touches the subtree is reported, not the oldest" 525 617 ); 526 618 assert_ne!(reported, older, "not the first commit that created subtree"); 527 619 assert_ne!( ··· 621 713 } 622 714 623 715 #[tokio::test] 624 - async fn log_serializes_commits_in_the_go_wire_shape() { 716 + async fn log_serializes_commits_in_the_wire_shape() { 625 717 let world = World::new(); 626 718 let (did, work) = seeded(&world, "limpet"); 627 719 let head = sh_git(work.path(), &["rev-parse", "HEAD"]); ··· 652 744 .step_by(2) 653 745 .map(|index| u8::from_str_radix(&head[index..index + 2], 16).unwrap()) 654 746 .collect::<Vec<u8>>(), 655 - "commit hash rides as a byte array, exactly like go-git" 747 + "commit hash rides as a byte array" 656 748 ); 657 749 assert_eq!(first["this"], head.as_str()); 658 750 assert_eq!(first["parent"], parent.as_str()); ··· 715 807 ) 716 808 .await, 717 809 (StatusCode::NOT_FOUND, "BranchNotFound".to_string()) 810 + ); 811 + } 812 + 813 + #[tokio::test] 814 + async fn branches_page_is_oldest_first_within_the_window() { 815 + let world = World::new(); 816 + let (did, work) = seeded(&world, "kelp"); 817 + let bare = world.layout.repo_path(&did).unwrap(); 818 + sh_git(work.path(), &["checkout", "-q", "-b", "older", "main"]); 819 + commit_file( 820 + work.path(), 821 + "older.txt", 822 + b"older\n", 823 + "older tip", 824 + "2026-06-01T12:20:00+02:00", 825 + ); 826 + sh_git(work.path(), &["checkout", "-q", "-b", "newer", "main"]); 827 + commit_file( 828 + work.path(), 829 + "newer.txt", 830 + b"newer\n", 831 + "newer tip", 832 + "2026-06-01T12:50:00+02:00", 833 + ); 834 + sh_git( 835 + work.path(), 836 + &[ 837 + "push", 838 + "-q", 839 + bare.to_str().unwrap(), 840 + "older", 841 + "newer", 842 + "main", 843 + ], 844 + ); 845 + 846 + let value = get_json( 847 + &world, 848 + &format!("/xrpc/sh.tangled.repo.branches?repo={did}"), 849 + ) 850 + .await; 851 + let names: Vec<&str> = value["branches"] 852 + .as_array() 853 + .unwrap() 854 + .iter() 855 + .map(|branch| branch["reference"]["name"].as_str().unwrap()) 856 + .collect(); 857 + assert_eq!( 858 + names, 859 + vec!["older", "main", "newer"], 860 + "a page reads oldest-creatordate first: branches sort by creatordate descending, then the window is reversed" 718 861 ); 719 862 } 720 863 ··· 1378 1521 ); 1379 1522 } 1380 1523 1524 + fn ref_names(value: &serde_json::Value, key: &str) -> Vec<String> { 1525 + value[key] 1526 + .as_array() 1527 + .unwrap() 1528 + .iter() 1529 + .map(|entry| entry["ref"].as_str().unwrap().to_string()) 1530 + .collect() 1531 + } 1532 + 1533 + fn repo_dids(value: &serde_json::Value) -> Vec<String> { 1534 + value["repos"] 1535 + .as_array() 1536 + .unwrap() 1537 + .iter() 1538 + .map(|entry| entry["repo"].as_str().unwrap().to_string()) 1539 + .collect() 1540 + } 1541 + 1542 + #[tokio::test] 1543 + async fn list_refs_reports_public_refs_with_the_default_branch() { 1544 + let world = World::new(); 1545 + let (did, work) = seeded(&world, "whelk"); 1546 + let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 1547 + 1548 + let value = get_json(&world, &format!("/xrpc/sh.tangled.git.listRefs?repo={did}")).await; 1549 + 1550 + assert_eq!( 1551 + ref_names(&value, "refs"), 1552 + vec![ 1553 + "refs/heads/main", 1554 + "refs/tags/lightweight", 1555 + "refs/tags/v1.0.0" 1556 + ] 1557 + ); 1558 + let main = value["refs"] 1559 + .as_array() 1560 + .unwrap() 1561 + .iter() 1562 + .find(|entry| entry["ref"] == "refs/heads/main") 1563 + .unwrap(); 1564 + assert_eq!(main["sha"], head); 1565 + assert_eq!(value["defaultBranch"]["ref"], "refs/heads/main"); 1566 + assert_eq!(value["defaultBranch"]["head"], head); 1567 + assert!(value["cursor"].is_null()); 1568 + } 1569 + 1570 + #[tokio::test] 1571 + async fn list_refs_hides_reserved_cob_refs() { 1572 + let world = World::new(); 1573 + let (did, work) = seeded(&world, "periwinkle"); 1574 + let head = sh_git(work.path(), &["rev-parse", "HEAD"]); 1575 + let bare = world.layout.repo_path(&did).unwrap(); 1576 + sh_git( 1577 + bare.as_path(), 1578 + &[ 1579 + "update-ref", 1580 + "refs/cobs/sh.tangled.repo.collaborator/x", 1581 + &head, 1582 + ], 1583 + ); 1584 + 1585 + let value = get_json(&world, &format!("/xrpc/sh.tangled.git.listRefs?repo={did}")).await; 1586 + assert!( 1587 + ref_names(&value, "refs") 1588 + .iter() 1589 + .all(|name| !name.starts_with("refs/cobs/")), 1590 + "reserved cob ref leaked into listRefs" 1591 + ); 1592 + } 1593 + 1594 + #[tokio::test] 1595 + async fn list_refs_paginates_with_a_cursor() { 1596 + let world = World::new(); 1597 + let (did, _work) = seeded(&world, "conch"); 1598 + 1599 + let first = get_json( 1600 + &world, 1601 + &format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=2"), 1602 + ) 1603 + .await; 1604 + assert_eq!(ref_names(&first, "refs").len(), 2); 1605 + let cursor = first["cursor"].as_str().unwrap().to_string(); 1606 + 1607 + let second = get_json( 1608 + &world, 1609 + &format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=2&cursor={cursor}"), 1610 + ) 1611 + .await; 1612 + assert_eq!(ref_names(&second, "refs").len(), 1); 1613 + assert!(second["cursor"].is_null()); 1614 + } 1615 + 1616 + #[tokio::test] 1617 + async fn a_cursor_at_the_usize_ceiling_drains_cleanly_without_overflow() { 1618 + let world = World::new(); 1619 + let (did, _work) = seeded(&world, "barnacle"); 1620 + 1621 + let refs = get_json( 1622 + &world, 1623 + &format!( 1624 + "/xrpc/sh.tangled.git.listRefs?repo={did}&cursor={}", 1625 + usize::MAX 1626 + ), 1627 + ) 1628 + .await; 1629 + assert!(ref_names(&refs, "refs").is_empty()); 1630 + assert!(refs["cursor"].is_null()); 1631 + 1632 + let repos = get_json( 1633 + &world, 1634 + &format!("/xrpc/sh.tangled.sync.listRepos?cursor={}", usize::MAX), 1635 + ) 1636 + .await; 1637 + assert!(repo_dids(&repos).is_empty()); 1638 + assert!(repos["cursor"].is_null()); 1639 + } 1640 + 1641 + #[tokio::test] 1642 + async fn the_list_reads_reject_malformed_paging_params() { 1643 + let world = World::new(); 1644 + let (did, _work) = seeded(&world, "barnacle"); 1645 + 1646 + for query in [ 1647 + format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=abc"), 1648 + format!("/xrpc/sh.tangled.git.listRefs?repo={did}&cursor=notanint"), 1649 + "/xrpc/sh.tangled.sync.listRepos?limit=abc".to_string(), 1650 + "/xrpc/sh.tangled.sync.listRepos?cursor=notanint".to_string(), 1651 + "/xrpc/sh.tangled.sync.listRepos?order=sideways".to_string(), 1652 + ] { 1653 + let (status, error) = get_error(&world, &query).await; 1654 + assert_eq!(status, StatusCode::BAD_REQUEST, "query {query}"); 1655 + assert_eq!(error, "InvalidRequest", "query {query}"); 1656 + } 1657 + 1658 + let clamped = get_json( 1659 + &world, 1660 + &format!("/xrpc/sh.tangled.git.listRefs?repo={did}&limit=5000"), 1661 + ) 1662 + .await; 1663 + assert_eq!( 1664 + ref_names(&clamped, "refs").len(), 1665 + 3, 1666 + "an oversize limit clamps to the max instead of erroring" 1667 + ); 1668 + } 1669 + 1670 + #[tokio::test] 1671 + async fn list_repos_lists_hosted_repos_with_order_and_pagination() { 1672 + let world = World::new(); 1673 + let (mussel, _a) = seeded(&world, "mussel"); 1674 + let (nautilus, _b) = seeded(&world, "nautilus"); 1675 + let (scallop, _c) = seeded(&world, "scallop"); 1676 + 1677 + let desc = get_json(&world, "/xrpc/sh.tangled.sync.listRepos").await; 1678 + assert_eq!( 1679 + repo_dids(&desc), 1680 + vec![ 1681 + scallop.as_str().to_string(), 1682 + nautilus.as_str().to_string(), 1683 + mussel.as_str().to_string(), 1684 + ] 1685 + ); 1686 + assert_eq!(desc["repos"][0]["status"], "active"); 1687 + assert_eq!(desc["repos"][0]["defaultBranch"]["ref"], "refs/heads/main"); 1688 + 1689 + let asc = get_json(&world, "/xrpc/sh.tangled.sync.listRepos?order=asc").await; 1690 + assert_eq!( 1691 + repo_dids(&asc), 1692 + vec![ 1693 + mussel.as_str().to_string(), 1694 + nautilus.as_str().to_string(), 1695 + scallop.as_str().to_string(), 1696 + ] 1697 + ); 1698 + 1699 + let page = get_json(&world, "/xrpc/sh.tangled.sync.listRepos?order=asc&limit=2").await; 1700 + assert_eq!(page["repos"].as_array().unwrap().len(), 2); 1701 + let cursor = page["cursor"].as_str().unwrap().to_string(); 1702 + let rest = get_json( 1703 + &world, 1704 + &format!("/xrpc/sh.tangled.sync.listRepos?order=asc&limit=2&cursor={cursor}"), 1705 + ) 1706 + .await; 1707 + assert_eq!(repo_dids(&rest), vec![scallop.as_str().to_string()]); 1708 + assert!(rest["cursor"].is_null()); 1709 + } 1710 + 1711 + #[tokio::test] 1712 + async fn list_repos_fails_closed_while_the_registry_is_warming() { 1713 + let world = World::warming(); 1714 + let (status, error) = get_error(&world, "/xrpc/sh.tangled.sync.listRepos").await; 1715 + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); 1716 + assert_eq!(error, "ProjectionWarming"); 1717 + } 1718 + 1381 1719 #[tokio::test] 1382 1720 async fn the_repo_param_resolves_by_did_or_owner_and_rkey_and_fails_closed() { 1383 1721 let world = World::new(); ··· 1500 1838 } 1501 1839 1502 1840 #[tokio::test] 1503 - async fn a_merge_tip_branch_reports_the_go_zero_parent_hash() { 1841 + async fn a_merge_tip_branch_reports_a_zero_parent_hash() { 1504 1842 let world = World::new(); 1505 1843 let (did, work) = seeded(&world, "trochus"); 1506 1844 let bare = world ··· 1544 1882 .unwrap() 1545 1883 .iter() 1546 1884 .all(|byte| byte.as_u64() == Some(0)), 1547 - "merge tip must carry the zero parent hash, exactly like the Go knot" 1885 + "merge tip must carry the zero parent hash" 1548 1886 ); 1549 1887 } 1550 1888 ··· 1577 1915 .unwrap() 1578 1916 .iter() 1579 1917 .all(|byte| byte.as_u64() == Some(0)), 1580 - "opaque tip carries the zero tree hash, like the Go for-each-ref output" 1918 + "opaque tip carries the zero tree hash" 1581 1919 ); 1582 1920 let main = branches 1583 1921 .iter() ··· 1992 2330 } 1993 2331 1994 2332 #[tokio::test] 1995 - async fn list_members_pages_in_the_go_wire_shape() { 2333 + async fn list_members_pages_in_the_wire_shape() { 1996 2334 let world = World::new(); 1997 2335 world.add_member("did:plc:limpet", OWNER, 1_000); 1998 2336 world.add_member("did:plc:scallop", OWNER, 2_000); ··· 2094 2432 .await; 2095 2433 assert!( 2096 2434 unknown["items"].as_array().unwrap().is_empty(), 2097 - "unhosted repo has no collaborators, matching the Go knot's empty answer" 2435 + "unhosted repo has no collaborators, the answer is an empty list" 2098 2436 ); 2099 2437 2100 2438 let (status, _) = get_error( ··· 2166 2504 } 2167 2505 2168 2506 #[tokio::test] 2169 - async fn version_reports_the_go_parity_level_and_the_acl_capability() { 2507 + async fn list_collaborators_repo_error_outranks_paging_whatever_the_param_order() { 2508 + let world = World::new(); 2509 + let (_, error) = get_error( 2510 + &world, 2511 + "/xrpc/sh.tangled.repo.listCollaborators?limit=abc&subject=notadid", 2512 + ) 2513 + .await; 2514 + assert_eq!( 2515 + error, "InvalidRepo", 2516 + "the subject extractor runs before paging by signature position, not query order" 2517 + ); 2518 + } 2519 + 2520 + #[tokio::test] 2521 + async fn a_missing_required_param_is_refused_through_the_xrpc_error_envelope() { 2522 + let world = World::new(); 2523 + let (status, error) = get_error(&world, "/xrpc/sh.tangled.repo.getDefaultBranch").await; 2524 + assert_eq!(status, StatusCode::BAD_REQUEST); 2525 + assert_eq!( 2526 + error, "InvalidRequest", 2527 + "a structurally unsound query carries the lexicon error shape, not the runtime's default plaintext" 2528 + ); 2529 + } 2530 + 2531 + #[tokio::test] 2532 + async fn version_reports_the_wire_version_and_the_acl_capability() { 2170 2533 let world = World::new(); 2171 2534 let wire = get_json(&world, "/xrpc/sh.tangled.knot.version").await; 2172 2535 assert_eq!( ··· 2312 2675 } 2313 2676 refused(tokio_tungstenite::connect_async(format!("ws://{addr}/events")).await); 2314 2677 drop(held); 2678 + } 2679 + 2680 + #[tokio::test] 2681 + async fn set_default_branch_resolves_an_at_uri_repo_and_an_existing_branch() { 2682 + let world = World::new(); 2683 + let (did, work) = seeded(&world, "coral"); 2684 + let bare = world.layout.repo_path(&did).unwrap(); 2685 + sh_git(work.path(), &["branch", "release", "main"]); 2686 + sh_git( 2687 + work.path(), 2688 + &["push", "-q", bare.to_str().unwrap(), "refs/heads/release"], 2689 + ); 2690 + 2691 + let (status, _) = post_authed( 2692 + &world, 2693 + "/xrpc/sh.tangled.repo.setDefaultBranch", 2694 + "sh.tangled.repo.setDefaultBranch", 2695 + OWNER, 2696 + serde_json::json!({ 2697 + "repo": format!("at://{OWNER}/sh.tangled.repo/coral"), 2698 + "defaultBranch": "release", 2699 + }), 2700 + ) 2701 + .await; 2702 + assert_eq!(status, StatusCode::OK); 2703 + 2704 + let repo = world.layout.open(&did).unwrap(); 2705 + assert_eq!( 2706 + repo.default_branch().unwrap().as_str(), 2707 + "refs/heads/release", 2708 + "the default head moved to the requested branch" 2709 + ); 2710 + } 2711 + 2712 + #[tokio::test] 2713 + async fn set_default_branch_refuses_a_repo_that_is_not_an_at_uri() { 2714 + let world = World::new(); 2715 + let (_did, _work) = seeded(&world, "coral"); 2716 + let (status, body) = post_authed( 2717 + &world, 2718 + "/xrpc/sh.tangled.repo.setDefaultBranch", 2719 + "sh.tangled.repo.setDefaultBranch", 2720 + OWNER, 2721 + serde_json::json!({ "repo": "not-an-at-uri", "defaultBranch": "main" }), 2722 + ) 2723 + .await; 2724 + assert_eq!(status, StatusCode::BAD_REQUEST); 2725 + assert_eq!(body["error"], "InvalidRequest"); 2726 + } 2727 + 2728 + #[tokio::test] 2729 + async fn delete_branch_removes_a_non_default_branch_then_reports_it_gone() { 2730 + let world = World::new(); 2731 + let (did, work) = seeded(&world, "kelp"); 2732 + let bare = world.layout.repo_path(&did).unwrap(); 2733 + sh_git(work.path(), &["branch", "feature", "main"]); 2734 + sh_git( 2735 + work.path(), 2736 + &["push", "-q", bare.to_str().unwrap(), "refs/heads/feature"], 2737 + ); 2738 + 2739 + let at = format!("at://{OWNER}/sh.tangled.repo/kelp"); 2740 + let (status, _) = post_authed( 2741 + &world, 2742 + "/xrpc/sh.tangled.repo.deleteBranch", 2743 + "sh.tangled.repo.deleteBranch", 2744 + OWNER, 2745 + serde_json::json!({ "repo": at, "branch": "feature" }), 2746 + ) 2747 + .await; 2748 + assert_eq!(status, StatusCode::OK); 2749 + 2750 + let (status, body) = post_authed( 2751 + &world, 2752 + "/xrpc/sh.tangled.repo.deleteBranch", 2753 + "sh.tangled.repo.deleteBranch", 2754 + OWNER, 2755 + serde_json::json!({ "repo": at, "branch": "feature" }), 2756 + ) 2757 + .await; 2758 + assert_eq!(status, StatusCode::NOT_FOUND, "second delete: {body}"); 2759 + } 2760 + 2761 + #[tokio::test] 2762 + async fn delete_branch_refuses_a_malformed_branch_name() { 2763 + let world = World::new(); 2764 + let (_did, _work) = seeded(&world, "kelp"); 2765 + let (status, body) = post_authed( 2766 + &world, 2767 + "/xrpc/sh.tangled.repo.deleteBranch", 2768 + "sh.tangled.repo.deleteBranch", 2769 + OWNER, 2770 + serde_json::json!({ 2771 + "repo": format!("at://{OWNER}/sh.tangled.repo/kelp"), 2772 + "branch": "bad branch", 2773 + }), 2774 + ) 2775 + .await; 2776 + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); 2777 + assert_eq!(body["error"], "InvalidRequest"); 2778 + } 2779 + 2780 + #[tokio::test] 2781 + async fn merge_applies_a_plain_patch_under_the_supplied_author() { 2782 + let world = World::new(); 2783 + let did = RepoDid::new("did:plc:musselfixture").unwrap(); 2784 + world.layout.create(&did).unwrap(); 2785 + world.register(&did, "mussel"); 2786 + let bare = world.layout.repo_path(&did).unwrap(); 2787 + let work_dir = tempfile::tempdir().unwrap(); 2788 + let work = work_dir.path(); 2789 + sh_git(work, &["init", "-q", "-b", "main"]); 2790 + commit_file( 2791 + work, 2792 + "reef.txt", 2793 + b"one\ntwo\n", 2794 + "base", 2795 + "2026-06-01T12:30:00+02:00", 2796 + ); 2797 + sh_git(work, &["checkout", "-q", "-b", "feature"]); 2798 + commit_file( 2799 + work, 2800 + "reef.txt", 2801 + b"one\nTWO\n", 2802 + "cap", 2803 + "2026-06-01T12:31:00+02:00", 2804 + ); 2805 + commit_file( 2806 + work, 2807 + "kelp.txt", 2808 + b"frond\n", 2809 + "add kelp", 2810 + "2026-06-01T12:32:00+02:00", 2811 + ); 2812 + sh_git( 2813 + work, 2814 + &["push", "-q", bare.to_str().unwrap(), "main", "feature"], 2815 + ); 2816 + let main_sha = sh_git(work, &["rev-parse", "main"]); 2817 + let feature_sha = sh_git(work, &["rev-parse", "feature"]); 2818 + let compared = get_json( 2819 + &world, 2820 + &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1={main_sha}&rev2={feature_sha}"), 2821 + ) 2822 + .await; 2823 + let patch = compared["combined_patch_raw"].as_str().unwrap().to_string(); 2824 + 2825 + let (status, body) = post_authed( 2826 + &world, 2827 + "/xrpc/sh.tangled.repo.merge", 2828 + "sh.tangled.repo.merge", 2829 + OWNER, 2830 + serde_json::json!({ 2831 + "did": OWNER, 2832 + "name": "mussel", 2833 + "branch": "main", 2834 + "patch": patch, 2835 + "authorName": "Teq", 2836 + "authorEmail": "teq@nel.pet", 2837 + "commitMessage": "merged kelp", 2838 + }), 2839 + ) 2840 + .await; 2841 + assert_eq!(status, StatusCode::OK, "merge failed: {body}"); 2842 + 2843 + let log = get_json( 2844 + &world, 2845 + &format!("/xrpc/sh.tangled.repo.log?repo={did}&ref=main"), 2846 + ) 2847 + .await; 2848 + let top = &log["commits"][0]; 2849 + assert_eq!( 2850 + top["author"]["Name"], "Teq", 2851 + "the supplied author rode through" 2852 + ); 2853 + assert!( 2854 + top["message"].as_str().unwrap().contains("merged kelp"), 2855 + "the supplied commit message rode through: {}", 2856 + top["message"] 2857 + ); 2858 + } 2859 + 2860 + #[tokio::test] 2861 + async fn create_mints_a_did_plc_repo_with_the_requested_default_branch() { 2862 + let world = World::new(); 2863 + world.add_member(OWNER, OWNER, 1_000); 2864 + let (status, body) = post_authed( 2865 + &world, 2866 + "/xrpc/sh.tangled.repo.create", 2867 + "sh.tangled.repo.create", 2868 + OWNER, 2869 + serde_json::json!({ "rkey": "squidkey", "name": "squid", "defaultBranch": "trunk" }), 2870 + ) 2871 + .await; 2872 + assert_eq!(status, StatusCode::OK, "create failed: {body}"); 2873 + let repo_did = body["repoDid"].as_str().unwrap(); 2874 + assert!( 2875 + repo_did.starts_with("did:plc:"), 2876 + "minted a did:plc: {repo_did}" 2877 + ); 2878 + let did = RepoDid::new(repo_did).unwrap(); 2879 + let repo = world.layout.open(&did).unwrap(); 2880 + assert_eq!( 2881 + repo.default_branch().unwrap().as_str(), 2882 + "refs/heads/trunk", 2883 + "the requested default branch became HEAD" 2884 + ); 2885 + } 2886 + 2887 + #[tokio::test] 2888 + async fn fork_sync_refuses_a_malformed_branch_name() { 2889 + let world = World::new(); 2890 + let (_did, _work) = seeded(&world, "barnacle"); 2891 + let (status, body) = post_authed( 2892 + &world, 2893 + "/xrpc/sh.tangled.repo.forkSync", 2894 + "sh.tangled.repo.forkSync", 2895 + OWNER, 2896 + serde_json::json!({ "did": OWNER, "name": "barnacle", "branch": "bad branch" }), 2897 + ) 2898 + .await; 2899 + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); 2900 + assert_eq!(body["error"], "InvalidRequest"); 2901 + } 2902 + 2903 + #[tokio::test] 2904 + async fn hidden_ref_refuses_a_repo_that_is_not_an_at_uri() { 2905 + let world = World::new(); 2906 + let (_did, _work) = seeded(&world, "conch"); 2907 + let (status, body) = post_authed( 2908 + &world, 2909 + "/xrpc/sh.tangled.repo.hiddenRef", 2910 + "sh.tangled.repo.hiddenRef", 2911 + OWNER, 2912 + serde_json::json!({ "repo": "nope", "forkRef": "feature", "remoteRef": "main" }), 2913 + ) 2914 + .await; 2915 + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); 2916 + assert_eq!(body["error"], "InvalidRequest"); 2315 2917 }