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};
60pub use knot_git::ArchiveLimit;
61use knot_git::Layout;
62use knot_index::{Index, Resolved};
63use knot_maintenance::MaintenanceHandle;
64use knot_resource::Slots;
65use knot_runtime::{Clock, Entropy, HttpTransport};
66use knot_secrets::SealedStore;
67use knot_types::{
68 AccountDid, AdmissionPolicy, AppviewEndpoint, CiLogsAddr, ClonePath, KnotHostname, KnotId,
69 KnotServiceUrl, Nsid, OwnerDid, OwnerRef, RepoDid, UnixSeconds,
70};
71
72use base64::Engine;
73use knot_pack::SocketPeer;
74use knot_resource::{AdmitGuard, Refusal};
75
76pub(crate) const PUSH_NSID: &str = "sh.tangled.repo.push";
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum ReadBudget {
80 Within(Duration),
81 Unbounded,
82}
83
84impl ReadBudget {
85 pub fn deadline(self) -> Option<Instant> {
86 match self {
87 ReadBudget::Within(budget) => Some(Instant::now() + budget),
88 ReadBudget::Unbounded => None,
89 }
90 }
91}
92
93// `XrpcState` keeps a bunch of these side by side,
94// some usize & some u64.
95// Within each group every one of them typechecked in every other one's slot.
96knot_types::scalar_newtype! {
97 pub struct BodyLimit(usize);
98 pub struct PatchLimit(usize);
99 pub struct PatchDecompressedLimit(u64);
100 pub struct ResponseLimit(usize);
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::default(),
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 proxy_trust: knot_types::ProxyTrust,
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 = state
340 .proxy_trust
341 .client_peer(request.headers(), socket.ip());
342 match admit_pre_auth(&state, peer) {
343 Ok(guard) => {
344 let response = next.run(request).await;
345 drop(guard);
346 response
347 }
348 Err(error) => error.into_response(),
349 }
350}
351
352pub(crate) fn admit_pre_auth<H: HttpTransport, C: Clock>(
353 state: &XrpcState<H, C>,
354 peer: Option<IpAddr>,
355) -> Result<AdmitGuard, XrpcError> {
356 state
357 .limiter
358 .admit(peer, state.atproto.now())
359 .map_err(|refusal| match refusal {
360 Refusal::RateLimited => {
361 XrpcError::rate_limited("too many pre-authentication requests, retry shortly")
362 }
363 Refusal::Saturated => {
364 XrpcError::overloaded("knot is shedding pre-authentication load, retry shortly")
365 }
366 })
367}
368
369pub(crate) const BASIC_CHALLENGE: HeaderValue = HeaderValue::from_static("Basic realm=\"knot\"");
370
371fn strip_bearer(value: &str) -> Option<&str> {
372 let (scheme, rest) = value.split_once(' ')?;
373 scheme.eq_ignore_ascii_case("Bearer").then_some(rest)
374}
375
376fn bearer(headers: &HeaderMap) -> Result<ServiceJwt, XrpcError> {
377 headers
378 .get(AUTHORIZATION)
379 .and_then(|value| value.to_str().ok())
380 .and_then(strip_bearer)
381 .map(str::trim)
382 .and_then(|token| ServiceJwt::new(token).ok())
383 .ok_or_else(|| XrpcError::auth_required("missing or malformed Bearer authorization header"))
384}
385
386pub(crate) struct BasicUser(String);
387
388impl BasicUser {
389 pub(crate) fn matches(&self, expected: &str) -> bool {
390 self.0 == expected
391 }
392}
393
394pub(crate) struct BasicPassword(String);
395
396impl BasicPassword {
397 pub(crate) fn as_bytes(&self) -> &[u8] {
398 self.0.as_bytes()
399 }
400}
401
402pub(crate) struct BasicCredentials {
403 pub(crate) user: BasicUser,
404 pub(crate) password: BasicPassword,
405}
406
407pub(crate) fn basic_credentials(value: &str) -> Option<BasicCredentials> {
408 let (scheme, rest) = value.split_once(' ')?;
409 if !scheme.eq_ignore_ascii_case("Basic") {
410 return None;
411 }
412 let decoded = base64::engine::general_purpose::STANDARD
413 .decode(rest.trim())
414 .ok()?;
415 let text = String::from_utf8(decoded).ok()?;
416 let (user, password) = text.split_once(':')?;
417 Some(BasicCredentials {
418 user: BasicUser(user.to_string()),
419 password: BasicPassword(password.to_string()),
420 })
421}
422
423fn strip_basic(value: &str) -> Option<String> {
424 basic_credentials(value)
425 .map(|credentials| credentials.password.0)
426 .filter(|password| !password.is_empty())
427}
428
429fn push_credential(headers: &HeaderMap) -> Result<ServiceJwt, XrpcError> {
430 let value = headers
431 .get(AUTHORIZATION)
432 .and_then(|value| value.to_str().ok())
433 .ok_or_else(|| XrpcError::auth_required("missing authorization header"))?;
434 strip_bearer(value)
435 .map(str::trim)
436 .map(str::to_string)
437 .or_else(|| strip_basic(value))
438 .and_then(|token| ServiceJwt::new(token).ok())
439 .ok_or_else(|| {
440 XrpcError::auth_required("authorization isn't a bearer token or basic credential")
441 })
442}
443
444pub(crate) fn decode<T: DeserializeOwned>(body: &Bytes) -> Result<T, XrpcError> {
445 serde_json::from_slice(body)
446 .map_err(|error| XrpcError::invalid_request(format!("invalid request body: {error}")))
447}
448
449pub(crate) fn ok_empty() -> Response {
450 (StatusCode::OK, Json(json!({}))).into_response()
451}
452
453pub(crate) fn current_owner<H: HttpTransport, C: Clock>(
454 state: &XrpcState<H, C>,
455 repo: &RepoDid,
456) -> Option<OwnerDid> {
457 match state.index.owner_of(repo) {
458 Resolved::Ready(owner) => owner,
459 Resolved::Warming => None,
460 }
461}
462
463pub(crate) async fn fold_collaborators<H: HttpTransport, C: Clock>(
464 state: &XrpcState<H, C>,
465 repo: &RepoDid,
466) {
467 let index = Arc::clone(&state.index);
468 let target = repo.clone();
469 let _ = run_blocking(move || Ok(index.ensure_collaborators(&target))).await;
470}
471
472pub(crate) async fn authorize_push<H: HttpTransport, C: Clock>(
473 state: &XrpcState<H, C>,
474 actor: &AccountDid,
475 repo: &RepoDid,
476 denied: &str,
477) -> Result<(), XrpcError> {
478 fold_collaborators(state, repo).await;
479 let acl = knot_acl::KnotAcl::new(&state.admins, state.admission, &state.index);
480 if knot_acl::can_push(&acl, actor, repo).is_allowed() {
481 Ok(())
482 } else {
483 Err(XrpcError::forbidden(denied))
484 }
485}
486
487pub(crate) async fn authenticate_and_authorize_push<H: HttpTransport, C: Clock>(
488 state: &XrpcState<H, C>,
489 socket: SocketPeer,
490 headers: &HeaderMap,
491 repo: &RepoDid,
492 denied: &str,
493) -> Result<AccountDid, XrpcError> {
494 let peer = state.proxy_trust.client_peer(headers, socket.ip());
495 let guard = admit_pre_auth(state, peer)?;
496 let actor = state.authenticate_push(headers).await?;
497 guard.refund();
498 authorize_push(state, &actor, repo, denied).await?;
499 Ok(actor)
500}
501
502pub(crate) async fn run_blocking<T, F>(task: F) -> Result<T, XrpcError>
503where
504 F: FnOnce() -> Result<T, XrpcError> + Send + 'static,
505 T: Send + 'static,
506{
507 match tokio::task::spawn_blocking(task).await {
508 Ok(result) => result,
509 Err(_) => Err(XrpcError::internal("blocking task failed to complete")),
510 }
511}
512
513#[derive(serde::Deserialize)]
514#[serde(transparent)]
515pub(crate) struct OwnerSegment(String);
516
517#[derive(serde::Deserialize)]
518#[serde(transparent)]
519pub(crate) struct RepoNameSegment(String);
520
521impl OwnerSegment {
522 pub(crate) fn as_str(&self) -> &str {
523 &self.0
524 }
525}
526
527impl RepoNameSegment {
528 pub(crate) fn as_str(&self) -> &str {
529 &self.0
530 }
531}
532
533#[derive(serde::Deserialize)]
534#[serde(transparent)]
535pub(crate) struct RepoDidSegment(String);
536
537impl RepoDidSegment {
538 pub(crate) fn as_str(&self) -> &str {
539 &self.0
540 }
541}
542
543#[derive(serde::Deserialize)]
544pub(crate) struct RepoPathParams {
545 pub(crate) did: OwnerSegment,
546 pub(crate) name: RepoNameSegment,
547}
548
549pub(crate) fn resolve_repo_did<H: HttpTransport, C: Clock>(
550 state: &XrpcState<H, C>,
551 segment: &RepoDidSegment,
552) -> Result<RepoDid, XrpcError> {
553 let raw = segment.as_str();
554 let trimmed = raw.strip_suffix(".git").unwrap_or(raw);
555 let did = RepoDid::new(trimmed).map_err(|_| XrpcError::not_found("repository not found"))?;
556 match state.index.owner_of(&did) {
557 Resolved::Ready(Some(_)) => Ok(did),
558 Resolved::Ready(None) => Err(XrpcError::not_found("repository not found")),
559 Resolved::Warming => Err(XrpcError::warming(
560 "registry projection is still warming, retry shortly",
561 )),
562 }
563}
564
565pub(crate) async fn resolve_repo_named<H: HttpTransport, C: Clock>(
566 state: &XrpcState<H, C>,
567 owner: &OwnerSegment,
568 name: &RepoNameSegment,
569) -> Result<RepoDid, XrpcError> {
570 let owner = resolve_owner_segment(state, owner).await?;
571 let path = ClonePath::parse(name.as_str())
572 .ok_or_else(|| XrpcError::not_found("repository not found"))?;
573 match state.index.resolve_clone_path(&owner, &path) {
574 Resolved::Ready(Some(did)) => Ok(did),
575 Resolved::Ready(None) => Err(XrpcError::not_found("repository not found")),
576 Resolved::Warming => Err(XrpcError::warming(
577 "registry projection is still warming, retry shortly",
578 )),
579 }
580}
581
582async fn resolve_owner_segment<H: HttpTransport, C: Clock>(
583 state: &XrpcState<H, C>,
584 owner: &OwnerSegment,
585) -> Result<OwnerDid, XrpcError> {
586 let not_found = || XrpcError::not_found("repository not found");
587 match OwnerRef::parse(owner.as_str()).ok_or_else(not_found)? {
588 OwnerRef::Did(did) => Ok(did),
589 OwnerRef::Handle(handle) => state
590 .atproto
591 .resolve_handle_to_did(&handle)
592 .await
593 .map(OwnerDid::from)
594 .map_err(|_| not_found()),
595 }
596}
597
598#[cfg(test)]
599mod credential_tests {
600 use super::push_credential;
601 use base64::Engine;
602 use http::{HeaderMap, HeaderValue, header::AUTHORIZATION};
603
604 fn with(value: &str) -> HeaderMap {
605 let mut headers = HeaderMap::new();
606 headers.insert(AUTHORIZATION, HeaderValue::from_str(value).unwrap());
607 headers
608 }
609
610 fn basic(user_pass: &str) -> String {
611 format!(
612 "Basic {}",
613 base64::engine::general_purpose::STANDARD.encode(user_pass)
614 )
615 }
616
617 #[test]
618 fn a_bearer_token_is_taken_verbatim() {
619 assert_eq!(
620 push_credential(&with("Bearer jwt.abc.def"))
621 .unwrap()
622 .as_str(),
623 "jwt.abc.def"
624 );
625 assert_eq!(
626 push_credential(&with("bearer jwt.abc.def"))
627 .unwrap()
628 .as_str(),
629 "jwt.abc.def"
630 );
631 }
632
633 #[test]
634 fn a_basic_credential_yields_the_password_after_the_first_colon() {
635 assert_eq!(
636 push_credential(&with(&basic("x-tangled-token:jwt.abc.def")))
637 .unwrap()
638 .as_str(),
639 "jwt.abc.def",
640 "RFC 7617 puts the token in the password half, so the username stays colon-free"
641 );
642 }
643
644 #[test]
645 fn malformed_or_empty_credentials_are_rejected() {
646 assert!(push_credential(&HeaderMap::new()).is_err());
647 assert!(push_credential(&with("Bearer ")).is_err());
648 assert!(push_credential(&with(&basic("x-tangled-token:"))).is_err());
649 assert!(push_credential(&with(&basic("no-colon"))).is_err());
650 assert!(push_credential(&with("Basic !!!not-base64")).is_err());
651 assert!(push_credential(&with("Digest whatever")).is_err());
652 }
653}