This repository has no description
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}