This repository has no description
0

Configure Feed

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

core / knot2 / crates / knot-xrpc / src / lib.rs
21 kB 663 lines
1mod blocklist; 2mod body; 3mod branches; 4mod cob; 5mod collaborators; 6mod error; 7mod events; 8mod forks; 9pub mod legacy_admin; 10mod lfs; 11mod lists; 12mod locks; 13mod members; 14mod merge; 15mod patchtext; 16mod query; 17mod reads; 18mod receive; 19mod repos; 20mod reservations; 21mod service; 22mod sniff; 23mod wire; 24 25#[cfg(test)] 26mod tests; 27 28pub use error::XrpcError; 29pub use knot_pack::MaxWireBytes; 30pub use knot_postreceive::LanguagesPushBudget; 31pub use knot_resource::{ 32 Burst, GlobalInflight, LimitConfig, PerPeerInflight, PreAuthLimiter, RateLimit, RefillMicros, 33}; 34pub use lfs::LfsWeb; 35pub use locks::CobLocks; 36pub use merge::Committer; 37pub use receive::advertiser as receive_advertiser; 38pub use reservations::{GlobalQuota, PerActorQuota, ReservationTtl, Reservations}; 39 40use std::collections::BTreeSet; 41use std::net::IpAddr; 42use std::path::PathBuf; 43use std::sync::Arc; 44use std::time::{Duration, Instant}; 45 46use axum::Json; 47use axum::Router; 48use axum::body::Bytes; 49use axum::extract::{DefaultBodyLimit, FromRequestParts, MatchedPath, Request, State}; 50use axum::middleware::{Next, from_fn_with_state}; 51use axum::response::{IntoResponse, Response}; 52use axum::routing::{get, post}; 53use http::request::Parts; 54use http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION}; 55use serde::de::DeserializeOwned; 56use serde_json::json; 57 58use knot_atproto::{Atproto, AtprotoError, ServiceJwt}; 59use knot_events::{EventLog, SubscriberGate}; 60use knot_git::Layout; 61use knot_index::{Index, Resolved}; 62use knot_maintenance::MaintenanceHandle; 63use knot_resource::Slots; 64use knot_runtime::{Clock, Entropy, HttpTransport}; 65use knot_secrets::SealedStore; 66use knot_types::{ 67 AccountDid, AdmissionPolicy, AppviewEndpoint, CiLogsAddr, KnotHostname, KnotId, KnotServiceUrl, 68 Nsid, OwnerDid, OwnerRef, RepoDid, RepoRkey, UnixSeconds, 69}; 70 71use base64::Engine; 72use knot_pack::SocketPeer; 73use knot_resource::{AdmitGuard, Refusal}; 74 75pub(crate) const PUSH_NSID: &str = "sh.tangled.repo.push"; 76 77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 78pub enum ReadBudget { 79 Within(Duration), 80 Unbounded, 81} 82 83impl ReadBudget { 84 pub fn deadline(self) -> Option<Instant> { 85 match self { 86 ReadBudget::Within(budget) => Some(Instant::now() + budget), 87 ReadBudget::Unbounded => None, 88 } 89 } 90} 91 92// `XrpcState` keeps a bunch of these side by side, 93// some usize & some u64. 94// Within each group every one of them typechecked in every other one's slot. 95knot_types::scalar_newtype! { 96 pub struct BodyLimit(usize); 97 pub struct PatchLimit(usize); 98 pub struct PatchDecompressedLimit(u64); 99 pub struct ResponseLimit(usize); 100 pub struct ArchiveLimit(u64); 101 pub struct ForkPackLimit(u64); 102 pub struct TreeReadBudget(ReadBudget); 103 pub struct BlobReadBudget(ReadBudget); 104 pub struct LanguagesReadBudget(ReadBudget); 105} 106 107#[derive(Debug, Clone, Copy, PartialEq, Eq)] 108pub struct ByteLimits { 109 pub body: BodyLimit, 110 pub patch: PatchLimit, 111 pub patch_decompressed: PatchDecompressedLimit, 112 pub response: ResponseLimit, 113 pub archive: ArchiveLimit, 114 pub fork_pack: ForkPackLimit, 115 pub pack: MaxWireBytes, 116} 117 118impl Default for ByteLimits { 119 fn default() -> Self { 120 Self { 121 body: BodyLimit::new(64 * 1024), 122 patch: PatchLimit::new(16 * 1024 * 1024), 123 patch_decompressed: PatchDecompressedLimit::new(128 * 1024 * 1024), 124 response: ResponseLimit::new(5 * 1024 * 1024), 125 archive: ArchiveLimit::new(1024 * 1024 * 1024), 126 fork_pack: ForkPackLimit::new(1024 * 1024 * 1024), 127 pack: MaxWireBytes::new(8 * 1024 * 1024 * 1024), 128 } 129 } 130} 131 132#[derive(Debug, Clone, Copy, PartialEq, Eq)] 133pub struct Budgets { 134 pub tree_last_commit: TreeReadBudget, 135 pub blob_last_commit: BlobReadBudget, 136 pub languages: LanguagesReadBudget, 137 pub languages_push: LanguagesPushBudget, 138} 139 140impl Default for Budgets { 141 fn default() -> Self { 142 Self { 143 tree_last_commit: TreeReadBudget::new(ReadBudget::Within(Duration::from_millis(300))), 144 blob_last_commit: BlobReadBudget::new(ReadBudget::Within(Duration::from_millis(2_000))), 145 languages: LanguagesReadBudget::new(ReadBudget::Within(Duration::from_millis(1_000))), 146 languages_push: LanguagesPushBudget::new(Duration::from_millis(2_000)), 147 } 148 } 149} 150 151pub struct XrpcState<H, C> { 152 pub layout: Layout, 153 pub index: Arc<Index>, 154 pub atproto: Arc<Atproto<H, C>>, 155 pub secrets: Arc<SealedStore>, 156 pub entropy: Arc<dyn Entropy>, 157 pub admins: BTreeSet<AccountDid>, 158 pub admission: AdmissionPolicy, 159 pub knot_did: KnotId, 160 pub knot_hostname: KnotHostname, 161 pub ci_logs: Option<CiLogsAddr>, 162 pub meta_path: PathBuf, 163 pub knot_service_url: KnotServiceUrl, 164 pub limiter: Arc<PreAuthLimiter>, 165 pub cob_locks: Arc<CobLocks>, 166 pub reservations: Arc<Reservations>, 167 pub trusted_proxy_header: Option<http::HeaderName>, 168 pub committer: Committer, 169 pub byte_limits: ByteLimits, 170 pub budgets: Budgets, 171 pub git_http: Arc<dyn HttpTransport>, 172 pub pack_limits: knot_pack::PackLimits, 173 pub service_owner: AccountDid, 174 pub events: Arc<EventLog<C>>, 175 pub subscriber_gate: Arc<SubscriberGate>, 176 pub maintenance: MaintenanceHandle, 177 pub appview: AppviewEndpoint, 178 pub slots: Slots, 179 pub lfs: Option<LfsWeb>, 180 pub catalog: Arc<knot_messages::Catalog>, 181} 182 183impl<H: HttpTransport, C: Clock> XrpcState<H, C> { 184 pub fn now(&self) -> UnixSeconds { 185 UnixSeconds::new((self.atproto.now().get() / 1_000_000) as i64) 186 } 187 188 pub(crate) fn knot_authority(&self) -> &str { 189 self.knot_service_url.authority() 190 } 191 192 pub(crate) async fn authenticate( 193 &self, 194 headers: &HeaderMap, 195 method: &Method, 196 ) -> Result<AccountDid, XrpcError> { 197 let token = bearer(headers)?; 198 self.atproto 199 .verify_service_jwt(&token, method.nsid()) 200 .await 201 .map_err(map_verify_error) 202 } 203 204 pub(crate) async fn authenticate_push( 205 &self, 206 headers: &HeaderMap, 207 ) -> Result<AccountDid, XrpcError> { 208 let token = push_credential(headers)?; 209 let method = Nsid::new_owned(PUSH_NSID).expect("push nsid is always a valid nsid"); 210 self.atproto 211 .verify_service_jwt_guarded( 212 &token, 213 &method, 214 knot_atproto::ReplayGuard::ReusableUntilExpiry, 215 ) 216 .await 217 .map_err(map_verify_error) 218 } 219} 220 221fn map_verify_error(error: AtprotoError) -> XrpcError { 222 if error.is_transient() { 223 XrpcError::upstream_unavailable(error.to_string()) 224 } else { 225 XrpcError::auth_required(error.to_string()) 226 } 227} 228 229pub(crate) struct Method(Nsid); 230 231impl Method { 232 fn nsid(&self) -> &Nsid { 233 &self.0 234 } 235 236 #[cfg(test)] 237 pub(crate) fn from_nsid(nsid: &str) -> Self { 238 Self(Nsid::new_owned(nsid).expect("test route nsid parses")) 239 } 240} 241 242impl<S: Send + Sync> FromRequestParts<S> for Method { 243 type Rejection = XrpcError; 244 245 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 246 let matched = MatchedPath::from_request_parts(parts, state) 247 .await 248 .map_err(|_| XrpcError::internal("xrpc handler reached without a matched route"))?; 249 let nsid = matched 250 .as_str() 251 .strip_prefix("/xrpc/") 252 .ok_or_else(|| XrpcError::internal("xrpc route paths are prefixed with /xrpc/"))?; 253 Nsid::new_owned(nsid) 254 .map(Self) 255 .map_err(|_| XrpcError::internal("route nsid is always a valid nsid")) 256 } 257} 258 259pub fn router<H: HttpTransport, C: Clock>(state: Arc<XrpcState<H, C>>) -> Router { 260 let merge_routes = Router::new() 261 .route(merge::MERGE_ROUTE, post(merge::merge::<H, C>)) 262 .route(merge::MERGE_CHECK_ROUTE, post(merge::merge_check::<H, C>)) 263 .layer(DefaultBodyLimit::max(state.byte_limits.patch.get())); 264 Router::new() 265 .merge(merge_routes) 266 .route(members::ADD_ROUTE, post(members::add_member::<H, C>)) 267 .route(members::REMOVE_ROUTE, post(members::remove_member::<H, C>)) 268 .route(blocklist::BAN_ROUTE, post(blocklist::ban::<H, C>)) 269 .route(blocklist::UNBAN_ROUTE, post(blocklist::unban::<H, C>)) 270 .route( 271 collaborators::ADD_ROUTE, 272 post(collaborators::add_collaborator::<H, C>), 273 ) 274 .route( 275 collaborators::REMOVE_ROUTE, 276 post(collaborators::remove_collaborator::<H, C>), 277 ) 278 .route(repos::CREATE_ROUTE, post(repos::create_repo::<H, C>)) 279 .route(repos::DELETE_ROUTE, post(repos::delete_repo::<H, C>)) 280 .route(repos::RENAME_ROUTE, post(repos::rename_repo::<H, C>)) 281 .route(repos::RESERVE_ROUTE, post(repos::reserve_key::<H, C>)) 282 .route( 283 branches::SET_DEFAULT_ROUTE, 284 post(branches::set_default_branch::<H, C>), 285 ) 286 .route( 287 branches::DELETE_ROUTE, 288 post(branches::delete_branch::<H, C>), 289 ) 290 .route(forks::STATUS_ROUTE, post(forks::fork_status::<H, C>)) 291 .route(forks::SYNC_ROUTE, post(forks::fork_sync::<H, C>)) 292 .route(forks::HIDDEN_REF_ROUTE, post(forks::hidden_ref::<H, C>)) 293 .route(reads::TREE_ROUTE, get(reads::repo_tree::<H, C>)) 294 .route(reads::LOG_ROUTE, get(reads::repo_log::<H, C>)) 295 .route(reads::BRANCHES_ROUTE, get(reads::repo_branches::<H, C>)) 296 .route(reads::BRANCH_ROUTE, get(reads::repo_branch::<H, C>)) 297 .route(reads::TAGS_ROUTE, get(reads::repo_tags::<H, C>)) 298 .route(reads::TAG_ROUTE, get(reads::repo_tag::<H, C>)) 299 .route(reads::BLOB_ROUTE, get(reads::repo_blob::<H, C>)) 300 .route(reads::DIFF_ROUTE, get(reads::repo_diff::<H, C>)) 301 .route(reads::COMPARE_ROUTE, get(reads::repo_compare::<H, C>)) 302 .route(reads::ARCHIVE_ROUTE, get(reads::repo_archive::<H, C>)) 303 .route(reads::LANGUAGES_ROUTE, get(reads::repo_languages::<H, C>)) 304 .route( 305 reads::GET_DEFAULT_BRANCH_ROUTE, 306 get(reads::repo_get_default_branch::<H, C>), 307 ) 308 .route( 309 reads::DESCRIBE_REPO_ROUTE, 310 get(reads::repo_describe_repo::<H, C>), 311 ) 312 .route(reads::LIST_REFS_ROUTE, get(reads::git_list_refs::<H, C>)) 313 .route(reads::LIST_REPOS_ROUTE, get(reads::sync_list_repos::<H, C>)) 314 .route(lists::LIST_MEMBERS_ROUTE, get(lists::list_members::<H, C>)) 315 .route( 316 lists::LIST_COLLABORATORS_ROUTE, 317 get(lists::list_collaborators::<H, C>), 318 ) 319 .route(service::VERSION_ROUTE, get(service::version)) 320 .route(service::OWNER_ROUTE, get(service::owner::<H, C>)) 321 .layer(DefaultBodyLimit::max(state.byte_limits.body.get())) 322 .layer(from_fn_with_state( 323 Arc::clone(&state), 324 enforce_pre_auth_limit::<H, C>, 325 )) 326 .merge(lfs::routes::<H, C>()) 327 .merge(receive::routes::<H, C>()) 328 .route(service::HEALTH_ROUTE, get(service::health::<H, C>)) 329 .route(events::EVENTS_ROUTE, get(events::events::<H, C>)) 330 .with_state(state) 331} 332 333pub(crate) async fn enforce_pre_auth_limit<H: HttpTransport, C: Clock>( 334 State(state): State<Arc<XrpcState<H, C>>>, 335 socket: SocketPeer, 336 request: Request, 337 next: Next, 338) -> Response { 339 let peer = effective_peer(&state, socket, request.headers()); 340 match admit_pre_auth(&state, peer) { 341 Ok(guard) => { 342 let response = next.run(request).await; 343 drop(guard); 344 response 345 } 346 Err(error) => error.into_response(), 347 } 348} 349 350pub(crate) fn effective_peer<H: HttpTransport, C: Clock>( 351 state: &XrpcState<H, C>, 352 socket: SocketPeer, 353 headers: &HeaderMap, 354) -> Option<IpAddr> { 355 state 356 .trusted_proxy_header 357 .as_ref() 358 .and_then(|header| knot_types::forwarded_peer(headers, header)) 359 .or(socket.ip()) 360} 361 362pub(crate) fn admit_pre_auth<H: HttpTransport, C: Clock>( 363 state: &XrpcState<H, C>, 364 peer: Option<IpAddr>, 365) -> Result<AdmitGuard, XrpcError> { 366 state 367 .limiter 368 .admit(peer, state.atproto.now()) 369 .map_err(|refusal| match refusal { 370 Refusal::RateLimited => { 371 XrpcError::rate_limited("too many pre-authentication requests, retry shortly") 372 } 373 Refusal::Saturated => { 374 XrpcError::overloaded("knot is shedding pre-authentication load, retry shortly") 375 } 376 }) 377} 378 379pub(crate) const BASIC_CHALLENGE: HeaderValue = HeaderValue::from_static("Basic realm=\"knot\""); 380 381fn strip_bearer(value: &str) -> Option<&str> { 382 let (scheme, rest) = value.split_once(' ')?; 383 scheme.eq_ignore_ascii_case("Bearer").then_some(rest) 384} 385 386fn bearer(headers: &HeaderMap) -> Result<ServiceJwt, XrpcError> { 387 headers 388 .get(AUTHORIZATION) 389 .and_then(|value| value.to_str().ok()) 390 .and_then(strip_bearer) 391 .map(str::trim) 392 .and_then(|token| ServiceJwt::new(token).ok()) 393 .ok_or_else(|| XrpcError::auth_required("missing or malformed Bearer authorization header")) 394} 395 396pub(crate) struct BasicUser(String); 397 398impl BasicUser { 399 pub(crate) fn matches(&self, expected: &str) -> bool { 400 self.0 == expected 401 } 402} 403 404pub(crate) struct BasicPassword(String); 405 406impl BasicPassword { 407 pub(crate) fn as_bytes(&self) -> &[u8] { 408 self.0.as_bytes() 409 } 410} 411 412pub(crate) struct BasicCredentials { 413 pub(crate) user: BasicUser, 414 pub(crate) password: BasicPassword, 415} 416 417pub(crate) fn basic_credentials(value: &str) -> Option<BasicCredentials> { 418 let (scheme, rest) = value.split_once(' ')?; 419 if !scheme.eq_ignore_ascii_case("Basic") { 420 return None; 421 } 422 let decoded = base64::engine::general_purpose::STANDARD 423 .decode(rest.trim()) 424 .ok()?; 425 let text = String::from_utf8(decoded).ok()?; 426 let (user, password) = text.split_once(':')?; 427 Some(BasicCredentials { 428 user: BasicUser(user.to_string()), 429 password: BasicPassword(password.to_string()), 430 }) 431} 432 433fn strip_basic(value: &str) -> Option<String> { 434 basic_credentials(value) 435 .map(|credentials| credentials.password.0) 436 .filter(|password| !password.is_empty()) 437} 438 439fn push_credential(headers: &HeaderMap) -> Result<ServiceJwt, XrpcError> { 440 let value = headers 441 .get(AUTHORIZATION) 442 .and_then(|value| value.to_str().ok()) 443 .ok_or_else(|| XrpcError::auth_required("missing authorization header"))?; 444 strip_bearer(value) 445 .map(str::trim) 446 .map(str::to_string) 447 .or_else(|| strip_basic(value)) 448 .and_then(|token| ServiceJwt::new(token).ok()) 449 .ok_or_else(|| { 450 XrpcError::auth_required("authorization isn't a bearer token or basic credential") 451 }) 452} 453 454pub(crate) fn decode<T: DeserializeOwned>(body: &Bytes) -> Result<T, XrpcError> { 455 serde_json::from_slice(body) 456 .map_err(|error| XrpcError::invalid_request(format!("invalid request body: {error}"))) 457} 458 459pub(crate) fn ok_empty() -> Response { 460 (StatusCode::OK, Json(json!({}))).into_response() 461} 462 463pub(crate) fn current_owner<H: HttpTransport, C: Clock>( 464 state: &XrpcState<H, C>, 465 repo: &RepoDid, 466) -> Option<OwnerDid> { 467 match state.index.owner_of(repo) { 468 Resolved::Ready(owner) => owner, 469 Resolved::Warming => None, 470 } 471} 472 473pub(crate) async fn fold_collaborators<H: HttpTransport, C: Clock>( 474 state: &XrpcState<H, C>, 475 repo: &RepoDid, 476) { 477 let index = Arc::clone(&state.index); 478 let target = repo.clone(); 479 let _ = run_blocking(move || Ok(index.ensure_collaborators(&target))).await; 480} 481 482pub(crate) async fn authorize_push<H: HttpTransport, C: Clock>( 483 state: &XrpcState<H, C>, 484 actor: &AccountDid, 485 repo: &RepoDid, 486 denied: &str, 487) -> Result<(), XrpcError> { 488 fold_collaborators(state, repo).await; 489 let acl = knot_acl::KnotAcl::new(&state.admins, state.admission, &state.index); 490 if knot_acl::can_push(&acl, actor, repo).is_allowed() { 491 Ok(()) 492 } else { 493 Err(XrpcError::forbidden(denied)) 494 } 495} 496 497pub(crate) async fn authenticate_and_authorize_push<H: HttpTransport, C: Clock>( 498 state: &XrpcState<H, C>, 499 socket: SocketPeer, 500 headers: &HeaderMap, 501 repo: &RepoDid, 502 denied: &str, 503) -> Result<AccountDid, XrpcError> { 504 let peer = effective_peer(state, socket, headers); 505 let guard = admit_pre_auth(state, peer)?; 506 let actor = state.authenticate_push(headers).await?; 507 guard.refund(); 508 authorize_push(state, &actor, repo, denied).await?; 509 Ok(actor) 510} 511 512pub(crate) async fn run_blocking<T, F>(task: F) -> Result<T, XrpcError> 513where 514 F: FnOnce() -> Result<T, XrpcError> + Send + 'static, 515 T: Send + 'static, 516{ 517 match tokio::task::spawn_blocking(task).await { 518 Ok(result) => result, 519 Err(_) => Err(XrpcError::internal("blocking task failed to complete")), 520 } 521} 522 523#[derive(serde::Deserialize)] 524#[serde(transparent)] 525pub(crate) struct OwnerSegment(String); 526 527#[derive(serde::Deserialize)] 528#[serde(transparent)] 529pub(crate) struct RepoNameSegment(String); 530 531impl OwnerSegment { 532 pub(crate) fn as_str(&self) -> &str { 533 &self.0 534 } 535} 536 537impl RepoNameSegment { 538 pub(crate) fn as_str(&self) -> &str { 539 &self.0 540 } 541} 542 543#[derive(serde::Deserialize)] 544#[serde(transparent)] 545pub(crate) struct RepoDidSegment(String); 546 547impl RepoDidSegment { 548 pub(crate) fn as_str(&self) -> &str { 549 &self.0 550 } 551} 552 553#[derive(serde::Deserialize)] 554pub(crate) struct RepoPathParams { 555 pub(crate) did: OwnerSegment, 556 pub(crate) name: RepoNameSegment, 557} 558 559pub(crate) fn resolve_repo_did<H: HttpTransport, C: Clock>( 560 state: &XrpcState<H, C>, 561 segment: &RepoDidSegment, 562) -> Result<RepoDid, XrpcError> { 563 let raw = segment.as_str(); 564 let trimmed = raw.strip_suffix(".git").unwrap_or(raw); 565 let did = RepoDid::new(trimmed).map_err(|_| XrpcError::not_found("repository not found"))?; 566 match state.index.owner_of(&did) { 567 Resolved::Ready(Some(_)) => Ok(did), 568 Resolved::Ready(None) => Err(XrpcError::not_found("repository not found")), 569 Resolved::Warming => Err(XrpcError::warming( 570 "registry projection is still warming, retry shortly", 571 )), 572 } 573} 574 575pub(crate) async fn resolve_repo_named<H: HttpTransport, C: Clock>( 576 state: &XrpcState<H, C>, 577 owner: &OwnerSegment, 578 name: &RepoNameSegment, 579) -> Result<RepoDid, XrpcError> { 580 let owner = resolve_owner_segment(state, owner).await?; 581 RepoRkey::clone_path_candidates(name.as_str()) 582 .find_map(|rkey| match state.index.resolve_repo(&owner, &rkey) { 583 Resolved::Ready(Some(did)) => Some(Ok(did)), 584 Resolved::Ready(None) => None, 585 Resolved::Warming => Some(Err(XrpcError::warming( 586 "registry projection is still warming, retry shortly", 587 ))), 588 }) 589 .unwrap_or_else(|| Err(XrpcError::not_found("repository not found"))) 590} 591 592async fn resolve_owner_segment<H: HttpTransport, C: Clock>( 593 state: &XrpcState<H, C>, 594 owner: &OwnerSegment, 595) -> Result<OwnerDid, XrpcError> { 596 let not_found = || XrpcError::not_found("repository not found"); 597 match OwnerRef::parse(owner.as_str()).ok_or_else(not_found)? { 598 OwnerRef::Did(did) => Ok(did), 599 OwnerRef::Handle(handle) => state 600 .atproto 601 .resolve_handle_to_did(&handle) 602 .await 603 .map(OwnerDid::from) 604 .map_err(|_| not_found()), 605 } 606} 607 608#[cfg(test)] 609mod credential_tests { 610 use super::push_credential; 611 use base64::Engine; 612 use http::{HeaderMap, HeaderValue, header::AUTHORIZATION}; 613 614 fn with(value: &str) -> HeaderMap { 615 let mut headers = HeaderMap::new(); 616 headers.insert(AUTHORIZATION, HeaderValue::from_str(value).unwrap()); 617 headers 618 } 619 620 fn basic(user_pass: &str) -> String { 621 format!( 622 "Basic {}", 623 base64::engine::general_purpose::STANDARD.encode(user_pass) 624 ) 625 } 626 627 #[test] 628 fn a_bearer_token_is_taken_verbatim() { 629 assert_eq!( 630 push_credential(&with("Bearer jwt.abc.def")) 631 .unwrap() 632 .as_str(), 633 "jwt.abc.def" 634 ); 635 assert_eq!( 636 push_credential(&with("bearer jwt.abc.def")) 637 .unwrap() 638 .as_str(), 639 "jwt.abc.def" 640 ); 641 } 642 643 #[test] 644 fn a_basic_credential_yields_the_password_after_the_first_colon() { 645 assert_eq!( 646 push_credential(&with(&basic("x-tangled-token:jwt.abc.def"))) 647 .unwrap() 648 .as_str(), 649 "jwt.abc.def", 650 "RFC 7617 puts the token in the password half, so the username stays colon-free" 651 ); 652 } 653 654 #[test] 655 fn malformed_or_empty_credentials_are_rejected() { 656 assert!(push_credential(&HeaderMap::new()).is_err()); 657 assert!(push_credential(&with("Bearer ")).is_err()); 658 assert!(push_credential(&with(&basic("x-tangled-token:"))).is_err()); 659 assert!(push_credential(&with(&basic("no-colon"))).is_err()); 660 assert!(push_credential(&with("Basic !!!not-base64")).is_err()); 661 assert!(push_credential(&with("Digest whatever")).is_err()); 662 } 663}