1pub mod error;
2pub mod pagination;
3pub mod ratelimit;
4pub mod retry;
5
6use backoff::backoff::Backoff;
7use error::{ApiError, Result};
8use ratelimit::RateLimiter;
9use reqwest::header::HeaderMap;
10use reqwest::{Client, Method, RequestBuilder, StatusCode};
11use retry::{retry_with_backoff, RetryConfig};
12use secrecy::{ExposeSecret, SecretString};
13use serde::de::DeserializeOwned;
14use serde::Serialize;
15use std::fmt;
16use std::time::Duration;
17use tracing::{debug, error, warn};
18use url::Url;
19
20#[derive(Clone)]
21pub enum AuthMethod {
22 Basic {
23 username: String,
24 token: SecretString,
25 },
26 Bearer {
27 token: SecretString,
28 },
29 GenieKey {
30 api_key: SecretString,
31 },
32}
33
34impl fmt::Debug for AuthMethod {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 match self {
37 AuthMethod::Basic { username, .. } => f
38 .debug_struct("Basic")
39 .field("username", username)
40 .field("token", &"[REDACTED]")
41 .finish(),
42 AuthMethod::Bearer { .. } => f
43 .debug_struct("Bearer")
44 .field("token", &"[REDACTED]")
45 .finish(),
46 AuthMethod::GenieKey { .. } => f
47 .debug_struct("GenieKey")
48 .field("api_key", &"[REDACTED]")
49 .finish(),
50 }
51 }
52}
53
54fn same_origin(a: &Url, b: &Url) -> bool {
60 a.scheme() == b.scheme()
61 && a.host() == b.host()
62 && a.port_or_known_default() == b.port_or_known_default()
63}
64
65pub fn normalize_base_url(mut url: Url) -> Url {
70 if url.cannot_be_a_base() {
71 return url;
72 }
73
74 let path = url.path();
75 if !path.ends_with('/') {
76 url.set_path(&format!("{path}/"));
77 }
78 url
79}
80
81const UNAUTHORIZED_FALLBACK: &str = "Invalid or expired credentials";
83
84const MAX_DETAIL_LEN: usize = 200;
86
87async fn unauthorized_error(response: reqwest::Response) -> ApiError {
95 let body = response.text().await.unwrap_or_default();
96 ApiError::AuthenticationFailed {
97 message: unauthorized_message(&body),
98 }
99}
100
101fn unauthorized_message(body: &str) -> String {
103 match unauthorized_detail(body) {
104 Some(detail) => format!("{UNAUTHORIZED_FALLBACK} ({detail})"),
105 None => UNAUTHORIZED_FALLBACK.to_string(),
106 }
107}
108
109fn unauthorized_detail(body: &str) -> Option<String> {
114 let trimmed = body.trim();
115 if trimmed.is_empty() || trimmed.starts_with('<') {
116 return None;
117 }
118
119 let detail = serde_json::from_str::<serde_json::Value>(trimmed)
120 .ok()
121 .and_then(|value| json_error_detail(&value))
122 .unwrap_or_else(|| trimmed.to_string());
123
124 let detail = detail.trim();
125 if detail.is_empty() {
126 return None;
127 }
128 Some(truncate_detail(&scrub_credentials(detail)))
129}
130
131fn json_error_detail(value: &serde_json::Value) -> Option<String> {
133 let direct = ["message", "error_description", "error"]
134 .iter()
135 .find_map(|key| value.get(*key).and_then(|v| v.as_str()))
136 .map(str::to_string);
137
138 direct
139 .or_else(|| {
143 value
144 .get("error")
145 .and_then(|e| e.get("message").or_else(|| e.get("description")))
146 .and_then(|v| v.as_str())
147 .map(str::to_string)
148 })
149 .or_else(|| {
151 value
152 .get("errorMessages")
153 .and_then(|v| v.as_array())
154 .map(|messages| {
155 messages
156 .iter()
157 .filter_map(|m| m.as_str())
158 .collect::<Vec<_>>()
159 .join("; ")
160 })
161 })
162 .map(|detail| detail.trim().to_string())
163 .filter(|detail| !detail.is_empty())
164}
165
166fn scrub_credentials(detail: &str) -> String {
180 const SCHEMES: [&str; 2] = ["bearer ", "basic "];
181
182 let haystack = detail.to_ascii_lowercase();
185
186 let mut out = String::with_capacity(detail.len());
187 let mut cursor = 0;
188
189 while cursor < detail.len() {
190 let found = SCHEMES
191 .iter()
192 .filter_map(|scheme| {
193 haystack[cursor..]
194 .find(scheme)
195 .map(|at| (cursor + at, *scheme))
196 })
197 .min_by_key(|(at, _)| *at);
198
199 let Some((at, scheme)) = found else {
200 out.push_str(&detail[cursor..]);
201 break;
202 };
203
204 let value_start = at + scheme.len();
205 out.push_str(&detail[cursor..value_start]);
206
207 let value_end = detail[value_start..]
208 .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | '}' | ']' | ')'))
209 .map(|offset| value_start + offset)
210 .unwrap_or(detail.len());
211
212 if is_credential_shaped(&detail[value_start..value_end]) {
213 out.push_str("<redacted>");
214 cursor = value_end;
215 } else {
216 cursor = value_start;
219 }
220 }
221
222 out
223}
224
225fn is_credential_shaped(token: &str) -> bool {
233 const MIN_OPAQUE_LEN: usize = 16;
234
235 if token.len() < 4 {
236 return false;
237 }
238 if !token
239 .chars()
240 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '.' | '_' | '-'))
241 {
242 return false;
243 }
244
245 token.len() >= MIN_OPAQUE_LEN || token.contains(['+', '/', '=', '.'])
246}
247
248fn truncate_detail(detail: &str) -> String {
250 if detail.chars().count() <= MAX_DETAIL_LEN {
251 return detail.to_string();
252 }
253 let short: String = detail.chars().take(MAX_DETAIL_LEN).collect();
254 format!("{short}...")
255}
256
257fn retry_after(response: &reqwest::Response) -> Option<Duration> {
260 response
261 .headers()
262 .get(reqwest::header::RETRY_AFTER)?
263 .to_str()
264 .ok()?
265 .trim()
266 .parse::<u64>()
267 .ok()
268 .map(Duration::from_secs)
269}
270
271pub struct RawRequest<'a> {
273 pub method: Method,
274 pub path: &'a str,
276 pub headers: HeaderMap,
277 pub body: Option<&'a [u8]>,
278 pub timeout: Option<Duration>,
280}
281
282#[derive(Debug, Clone)]
284pub struct RawResponse {
285 pub status: u16,
286 pub headers: Vec<(String, String)>,
287 pub body: Vec<u8>,
288}
289
290impl RawResponse {
291 pub fn is_success(&self) -> bool {
292 (200..300).contains(&self.status)
293 }
294
295 pub fn header(&self, name: &str) -> Option<&str> {
297 self.headers
298 .iter()
299 .find(|(key, _)| key.eq_ignore_ascii_case(name))
300 .map(|(_, value)| value.as_str())
301 }
302}
303
304#[derive(Clone)]
305pub struct ApiClient {
306 client: Client,
307 raw_client: Client,
309 base_url: Url,
310 auth: Option<AuthMethod>,
311 retry_config: RetryConfig,
312 rate_limiter: RateLimiter,
313}
314
315fn reject_restructuring_path(path: &str) -> Result<()> {
346 let restructured = |reason: &str| {
347 debug!(
348 path,
349 reason, "Refusing a path the URL parser would restructure"
350 );
351 ApiError::InvalidUrl(url::ParseError::InvalidDomainCharacter)
352 };
353
354 if path.contains('#') {
361 return Err(restructured("fragment marker truncates the path"));
362 }
363
364 let path_only = path.split('?').next().unwrap_or(path);
367
368 if path_only.chars().any(|c| c.is_control()) {
369 return Err(restructured("control character"));
370 }
371 if path_only.contains('\\') {
372 return Err(restructured("backslash is a path separator"));
373 }
374 if path_only.trim_matches(' ') != path_only {
379 return Err(restructured("leading or trailing space is stripped"));
380 }
381
382 for segment in path_only.split('/') {
383 if is_dot_segment(segment) {
384 return Err(restructured("dot component"));
385 }
386 }
387
388 Ok(())
389}
390
391fn is_dot_segment(segment: &str) -> bool {
397 let decoded = decode_once(segment);
398 decoded == "." || decoded == ".."
399}
400
401fn hex_value(byte: u8) -> Option<u8> {
403 match byte {
404 b'0'..=b'9' => Some(byte - b'0'),
405 b'a'..=b'f' => Some(byte - b'a' + 10),
406 b'A'..=b'F' => Some(byte - b'A' + 10),
407 _ => None,
408 }
409}
410
411fn decode_once(segment: &str) -> String {
413 let bytes = segment.as_bytes();
417 let mut out = Vec::with_capacity(bytes.len());
418 let mut i = 0;
419 while i < bytes.len() {
420 if bytes[i] == b'%' && i + 2 < bytes.len() {
421 if let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2])) {
422 out.push(hi * 16 + lo);
423 i += 3;
424 continue;
425 }
426 }
427 out.push(bytes[i]);
428 i += 1;
429 }
430 String::from_utf8_lossy(&out).into_owned()
431}
432
433impl ApiClient {
434 pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
435 let url = Url::parse(base_url.as_ref()).map_err(ApiError::InvalidUrl)?;
436
437 if url.scheme() != "https" {
440 let is_localhost = url
441 .host_str()
442 .map(|h| h == "localhost" || h == "127.0.0.1" || h.starts_with("127."))
443 .unwrap_or(false);
444
445 if !is_localhost {
446 return Err(ApiError::InvalidUrl(
447 url::ParseError::InvalidDomainCharacter,
448 ));
449 }
450 }
451
452 let url = normalize_base_url(url);
453
454 let client = Client::builder()
455 .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
456 .timeout(Duration::from_secs(30))
457 .build()
458 .map_err(ApiError::RequestFailed)?;
459
460 let origin = url.clone();
470 let raw_client = Client::builder()
471 .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
472 .timeout(Duration::from_secs(30))
473 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
474 if attempt.previous().len() >= 10 {
475 attempt.error("too many redirects")
476 } else if same_origin(attempt.url(), &origin) {
477 attempt.follow()
478 } else {
479 attempt.stop()
480 }
481 }))
482 .build()
483 .map_err(ApiError::RequestFailed)?;
484
485 Ok(Self {
486 client,
487 raw_client,
488 base_url: url,
489 auth: None,
490 retry_config: RetryConfig::default(),
491 rate_limiter: RateLimiter::new(),
492 })
493 }
494
495 fn safe_join(&self, path: &str) -> Result<Url> {
498 reject_restructuring_path(path)?;
499
500 let joined = self
501 .base_url
502 .join(path.strip_prefix('/').unwrap_or(path))
503 .map_err(ApiError::InvalidUrl)?;
504
505 if !same_origin(&joined, &self.base_url) {
506 return Err(ApiError::InvalidUrl(
507 url::ParseError::InvalidDomainCharacter,
508 ));
509 }
510
511 Ok(joined)
512 }
513
514 pub fn with_basic_auth(
515 mut self,
516 username: impl Into<String>,
517 token: impl Into<String>,
518 ) -> Self {
519 self.auth = Some(AuthMethod::Basic {
520 username: username.into(),
521 token: SecretString::from(token.into()),
522 });
523 self
524 }
525
526 pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
527 self.auth = Some(AuthMethod::Bearer {
528 token: SecretString::from(token.into()),
529 });
530 self
531 }
532
533 pub fn with_genie_key(mut self, api_key: impl Into<String>) -> Self {
534 self.auth = Some(AuthMethod::GenieKey {
535 api_key: SecretString::from(api_key.into()),
536 });
537 self
538 }
539
540 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
541 self.retry_config = config;
542 self
543 }
544
545 pub fn base_url(&self) -> &str {
546 self.base_url.as_str()
547 }
548
549 pub fn http_client(&self) -> &Client {
551 &self.client
552 }
553
554 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
555 self.request(Method::GET, path, Option::<&()>::None).await
556 }
557
558 pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
559 &self,
560 path: &str,
561 body: &B,
562 ) -> Result<T> {
563 self.request(Method::POST, path, Some(body)).await
564 }
565
566 pub async fn put<T: DeserializeOwned, B: Serialize + ?Sized>(
567 &self,
568 path: &str,
569 body: &B,
570 ) -> Result<T> {
571 self.request(Method::PUT, path, Some(body)).await
572 }
573
574 pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
575 self.request(Method::DELETE, path, Option::<&()>::None)
576 .await
577 }
578
579 pub async fn delete_with_body<T: DeserializeOwned, B: Serialize + ?Sized>(
580 &self,
581 path: &str,
582 body: &B,
583 ) -> Result<T> {
584 self.request(Method::DELETE, path, Some(body)).await
585 }
586
587 pub async fn delete_no_content(&self, path: &str) -> Result<()> {
589 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
590 warn!(wait_secs, "Rate limit reached, waiting");
591 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
592 }
593
594 let joined = self.safe_join(path)?;
595
596 debug!(method = "DELETE", url = %joined, "Sending delete (no content) request");
597
598 retry_with_backoff(&self.retry_config, || async {
599 let mut req = self.client.request(Method::DELETE, joined.clone());
600 req = self.apply_auth(req);
601
602 let response = req.send().await.map_err(ApiError::RequestFailed)?;
603
604 self.rate_limiter.update_from_response(&response).await;
605
606 let status = response.status();
607
608 match status {
609 StatusCode::UNAUTHORIZED => Err(unauthorized_error(response).await),
610 StatusCode::FORBIDDEN => {
611 let message = response
612 .text()
613 .await
614 .unwrap_or_else(|_| "Access forbidden".to_string());
615 Err(ApiError::Forbidden { message })
616 }
617 StatusCode::NOT_FOUND => {
618 let resource = joined.path().to_string();
619 Err(ApiError::NotFound { resource })
620 }
621 StatusCode::BAD_REQUEST => {
622 let message = response
623 .text()
624 .await
625 .unwrap_or_else(|_| "Bad request".to_string());
626 Err(ApiError::BadRequest { message })
627 }
628 StatusCode::GONE => {
629 let message = response
630 .text()
631 .await
632 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
633 Err(ApiError::EndpointGone { message })
634 }
635 StatusCode::TOO_MANY_REQUESTS => {
636 let retry_after = response
637 .headers()
638 .get("retry-after")
639 .and_then(|v| v.to_str().ok())
640 .and_then(|s| s.parse().ok())
641 .unwrap_or(60);
642 Err(ApiError::RateLimitExceeded { retry_after })
643 }
644 status if status.is_server_error() => {
645 let message = response
646 .text()
647 .await
648 .unwrap_or_else(|_| "Server error".to_string());
649 Err(ApiError::ServerError {
650 status: status.as_u16(),
651 message,
652 })
653 }
654 status if status.is_success() => Ok(()),
655 _ => {
656 let message = response
657 .text()
658 .await
659 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
660 Err(ApiError::ServerError {
661 status: status.as_u16(),
662 message,
663 })
664 }
665 }
666 })
667 .await
668 }
669
670 pub async fn response_header(&self, path: &str, header: &str) -> Result<Option<String>> {
683 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
684 warn!(wait_secs, "Rate limit reached, waiting");
685 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
686 }
687
688 let joined = self.safe_join(path)?;
689 debug!(method = "GET", url = %joined, header, "Reading response header");
690
691 let mut req = self.client.request(Method::GET, joined.clone());
692 req = self.apply_auth(req);
693 let response = req.send().await.map_err(ApiError::RequestFailed)?;
694
695 self.rate_limiter.update_from_response(&response).await;
696
697 let status = response.status();
698 if status == StatusCode::UNAUTHORIZED {
699 return Err(unauthorized_error(response).await);
700 }
701 if status == StatusCode::FORBIDDEN {
702 let message = response
703 .text()
704 .await
705 .unwrap_or_else(|_| "Access forbidden".to_string());
706 return Err(ApiError::Forbidden { message });
707 }
708
709 Ok(response
710 .headers()
711 .get(header)
712 .and_then(|value| value.to_str().ok())
713 .map(str::to_string))
714 }
715
716 pub async fn get_text(&self, path: &str) -> Result<String> {
717 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
718 warn!(wait_secs, "Rate limit reached, waiting");
719 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
720 }
721
722 let joined = self.safe_join(path)?;
723
724 debug!(method = "GET", url = %joined, "Sending text request");
725
726 let result = retry_with_backoff(&self.retry_config, || async {
727 let mut req = self.client.request(Method::GET, joined.clone());
728 req = self.apply_auth(req);
729 req = req.header("Accept", "text/plain, */*;q=0.1");
730
731 let response = req.send().await.map_err(ApiError::RequestFailed)?;
732
733 self.rate_limiter.update_from_response(&response).await;
734
735 let status = response.status();
736
737 match status {
738 StatusCode::UNAUTHORIZED => Err(unauthorized_error(response).await),
739 StatusCode::FORBIDDEN => {
740 let message = response
741 .text()
742 .await
743 .unwrap_or_else(|_| "Access forbidden".to_string());
744 Err(ApiError::Forbidden { message })
745 }
746 StatusCode::NOT_FOUND => {
747 let resource = joined.path().to_string();
748 Err(ApiError::NotFound { resource })
749 }
750 StatusCode::BAD_REQUEST => {
751 let message = response
752 .text()
753 .await
754 .unwrap_or_else(|_| "Bad request".to_string());
755 Err(ApiError::BadRequest { message })
756 }
757 StatusCode::NOT_ACCEPTABLE => {
758 let message = response
759 .text()
760 .await
761 .unwrap_or_else(|_| "Content not acceptable".to_string());
762 Err(ApiError::ServerError {
763 status: 406,
764 message,
765 })
766 }
767 StatusCode::GONE => {
768 let message = response
769 .text()
770 .await
771 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
772 Err(ApiError::EndpointGone { message })
773 }
774 StatusCode::TOO_MANY_REQUESTS => {
775 let retry_after = response
776 .headers()
777 .get("retry-after")
778 .and_then(|v| v.to_str().ok())
779 .and_then(|s| s.parse().ok())
780 .unwrap_or(60);
781 Err(ApiError::RateLimitExceeded { retry_after })
782 }
783 status if status.is_server_error() => {
784 let message = response
785 .text()
786 .await
787 .unwrap_or_else(|_| "Server error".to_string());
788 Err(ApiError::ServerError {
789 status: status.as_u16(),
790 message,
791 })
792 }
793 status if status.is_success() => response.text().await.map_err(|e| {
794 error!("Failed to read text response: {}", e);
795 ApiError::InvalidResponse(e.to_string())
796 }),
797 _ => {
798 let message = response
799 .text()
800 .await
801 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
802 Err(ApiError::ServerError {
803 status: status.as_u16(),
804 message,
805 })
806 }
807 }
808 })
809 .await?;
810
811 Ok(result)
812 }
813
814 pub fn resolve_url(&self, path: &str) -> Result<Url> {
818 self.safe_join(path)
819 }
820
821 pub async fn request_raw(&self, req: RawRequest<'_>) -> Result<RawResponse> {
832 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
833 warn!(wait_secs, "Rate limit reached, waiting");
834 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
835 }
836
837 let joined = self.safe_join(req.path)?;
838 debug!(method = %req.method, url = %joined, "Sending raw request");
839
840 let idempotent = matches!(
841 req.method,
842 Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
843 );
844 let mut backoff = self.retry_config.backoff();
848 let mut attempts = 0usize;
849
850 loop {
851 attempts += 1;
852
853 let mut builder = self.raw_client.request(req.method.clone(), joined.clone());
854 builder = self.apply_auth(builder);
855 builder = builder.headers(req.headers.clone());
856 if let Some(body) = req.body {
857 builder = builder.body(body.to_vec());
858 }
859 if let Some(timeout) = req.timeout {
860 builder = builder.timeout(timeout);
861 }
862
863 let response = builder.send().await.map_err(ApiError::RequestFailed)?;
864 self.rate_limiter.update_from_response(&response).await;
865 let status = response.status();
866
867 let retryable = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
868 if idempotent && retryable && attempts < self.retry_config.max_retries {
869 if let Some(wait) = backoff.next_backoff() {
870 let wait = retry_after(&response).unwrap_or(wait);
873 warn!(
874 status = status.as_u16(),
875 attempt = attempts,
876 wait_ms = wait.as_millis(),
877 "Raw request failed, retrying"
878 );
879 tokio::time::sleep(wait).await;
880 continue;
881 }
882 }
883
884 let headers = response
885 .headers()
886 .iter()
887 .map(|(name, value)| {
888 (
889 name.as_str().to_string(),
890 value.to_str().unwrap_or_default().to_string(),
891 )
892 })
893 .collect();
894 let body = response
895 .bytes()
896 .await
897 .map_err(|err| ApiError::InvalidResponse(err.to_string()))?
898 .to_vec();
899
900 return Ok(RawResponse {
901 status: status.as_u16(),
902 headers,
903 body,
904 });
905 }
906 }
907
908 pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
911 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
912 warn!(wait_secs, "Rate limit reached, waiting");
913 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
914 }
915
916 let joined = self.safe_join(path)?;
917
918 debug!(method = "GET", url = %joined, "Sending bytes request");
919
920 let result = retry_with_backoff(&self.retry_config, || async {
921 let mut req = self.client.request(Method::GET, joined.clone());
922 req = self.apply_auth(req);
923
924 let response = req.send().await.map_err(ApiError::RequestFailed)?;
925
926 self.rate_limiter.update_from_response(&response).await;
927
928 let status = response.status();
929
930 match status {
931 StatusCode::UNAUTHORIZED => Err(unauthorized_error(response).await),
932 StatusCode::FORBIDDEN => {
933 let message = response
934 .text()
935 .await
936 .unwrap_or_else(|_| "Access forbidden".to_string());
937 Err(ApiError::Forbidden { message })
938 }
939 StatusCode::NOT_FOUND => {
940 let resource = joined.path().to_string();
941 Err(ApiError::NotFound { resource })
942 }
943 StatusCode::GONE => {
944 let message = response
945 .text()
946 .await
947 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
948 Err(ApiError::EndpointGone { message })
949 }
950 StatusCode::TOO_MANY_REQUESTS => {
951 let retry_after = response
952 .headers()
953 .get("retry-after")
954 .and_then(|v| v.to_str().ok())
955 .and_then(|s| s.parse().ok())
956 .unwrap_or(60);
957 Err(ApiError::RateLimitExceeded { retry_after })
958 }
959 status if status.is_success() => {
960 response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
961 error!("Failed to read bytes response: {}", e);
962 ApiError::InvalidResponse(e.to_string())
963 })
964 }
965 _ => {
966 let message = response
967 .text()
968 .await
969 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
970 Err(ApiError::ServerError {
971 status: status.as_u16(),
972 message,
973 })
974 }
975 }
976 })
977 .await?;
978
979 Ok(result)
980 }
981
982 pub async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
983 &self,
984 method: Method,
985 path: &str,
986 body: Option<&B>,
987 ) -> Result<T> {
988 if let Some(wait_secs) = self.rate_limiter.check_limit().await {
989 warn!(wait_secs, "Rate limit reached, waiting");
990 tokio::time::sleep(Duration::from_secs(wait_secs)).await;
991 }
992
993 let joined = self.safe_join(path)?;
994
995 debug!(method = %method, url = %joined, "Sending request");
996
997 let result = retry_with_backoff(&self.retry_config, || async {
998 let mut req = self.client.request(method.clone(), joined.clone());
999 req = self.apply_auth(req);
1000
1001 if let Some(body) = body {
1002 req = req.json(body);
1003 }
1004
1005 let response = req.send().await.map_err(ApiError::RequestFailed)?;
1006
1007 self.rate_limiter.update_from_response(&response).await;
1008
1009 let status = response.status();
1010
1011 match status {
1012 StatusCode::UNAUTHORIZED => Err(unauthorized_error(response).await),
1013 StatusCode::FORBIDDEN => {
1014 let message = response
1015 .text()
1016 .await
1017 .unwrap_or_else(|_| "Access forbidden".to_string());
1018 Err(ApiError::Forbidden { message })
1019 }
1020 StatusCode::NOT_FOUND => {
1021 let resource = joined.path().to_string();
1022 Err(ApiError::NotFound { resource })
1023 }
1024 StatusCode::BAD_REQUEST => {
1025 let message = response
1026 .text()
1027 .await
1028 .unwrap_or_else(|_| "Bad request".to_string());
1029 Err(ApiError::BadRequest { message })
1030 }
1031 StatusCode::GONE => {
1032 let message = response
1033 .text()
1034 .await
1035 .unwrap_or_else(|_| "API endpoint has been removed".to_string());
1036 Err(ApiError::EndpointGone { message })
1037 }
1038 StatusCode::TOO_MANY_REQUESTS => {
1039 let retry_after = response
1040 .headers()
1041 .get("retry-after")
1042 .and_then(|v| v.to_str().ok())
1043 .and_then(|s| s.parse().ok())
1044 .unwrap_or(60);
1045 Err(ApiError::RateLimitExceeded { retry_after })
1046 }
1047 status if status.is_server_error() => {
1048 let message = response
1049 .text()
1050 .await
1051 .unwrap_or_else(|_| "Server error".to_string());
1052 Err(ApiError::ServerError {
1053 status: status.as_u16(),
1054 message,
1055 })
1056 }
1057 status if status.is_success() => {
1058 let bytes = response
1059 .bytes()
1060 .await
1061 .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
1062 let slice: &[u8] = if bytes.iter().all(|b| b.is_ascii_whitespace()) {
1068 b"null"
1069 } else {
1070 &bytes
1071 };
1072 serde_json::from_slice::<T>(slice).map_err(|e| {
1073 error!("Failed to parse JSON response: {}", e);
1074 ApiError::InvalidResponse(e.to_string())
1075 })
1076 }
1077 _ => {
1078 let message = response
1079 .text()
1080 .await
1081 .unwrap_or_else(|_| format!("Unexpected status: {}", status));
1082 Err(ApiError::ServerError {
1083 status: status.as_u16(),
1084 message,
1085 })
1086 }
1087 }
1088 })
1089 .await?;
1090
1091 Ok(result)
1092 }
1093
1094 pub fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
1095 match &self.auth {
1096 Some(AuthMethod::Basic { username, token }) => {
1097 request.basic_auth(username, Some(token.expose_secret()))
1098 }
1099 Some(AuthMethod::Bearer { token }) => request.bearer_auth(token.expose_secret()),
1100 Some(AuthMethod::GenieKey { api_key }) => request.header(
1101 "Authorization",
1102 format!("GenieKey {}", api_key.expose_secret()),
1103 ),
1104 None => request,
1105 }
1106 }
1107
1108 pub fn rate_limiter(&self) -> &RateLimiter {
1109 &self.rate_limiter
1110 }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use super::*;
1116
1117 #[test]
1121 fn a_restructuring_path_is_refused_before_it_is_joined() {
1122 for bad in [
1123 "/2.0/repositories/w/r/hooks/..",
1124 "/2.0/repositories/w/r/hooks/.",
1125 "/2.0/repositories/w/r/hooks/..\\",
1126 "/2.0/repositories/w/r/hooks/a\\..\\x",
1127 "/2.0/repositories/w/r/hooks/.\t.",
1128 "/2.0/repositories/w/r/hooks/.\n.",
1129 "/rest/api/3/issue/../../admin",
1130 "/2.0/repositories/w/r/hooks/%2e%2e",
1134 "/2.0/repositories/w/r/hooks/%2E%2e",
1135 "/2.0/repositories/w/r/hooks/.%2e",
1136 "/2.0/repositories/w/r/hooks/%2e",
1137 "/rest/api/3/issue/ ",
1140 ] {
1141 assert!(
1142 reject_restructuring_path(bad).is_err(),
1143 "{bad:?} must be refused"
1144 );
1145 }
1146 }
1147
1148 #[test]
1151 fn decode_once_does_not_panic_on_a_multibyte_char_after_a_percent() {
1152 assert_eq!(decode_once("x-%2é"), "x-%2é");
1153 assert_eq!(decode_once("%2é"), "%2é");
1154 assert_eq!(decode_once("é%"), "é%");
1155 assert_eq!(decode_once("%é2"), "%é2");
1156 assert!(reject_restructuring_path("/rest/api/3/issue/x-%2é").is_ok());
1158 }
1159
1160 #[test]
1164 fn a_fragment_marker_is_refused_anywhere() {
1165 assert!(reject_restructuring_path("/2.0/repositories/w/r#x/hooks/u").is_err());
1166 assert!(reject_restructuring_path("/rest/api/3/issue/KEY-1#x").is_err());
1167 assert!(reject_restructuring_path("/x?jql=a#b").is_err());
1168 }
1169
1170 #[test]
1174 fn only_edge_spaces_are_refused() {
1175 assert!(reject_restructuring_path("/2.0/repositories/w/r/src/main/my file.txt").is_ok());
1176 assert!(reject_restructuring_path("/rest/api/3/issue/ ").is_err());
1177 assert!(reject_restructuring_path("/rest/api/3/issue/x ").is_err());
1178 assert!(reject_restructuring_path(" /rest/api/3/issue/x").is_err());
1179 assert!(reject_restructuring_path("/rest/api/3/search/jql?jql=a = b").is_ok());
1181 }
1182
1183 #[test]
1187 fn a_double_encoded_dot_is_a_real_segment() {
1188 assert!(reject_restructuring_path("/2.0/repositories/w/r/hooks/%252e%252e").is_ok());
1189 assert!(!is_dot_segment("%252e%252e"));
1190 assert_eq!(decode_once("%252e%252e"), "%2e%2e");
1191 }
1192
1193 #[test]
1195 fn decode_once_leaves_invalid_escapes_alone() {
1196 assert_eq!(decode_once("100%"), "100%");
1197 assert_eq!(decode_once("a%zzb"), "a%zzb");
1198 assert_eq!(decode_once("%7Babc%7D"), "{abc}");
1199 }
1200
1201 #[test]
1204 fn a_dot_in_the_query_is_not_a_path_component() {
1205 assert!(
1206 reject_restructuring_path("/rest/api/3/search/jql?jql=fixVersion%20in%20(1.0)").is_ok()
1207 );
1208 assert!(reject_restructuring_path("/x?range=a..b").is_ok());
1209 }
1210
1211 use wiremock::matchers::{body_string, header, method, path};
1212 use wiremock::{Mock, MockServer, ResponseTemplate};
1213
1214 #[tokio::test]
1215 async fn test_403_returns_forbidden() {
1216 let server = MockServer::start().await;
1217 Mock::given(method("GET"))
1218 .and(path("test"))
1219 .respond_with(ResponseTemplate::new(403).set_body_string("You do not have access"))
1220 .mount(&server)
1221 .await;
1222
1223 let client = ApiClient::new(server.uri()).unwrap();
1224 let result: error::Result<serde_json::Value> = client.get("/test").await;
1225
1226 match result {
1227 Err(ApiError::Forbidden { message }) => {
1228 assert!(message.contains("You do not have access"));
1229 }
1230 other => panic!("Expected Forbidden, got: {:?}", other),
1231 }
1232 }
1233
1234 #[tokio::test]
1235 async fn test_401_returns_authentication_failed() {
1236 let server = MockServer::start().await;
1237 Mock::given(method("GET"))
1238 .and(path("test"))
1239 .respond_with(ResponseTemplate::new(401))
1240 .mount(&server)
1241 .await;
1242
1243 let client = ApiClient::new(server.uri()).unwrap();
1244 let result: error::Result<serde_json::Value> = client.get("/test").await;
1245
1246 match result {
1247 Err(ApiError::AuthenticationFailed { message }) => {
1248 assert_eq!(message, UNAUTHORIZED_FALLBACK);
1250 }
1251 other => panic!("Expected AuthenticationFailed, got: {:?}", other),
1252 }
1253 }
1254
1255 #[tokio::test]
1258 async fn test_401_surfaces_gateway_scope_message() {
1259 let server = MockServer::start().await;
1260 Mock::given(method("GET"))
1261 .and(path("test"))
1262 .respond_with(
1263 ResponseTemplate::new(401).set_body_string(
1264 r#"{"code":401,"message":"Unauthorized; scope does not match"}"#,
1265 ),
1266 )
1267 .mount(&server)
1268 .await;
1269
1270 let client = ApiClient::new(server.uri()).unwrap();
1271 let result: error::Result<serde_json::Value> = client.get("/test").await;
1272
1273 match result {
1274 Err(ApiError::AuthenticationFailed { message }) => {
1275 assert!(
1276 message.contains("scope does not match"),
1277 "gateway reason was dropped: {message}"
1278 );
1279 }
1280 other => panic!("Expected AuthenticationFailed, got: {:?}", other),
1281 }
1282 }
1283
1284 #[test]
1285 fn unauthorized_message_falls_back_when_body_is_empty() {
1286 assert_eq!(unauthorized_message(""), UNAUTHORIZED_FALLBACK);
1287 assert_eq!(unauthorized_message(" "), UNAUTHORIZED_FALLBACK);
1288 }
1289
1290 #[test]
1291 fn unauthorized_message_keeps_gateway_reason() {
1292 let body = r#"{"code":401,"message":"Unauthorized; scope does not match"}"#;
1293 let message = unauthorized_message(body);
1294 assert!(message.starts_with(UNAUTHORIZED_FALLBACK));
1295 assert!(message.contains("Unauthorized; scope does not match"));
1296 }
1297
1298 #[test]
1299 fn unauthorized_message_reads_jira_error_messages() {
1300 let body = r#"{"errorMessages":["Client must be authenticated"],"errors":{}}"#;
1301 assert!(unauthorized_message(body).contains("Client must be authenticated"));
1302 }
1303
1304 #[test]
1305 fn unauthorized_message_reads_oauth_error_description() {
1306 let body = r#"{"error":"invalid_token","error_description":"The token expired"}"#;
1307 assert!(unauthorized_message(body).contains("The token expired"));
1308 }
1309
1310 #[test]
1311 fn unauthorized_message_reads_a_nested_error_object() {
1312 let body = r#"{"error":{"message":"Token does not have the required scope"}}"#;
1313 let message = unauthorized_message(body);
1314 assert!(message.contains("required scope"));
1315 assert!(
1317 !message.contains("{\"error\""),
1318 "raw JSON leaked: {message}"
1319 );
1320 }
1321
1322 #[test]
1325 fn unauthorized_message_redacts_an_echoed_authorization_header() {
1326 let body =
1327 "rejected request: Authorization: Basic Zm9vOmJhcnNlY3JldA== to /rest/api/3/myself";
1328 let message = unauthorized_message(body);
1329 assert!(
1330 !message.contains("Zm9vOmJhcnNlY3JldA=="),
1331 "the credential survived: {message}"
1332 );
1333 assert!(message.contains("Basic <redacted>"));
1334 assert!(
1335 message.contains("/rest/api/3/myself"),
1336 "the useful part of the body was lost: {message}"
1337 );
1338 }
1339
1340 #[test]
1341 fn unauthorized_message_redacts_a_bearer_token_inside_json() {
1342 let body = r#"{"message":"bad header \"Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig\""}"#;
1343 let message = unauthorized_message(body);
1344 assert!(!message.contains("eyJhbGciOiJIUzI1NiJ9"), "{message}");
1345 assert!(message.contains("Bearer <redacted>"));
1346 }
1347
1348 #[test]
1349 fn unauthorized_message_redacts_every_occurrence() {
1350 let body = "Bearer aGVsbG8gd29ybGQgdG9rZW4= and basic dXNlcjpwYXNzd29yZA==";
1351 let message = unauthorized_message(body);
1352 for secret in ["aGVsbG8gd29ybGQgdG9rZW4=", "dXNlcjpwYXNzd29yZA=="] {
1353 assert!(!message.contains(secret), "{secret} survived: {message}");
1354 }
1355 assert_eq!(message.matches("<redacted>").count(), 2);
1356 }
1357
1358 #[test]
1361 fn scrub_leaves_ordinary_prose_alone() {
1362 for prose in [
1363 "basic authentication is not permitted here",
1364 "Basic auth is not allowed",
1365 "use Bearer tokens instead",
1366 "no credentials at all",
1367 ] {
1368 assert_eq!(scrub_credentials(prose), prose, "prose was mangled");
1369 }
1370 }
1371
1372 #[test]
1373 fn credential_shape_separates_words_from_secrets() {
1374 for word in ["auth", "authentication", "tokens", "a", ""] {
1375 assert!(
1376 !is_credential_shaped(word),
1377 "{word} is a word, not a secret"
1378 );
1379 }
1380 for secret in [
1381 "Zm9vOmJhcg==",
1382 "eyJhbGciOiJIUzI1NiJ9.payload.sig",
1383 "abcdefghijklmnop",
1384 "ATATT3xFfGF0abc_def-123",
1385 ] {
1386 assert!(is_credential_shaped(secret), "{secret} should be redacted");
1387 }
1388 }
1389
1390 #[test]
1391 fn unauthorized_message_keeps_plain_text_body() {
1392 assert!(unauthorized_message("Basic auth is not allowed").contains("Basic auth"));
1393 }
1394
1395 #[test]
1396 fn unauthorized_message_ignores_html_login_page() {
1397 let body = "<!DOCTYPE html><html><body>Sign in</body></html>";
1398 assert_eq!(unauthorized_message(body), UNAUTHORIZED_FALLBACK);
1399 }
1400
1401 #[test]
1402 fn unauthorized_message_truncates_long_bodies() {
1403 let body = format!(r#"{{"message":"{}"}}"#, "x".repeat(500));
1404 let message = unauthorized_message(&body);
1405 assert!(message.contains("..."));
1406 assert!(message.len() < 300, "message was not truncated: {message}");
1407 }
1408
1409 #[test]
1411 fn unauthorized_message_truncates_on_char_boundary() {
1412 let body = format!(r#"{{"message":"{}"}}"#, "é".repeat(500));
1413 assert!(unauthorized_message(&body).contains("..."));
1414 }
1415
1416 #[tokio::test]
1417 async fn test_403_get_text_returns_forbidden() {
1418 let server = MockServer::start().await;
1419 Mock::given(method("GET"))
1420 .and(path("text-endpoint"))
1421 .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden resource"))
1422 .mount(&server)
1423 .await;
1424
1425 let client = ApiClient::new(server.uri()).unwrap();
1426 let result = client.get_text("/text-endpoint").await;
1427
1428 match result {
1429 Err(ApiError::Forbidden { message }) => {
1430 assert!(message.contains("Forbidden resource"));
1431 }
1432 other => panic!("Expected Forbidden, got: {:?}", other),
1433 }
1434 }
1435
1436 #[tokio::test]
1437 async fn test_403_get_bytes_returns_forbidden() {
1438 let server = MockServer::start().await;
1439 Mock::given(method("GET"))
1440 .and(path("bytes-endpoint"))
1441 .respond_with(ResponseTemplate::new(403).set_body_string("Access denied"))
1442 .mount(&server)
1443 .await;
1444
1445 let client = ApiClient::new(server.uri()).unwrap();
1446 let result = client.get_bytes("/bytes-endpoint").await;
1447
1448 match result {
1449 Err(ApiError::Forbidden { message }) => {
1450 assert!(message.contains("Access denied"));
1451 }
1452 other => panic!("Expected Forbidden, got: {:?}", other),
1453 }
1454 }
1455
1456 #[tokio::test]
1459 async fn test_204_no_content_put_succeeds() {
1460 let server = MockServer::start().await;
1461 Mock::given(method("PUT"))
1462 .and(path("issue/AEA-1"))
1463 .respond_with(ResponseTemplate::new(204))
1464 .mount(&server)
1465 .await;
1466
1467 let client = ApiClient::new(server.uri()).unwrap();
1468 let result: error::Result<serde_json::Value> = client
1469 .put("/issue/AEA-1", &serde_json::json!({"fields": {}}))
1470 .await;
1471
1472 match result {
1473 Ok(serde_json::Value::Null) => {}
1474 other => panic!("Expected Ok(Null) for 204, got: {:?}", other),
1475 }
1476 }
1477
1478 #[tokio::test]
1480 async fn test_200_empty_body_succeeds() {
1481 let server = MockServer::start().await;
1482 Mock::given(method("POST"))
1483 .and(path("transitions"))
1484 .respond_with(ResponseTemplate::new(200).set_body_string(" \n"))
1485 .mount(&server)
1486 .await;
1487
1488 let client = ApiClient::new(server.uri()).unwrap();
1489 let result: error::Result<serde_json::Value> =
1490 client.post("/transitions", &serde_json::json!({})).await;
1491
1492 match result {
1493 Ok(serde_json::Value::Null) => {}
1494 other => panic!("Expected Ok(Null) for empty 200, got: {:?}", other),
1495 }
1496 }
1497
1498 #[tokio::test]
1500 async fn test_200_json_body_still_parses() {
1501 let server = MockServer::start().await;
1502 Mock::given(method("GET"))
1503 .and(path("issue/AEA-1"))
1504 .respond_with(
1505 ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "AEA-1"})),
1506 )
1507 .mount(&server)
1508 .await;
1509
1510 let client = ApiClient::new(server.uri()).unwrap();
1511 let result: serde_json::Value = client.get("/issue/AEA-1").await.unwrap();
1512 assert_eq!(result["key"], "AEA-1");
1513 }
1514
1515 #[tokio::test]
1522 async fn test_request_raw_surfaces_non_2xx_without_erroring() {
1523 let server = MockServer::start().await;
1524 Mock::given(method("GET"))
1525 .and(path("/rest/api/3/issue/NOPE-1"))
1526 .respond_with(
1527 ResponseTemplate::new(404)
1528 .set_body_json(serde_json::json!({"errorMessages": ["Issue does not exist"]})),
1529 )
1530 .mount(&server)
1531 .await;
1532
1533 let client = ApiClient::new(server.uri()).unwrap();
1534 let response = client
1535 .request_raw(RawRequest {
1536 method: Method::GET,
1537 path: "/rest/api/3/issue/NOPE-1",
1538 headers: HeaderMap::new(),
1539 body: None,
1540 timeout: None,
1541 })
1542 .await
1543 .unwrap();
1544
1545 assert_eq!(response.status, 404);
1546 assert!(!response.is_success());
1547 assert!(response
1548 .header("Content-Type")
1549 .unwrap()
1550 .contains("application/json"));
1551 assert!(String::from_utf8_lossy(&response.body).contains("Issue does not exist"));
1552 }
1553
1554 #[tokio::test]
1555 async fn test_request_raw_applies_headers_and_body() {
1556 let server = MockServer::start().await;
1557 Mock::given(method("POST"))
1558 .and(path("/rest/api/3/issue"))
1559 .and(header("X-Atlassian-Token", "no-check"))
1560 .and(body_string("{\"fields\":{}}"))
1561 .respond_with(
1562 ResponseTemplate::new(201).set_body_json(serde_json::json!({"key": "A-1"})),
1563 )
1564 .mount(&server)
1565 .await;
1566
1567 let mut headers = HeaderMap::new();
1568 headers.insert("X-Atlassian-Token", "no-check".parse().unwrap());
1569
1570 let client = ApiClient::new(server.uri()).unwrap();
1571 let response = client
1572 .request_raw(RawRequest {
1573 method: Method::POST,
1574 path: "/rest/api/3/issue",
1575 headers,
1576 body: Some(b"{\"fields\":{}}"),
1577 timeout: None,
1578 })
1579 .await
1580 .unwrap();
1581
1582 assert_eq!(response.status, 201);
1583 }
1584
1585 #[tokio::test]
1586 async fn test_request_raw_retries_5xx_for_get() {
1587 let server = MockServer::start().await;
1588 Mock::given(method("GET"))
1589 .and(path("/flaky"))
1590 .respond_with(ResponseTemplate::new(500))
1591 .expect(3)
1592 .mount(&server)
1593 .await;
1594
1595 let client = ApiClient::new(server.uri())
1596 .unwrap()
1597 .with_retry_config(RetryConfig {
1598 initial_interval: Duration::from_millis(1),
1599 ..RetryConfig::default()
1600 });
1601 let response = client
1602 .request_raw(RawRequest {
1603 method: Method::GET,
1604 path: "/flaky",
1605 headers: HeaderMap::new(),
1606 body: None,
1607 timeout: None,
1608 })
1609 .await
1610 .unwrap();
1611
1612 assert_eq!(response.status, 500);
1613 }
1614
1615 #[tokio::test]
1618 async fn test_request_raw_never_retries_post() {
1619 let server = MockServer::start().await;
1620 Mock::given(method("POST"))
1621 .and(path("/create"))
1622 .respond_with(ResponseTemplate::new(503))
1623 .expect(1)
1624 .mount(&server)
1625 .await;
1626
1627 let client = ApiClient::new(server.uri())
1628 .unwrap()
1629 .with_retry_config(RetryConfig {
1630 initial_interval: Duration::from_millis(1),
1631 ..RetryConfig::default()
1632 });
1633 let response = client
1634 .request_raw(RawRequest {
1635 method: Method::POST,
1636 path: "/create",
1637 headers: HeaderMap::new(),
1638 body: Some(b"{}"),
1639 timeout: None,
1640 })
1641 .await
1642 .unwrap();
1643
1644 assert_eq!(response.status, 503);
1645 }
1646
1647 #[tokio::test]
1648 async fn test_request_raw_rejects_cross_host_path() {
1649 let server = MockServer::start().await;
1650 Mock::given(method("GET"))
1651 .respond_with(ResponseTemplate::new(200))
1652 .expect(0)
1653 .mount(&server)
1654 .await;
1655
1656 let client = ApiClient::new(server.uri()).unwrap();
1657 let err = client
1658 .request_raw(RawRequest {
1659 method: Method::GET,
1660 path: "https://evil.example.com/steal",
1661 headers: HeaderMap::new(),
1662 body: None,
1663 timeout: None,
1664 })
1665 .await
1666 .unwrap_err();
1667
1668 assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1669 }
1670
1671 #[test]
1672 fn test_resolve_url_enforces_same_origin() {
1673 let client = ApiClient::new("https://site.atlassian.net").unwrap();
1674
1675 assert_eq!(
1676 client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1677 "https://site.atlassian.net/rest/api/3/myself"
1678 );
1679 assert_eq!(
1681 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1682 "https://site.atlassian.net/rest/api/3/myself"
1683 );
1684 for bad in [
1686 "https://evil.example.com/x",
1687 "http://site.atlassian.net/x",
1688 "https://site.atlassian.net@evil.example.com/",
1689 "//evil.example.com/x",
1690 ] {
1691 let resolved = client.resolve_url(bad);
1692 match resolved {
1693 Err(_) => {}
1694 Ok(url) => assert_eq!(url.host_str(), Some("site.atlassian.net"), "{bad}"),
1697 }
1698 }
1699 }
1700
1701 #[test]
1704 fn test_resolve_url_keeps_the_base_path() {
1705 let client = ApiClient::new("https://api.atlassian.com/ex/jira/cloud-id").unwrap();
1706
1707 assert_eq!(
1708 client.base_url(),
1709 "https://api.atlassian.com/ex/jira/cloud-id/"
1710 );
1711 assert_eq!(
1712 client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1713 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1714 );
1715 assert_eq!(
1716 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1717 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1718 );
1719
1720 let client = ApiClient::new("https://api.atlassian.com/ex/jira/cloud-id/").unwrap();
1722
1723 assert_eq!(
1724 client.base_url(),
1725 "https://api.atlassian.com/ex/jira/cloud-id/"
1726 );
1727 assert_eq!(
1728 client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1729 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1730 );
1731 assert_eq!(
1732 client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1733 "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1734 );
1735 }
1736
1737 #[test]
1742 fn test_resolve_url_keeps_a_context_path() {
1743 let client = ApiClient::new("https://example.com/bamboo").unwrap();
1744
1745 assert_eq!(
1746 client
1747 .resolve_url("/rest/api/latest/plan")
1748 .unwrap()
1749 .as_str(),
1750 "https://example.com/bamboo/rest/api/latest/plan"
1751 );
1752 }
1753
1754 #[test]
1758 fn test_normalisation_does_not_move_existing_product_urls() {
1759 for (base, path, expected) in [
1760 (
1761 "https://x.atlassian.net",
1762 "/rest/api/3/myself",
1763 "https://x.atlassian.net/rest/api/3/myself",
1764 ),
1765 (
1766 "https://x.atlassian.net",
1767 "/wiki/download/attachments/1/f.png?version=1",
1768 "https://x.atlassian.net/wiki/download/attachments/1/f.png?version=1",
1769 ),
1770 (
1771 "https://api.bitbucket.org",
1772 "/2.0/repositories/w/r",
1773 "https://api.bitbucket.org/2.0/repositories/w/r",
1774 ),
1775 (
1778 "https://api.opsgenie.com/v2/",
1779 "alerts/123",
1780 "https://api.opsgenie.com/v2/alerts/123",
1781 ),
1782 ] {
1783 let client = ApiClient::new(base).unwrap();
1784 assert_eq!(
1785 client.resolve_url(path).unwrap().as_str(),
1786 expected,
1787 "{base} + {path}"
1788 );
1789 }
1790 }
1791
1792 #[tokio::test]
1795 async fn test_request_raw_rejects_a_different_port_on_the_same_host() {
1796 let victim = MockServer::start().await;
1797 Mock::given(method("GET"))
1798 .respond_with(ResponseTemplate::new(200).set_body_string("secrets"))
1799 .expect(0)
1800 .mount(&victim)
1801 .await;
1802
1803 let server = MockServer::start().await;
1804 let client = ApiClient::new(server.uri()).unwrap();
1805 let err = client
1806 .request_raw(RawRequest {
1807 method: Method::GET,
1808 path: &format!("{}/steal", victim.uri()),
1809 headers: HeaderMap::new(),
1810 body: None,
1811 timeout: None,
1812 })
1813 .await
1814 .unwrap_err();
1815
1816 assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1817 }
1818
1819 #[tokio::test]
1823 async fn test_request_raw_does_not_follow_a_cross_origin_redirect() {
1824 let evil = MockServer::start().await;
1825 Mock::given(method("POST"))
1826 .respond_with(ResponseTemplate::new(200).set_body_string("pwned"))
1827 .expect(0)
1828 .mount(&evil)
1829 .await;
1830
1831 let server = MockServer::start().await;
1832 Mock::given(method("POST"))
1833 .and(path("/rest/api/3/bounce"))
1834 .respond_with(
1835 ResponseTemplate::new(307)
1836 .insert_header("location", format!("{}/steal", evil.uri()).as_str()),
1837 )
1838 .mount(&server)
1839 .await;
1840
1841 let client = ApiClient::new(server.uri())
1842 .unwrap()
1843 .with_basic_auth("dev@example.com", "token");
1844 let response = client
1845 .request_raw(RawRequest {
1846 method: Method::POST,
1847 path: "/rest/api/3/bounce",
1848 headers: HeaderMap::new(),
1849 body: Some(b"{}"),
1850 timeout: None,
1851 })
1852 .await
1853 .unwrap();
1854
1855 assert_eq!(response.status, 307);
1856 assert!(response.header("location").unwrap().contains("/steal"));
1857 assert_ne!(response.body, b"pwned".to_vec());
1858 }
1859
1860 #[tokio::test]
1862 async fn test_request_raw_follows_a_same_origin_redirect() {
1863 let server = MockServer::start().await;
1864 Mock::given(method("GET"))
1865 .and(path("/from"))
1866 .respond_with(ResponseTemplate::new(302).insert_header("location", "/to"))
1867 .mount(&server)
1868 .await;
1869 Mock::given(method("GET"))
1870 .and(path("/to"))
1871 .respond_with(ResponseTemplate::new(200).set_body_string("arrived"))
1872 .mount(&server)
1873 .await;
1874
1875 let client = ApiClient::new(server.uri()).unwrap();
1876 let response = client
1877 .request_raw(RawRequest {
1878 method: Method::GET,
1879 path: "/from",
1880 headers: HeaderMap::new(),
1881 body: None,
1882 timeout: None,
1883 })
1884 .await
1885 .unwrap();
1886
1887 assert_eq!(response.status, 200);
1888 assert_eq!(response.body, b"arrived".to_vec());
1889 }
1890
1891 #[tokio::test]
1894 async fn test_get_bytes_still_follows_cross_host_redirects() {
1895 let media = MockServer::start().await;
1896 Mock::given(method("GET"))
1897 .and(path("/file/binary"))
1898 .respond_with(ResponseTemplate::new(200).set_body_bytes(b"BYTES".to_vec()))
1899 .mount(&media)
1900 .await;
1901
1902 let server = MockServer::start().await;
1903 Mock::given(method("GET"))
1904 .and(path("/content/1"))
1905 .respond_with(
1906 ResponseTemplate::new(302)
1907 .insert_header("location", format!("{}/file/binary", media.uri()).as_str()),
1908 )
1909 .mount(&server)
1910 .await;
1911
1912 let client = ApiClient::new(server.uri()).unwrap();
1913 assert_eq!(client.get_bytes("/content/1").await.unwrap(), b"BYTES");
1914 }
1915
1916 #[test]
1917 fn test_same_origin_compares_scheme_host_and_port() {
1918 let base = Url::parse("https://site.atlassian.net").unwrap();
1919 assert!(same_origin(
1920 &Url::parse("https://site.atlassian.net/x").unwrap(),
1921 &base
1922 ));
1923 assert!(same_origin(
1925 &Url::parse("https://site.atlassian.net:443/x").unwrap(),
1926 &base
1927 ));
1928 for other in [
1929 "https://site.atlassian.net:8443/x",
1930 "http://site.atlassian.net/x",
1931 "https://evil.example.com/x",
1932 ] {
1933 assert!(
1934 !same_origin(&Url::parse(other).unwrap(), &base),
1935 "{other} must not match"
1936 );
1937 }
1938 }
1939}