Skip to main content

atlassian_cli_api/
lib.rs

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
54/// Compare two URLs by full origin: scheme, host **and port**.
55///
56/// Port matters. Comparing scheme and host alone lets `https://site:8443/x`
57/// through on a `https://site` profile, and on a localhost profile it lets any
58/// other local port receive the profile's credentials.
59fn 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
65/// Make a base URL end with `/`.
66///
67/// `Url::join` drops the base's last path segment unless the base ends with
68/// `/`. Idempotent.
69pub 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
81/// What a 401 says when the server offered no explanation of its own.
82const UNAUTHORIZED_FALLBACK: &str = "Invalid or expired credentials";
83
84/// Keep a quoted server message short enough to stay readable on one screen.
85const MAX_DETAIL_LEN: usize = 200;
86
87/// Build the error for a 401, keeping whatever reason the server gave.
88///
89/// Atlassian's gateway explains *why* it rejected the call, for example
90/// `{"code":401,"message":"Unauthorized; scope does not match"}`. Reporting
91/// every 401 as "Invalid or expired credentials" hid that, so a token missing
92/// one scope looked identical to an expired one and sent people re-issuing
93/// credentials that were fine all along.
94async 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
101/// Combine the generic 401 wording with the server's own message, if any.
102fn 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
109/// Pull the human-readable reason out of a 401 body.
110///
111/// Returns `None` when the body is empty or is an HTML error page, since a
112/// login page's markup tells the user nothing.
113fn 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
131/// Find the message field, across the several shapes Atlassian returns.
132fn 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        // `{"error": {"message": "..."}}`, which some Atlassian services and
140        // most proxies in front of them return. Without this the whole JSON
141        // document ends up quoted at the user as though it were prose.
142        .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        // Jira classic instead returns `{"errorMessages": [...]}`.
150        .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
166/// Replace credential-shaped substrings with a placeholder.
167///
168/// When a body has no field we recognise we quote it whole, and a gateway or
169/// proxy that echoes the request back would then put an `Authorization` value
170/// on the user's terminal, into their shell history and into any CI log
171/// scraping stderr. Tokens are `SecretString` everywhere else precisely so they
172/// cannot leak by accident; this closes the one path that bypasses that.
173///
174/// It looks for the two auth schemes this client sends and drops what follows,
175/// but only when that looks like a credential rather than a word: "Basic auth
176/// is not allowed" is a sentence a server really sends, and mangling it into
177/// "Basic <redacted> is not allowed" would destroy the message to protect
178/// nothing. See `is_credential_shaped`.
179fn scrub_credentials(detail: &str) -> String {
180    const SCHEMES: [&str; 2] = ["bearer ", "basic "];
181
182    // ASCII-lowercase, so byte offsets stay valid in the original. A full
183    // `to_lowercase` can change a string's length and desync the indices.
184    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            // Not a credential. Leave it, but step past the scheme word so the
217            // scan cannot match it again and loop.
218            cursor = value_start;
219        }
220    }
221
222    out
223}
224
225/// Whether the token after an auth scheme is a credential rather than a word.
226///
227/// Credentials here are base64 or a JWT, so: only characters from that
228/// alphabet, and either long enough that no English word reaches it, or
229/// carrying one of the punctuation marks base64 and JWTs use and prose does
230/// not. "auth" and "authentication" fail both tests; `Zm9vOmJhcg==` and
231/// `eyJhbGci....sig` pass on punctuation alone, whatever their length.
232fn 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
248/// Shorten on a char boundary, so multi-byte text cannot panic the slice.
249fn 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
257/// The `Retry-After` delay in seconds, when the server sent one. The HTTP-date
258/// form is ignored; Atlassian sends seconds.
259fn 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
271/// An arbitrary request for [`ApiClient::request_raw`].
272pub struct RawRequest<'a> {
273    pub method: Method,
274    /// Path (and optional query) relative to the client's base URL.
275    pub path: &'a str,
276    pub headers: HeaderMap,
277    pub body: Option<&'a [u8]>,
278    /// Overrides the client-wide 30s timeout for this request only.
279    pub timeout: Option<Duration>,
280}
281
282/// A response with no status-to-error mapping applied.
283#[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    /// Case-insensitive header lookup. Returns the first match.
296    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    /// Same-origin-only redirect policy; used by `request_raw`.
308    raw_client: Client,
309    base_url: Url,
310    auth: Option<AuthMethod>,
311    retry_config: RetryConfig,
312    rate_limiter: RateLimiter,
313}
314
315/// Refuse a request path that the URL parser would restructure.
316///
317/// Every command builds its path with `format!`, so keeping them safe has meant
318/// each call site remembering to encode what it interpolates. That discipline
319/// failed repeatedly, and roughly forty interpolation sites across Jira, JSM and
320/// Opsgenie have never been audited at all. So the check lives here, at the one
321/// point every request passes through.
322///
323/// **Derived from what the parser does, not from a list of characters.** The
324/// first version of this function compared segments against the literal strings
325/// `"."` and `".."`, which missed `%2e%2e` — verified to resolve to the parent
326/// against the `url` version in the lockfile. The WHATWG parser percent-decodes
327/// a segment *once* before deciding whether it is a dot segment, so this does
328/// the same. That also gets the negative case right: `%252e%252e` decodes to
329/// the literal `%2e%2e`, is not a dot segment, and is correctly allowed —
330/// verified, it does not traverse.
331///
332/// Rejected in the path portion:
333///
334/// - any segment that percent-decodes to `.` or `..`;
335/// - a backslash, which the parser treats as a separator;
336/// - a control character, which the parser strips *before* parsing, so
337///   `.<TAB>.` becomes `..`;
338/// - a space, which the parser strips from the ends of the input, so a trailing
339///   one silently addresses the collection.
340///
341/// The query is exempt: JQL and CQL legitimately contain dots, spaces and `..`
342/// ranges, and a query cannot move the request to another resource. A caller
343/// needing any of these literally in a path must percent-encode it, which is
344/// what `encode_path_segment` in the CLI does.
345fn 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    // A `#` anywhere is refused before the split, not exempted by it. A
355    // fragment is never sent on the wire, so an interpolated value containing
356    // one silently truncates the path: a repository slug of `r#x` turns
357    // `DELETE .../hooks/{uuid}` into `DELETE /2.0/repositories/w/r`. No path
358    // this CLI builds contains a literal `#`, so refusing it costs nothing and
359    // closes the whole class centrally, rather than one call site at a time.
360    if path.contains('#') {
361        return Err(restructured("fragment marker truncates the path"));
362    }
363
364    // Everything below concerns the path; a query may legitimately contain a
365    // dot, a space or a backslash, and cannot move the request elsewhere.
366    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    // Only the ends. The parser strips spaces from the ends of the whole input,
375    // so a trailing one addresses the collection -- but an interior space is
376    // encoded harmlessly, and refusing it broke `bb commit browse` on the
377    // ordinary case of a repository file whose name contains a space.
378    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
391/// Whether a raw path segment is a dot segment once decoded.
392///
393/// Decodes exactly once, because that is what the URL parser does: `%2e%2e` is
394/// `..`, while the double-encoded `%252e%252e` is the literal text `%2e%2e` and
395/// addresses a real resource.
396fn is_dot_segment(segment: &str) -> bool {
397    let decoded = decode_once(segment);
398    decoded == "." || decoded == ".."
399}
400
401/// Value of one ASCII hex digit.
402fn 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
411/// Percent-decode a single pass, leaving invalid escapes as written.
412fn decode_once(segment: &str) -> String {
413    // Read the two hex digits from the bytes, never by slicing the `str`.
414    // Slicing panicked on `x-%2é`: byte index 5 lands inside the `é`, so a
415    // perfectly ordinary identifier aborted the process before any request.
416    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        // Enforce HTTPS for security (prevent accidental credential leaks over HTTP)
438        // Allow HTTP only for localhost/127.0.0.1 (for testing)
439        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        // `request_raw` sends user-chosen methods, bodies and headers, so it gets
461        // a client that refuses to leave the profile's origin. The default
462        // policy would follow a `Location` anywhere: reqwest strips
463        // `Authorization` cross-host, but 307/308 replay the body and custom
464        // `-H` headers are not stripped. A stopped redirect is returned to the
465        // caller as the 3xx itself, which is the transparent answer for a
466        // passthrough. The normal `client` keeps following redirects, because
467        // attachment downloads depend on the cross-host hop to Atlassian's
468        // media host.
469        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    /// Safely join a path to the base URL, ensuring the origin remains unchanged
496    /// to prevent SSRF attacks.
497    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    /// Returns a reference to the underlying HTTP client for raw requests (e.g., multipart uploads).
550    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    /// DELETE that expects 204 No Content (no response body).
588    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    /// Get plain text content from an endpoint.
671    /// Sets Accept: text/plain; charset=utf-8 header.
672    /// Includes retry logic and rate limiting.
673    /// Issue a GET and return one response header, discarding the body.
674    ///
675    /// Exists for `auth scopes`: Bitbucket reports a token's granted scopes in
676    /// `x-oauth-scopes` on any authenticated response, so the scopes can be
677    /// read without a dedicated endpoint and without caching anything locally.
678    ///
679    /// Deliberately no retry wrapper: this is a diagnostic, and a caller asking
680    /// "what does my token grant" wants the answer or the error now, not three
681    /// backed-off attempts.
682    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    /// Resolve `path` against the base URL, applying the same-origin (SSRF)
815    /// check used by every request. Public so callers can validate or preview a
816    /// path without sending anything.
817    pub fn resolve_url(&self, path: &str) -> Result<Url> {
818        self.safe_join(path)
819    }
820
821    /// Send an arbitrary request and return the status, headers and body bytes.
822    ///
823    /// Unlike [`ApiClient::request`], a non-2xx status is returned as
824    /// `Ok(RawResponse)` rather than mapped to an [`ApiError`], so callers can
825    /// surface the API's own error body. Only transport failures and URL
826    /// validation produce `Err`. Same-origin validation, auth and rate limiting
827    /// still apply.
828    ///
829    /// Retries on 429/5xx are limited to idempotent methods. `request` retries
830    /// POSTs, which can double-create; a raw passthrough must not inherit that.
831    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        // retry_with_backoff cannot be used here: its closure must signal a
845        // retryable outcome as Err, which would discard the RawResponse we have
846        // to return on the final attempt.
847        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                    // A 429 says how long to wait; obey it rather than racing
871                    // back in after a short exponential sleep.
872                    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    /// Get binary content from an endpoint.
909    /// Includes retry logic and rate limiting.
910    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                    // Successful responses with an empty (or whitespace-only) body,
1063                    // e.g. HTTP 204 No Content from Jira update/transition/assign and
1064                    // most DELETEs, are treated as JSON `null`. Callers that discard
1065                    // the body (`let _: Value`) then succeed instead of failing to
1066                    // parse an empty body as JSON.
1067                    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    /// The net beneath per-call-site encoding. Each of these was verified
1118    /// against the `url` version in the lockfile to resolve somewhere other
1119    /// than the segment it names.
1120    #[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            // Percent-encoded dot segments: the first version of this guard
1131            // compared against the literal strings and let every one of these
1132            // through, each of which resolves to the parent.
1133            "/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            // A trailing space is stripped from the input, addressing the
1138            // collection rather than a member.
1139            "/rest/api/3/issue/ ",
1140        ] {
1141            assert!(
1142                reject_restructuring_path(bad).is_err(),
1143                "{bad:?} must be refused"
1144            );
1145        }
1146    }
1147
1148    /// `decode_once` byte-sliced the `str` and aborted the process on an
1149    /// ordinary identifier: byte index 5 of `x-%2é` lands inside the `é`.
1150    #[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        // And the request path built from one is refused or accepted, not a crash.
1157        assert!(reject_restructuring_path("/rest/api/3/issue/x-%2é").is_ok());
1158    }
1159
1160    /// A `#` in an interpolated value truncates the path, so it is refused
1161    /// before the query split rather than exempted by it. A slug of `r#x`
1162    /// turned a webhook delete into a repository delete.
1163    #[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    /// An interior space is encoded harmlessly by the parser; only the ends are
1171    /// stripped. Refusing every space broke `bb commit browse` for a repository
1172    /// file whose name contains one.
1173    #[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        // A space in the query is untouched.
1180        assert!(reject_restructuring_path("/rest/api/3/search/jql?jql=a = b").is_ok());
1181    }
1182
1183    /// Double-encoding is not traversal: `%252e%252e` is the literal text
1184    /// `%2e%2e` and addresses a real resource. Rejecting it would be a false
1185    /// positive, and decoding more than once would cause one.
1186    #[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    /// The decoder must not mangle a segment that merely contains a `%`.
1194    #[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    /// A query value is not part of the path and cannot move the request, so
1202    /// JQL containing a dot or a `..` range must still be allowed through.
1203    #[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                // No body, so the generic wording is all we can say.
1249                assert_eq!(message, UNAUTHORIZED_FALLBACK);
1250            }
1251            other => panic!("Expected AuthenticationFailed, got: {:?}", other),
1252        }
1253    }
1254
1255    /// The scope-mismatch case that cost a full debugging session: the gateway
1256    /// explained itself and the CLI threw the explanation away.
1257    #[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        // The whole document must not be quoted back as though it were prose.
1316        assert!(
1317            !message.contains("{\"error\""),
1318            "raw JSON leaked: {message}"
1319        );
1320    }
1321
1322    /// A body we cannot parse is quoted whole, so a proxy echoing the request
1323    /// back would otherwise print the token we just sent it.
1324    #[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    /// "Basic auth is not allowed" is a sentence servers really send. Redacting
1359    /// the word after the scheme would destroy the message to protect nothing.
1360    #[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    /// Multi-byte text must not panic the truncation slice.
1410    #[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    // Regression for #45: a successful PUT/POST returning HTTP 204 No Content (empty
1457    // body) must not fail JSON parsing. Callers discard the body as `Value`.
1458    #[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    // A 200 with an empty/whitespace-only body is also treated as null.
1479    #[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    // A non-empty JSON body on success still parses normally.
1499    #[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    // -----------------------------------------------------------------------
1516    // request_raw
1517    // -----------------------------------------------------------------------
1518
1519    /// The point of the raw path: a non-2xx status is data, not an error, so the
1520    /// API's own error body survives instead of being replaced by ApiError.
1521    #[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    /// Replaying a POST can double-create. `request` does retry POSTs; the raw
1616    /// path deliberately does not inherit that.
1617    #[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        // Relative paths work with or without the leading slash.
1680        assert_eq!(
1681            client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1682            "https://site.atlassian.net/rest/api/3/myself"
1683        );
1684        // Other hosts, scheme downgrades and userinfo tricks are all rejected.
1685        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                // A protocol-relative path is not treated as a host by `join`;
1695                // pin the behaviour so a future change cannot silently open it up.
1696                Ok(url) => assert_eq!(url.host_str(), Some("site.atlassian.net"), "{bad}"),
1697            }
1698        }
1699    }
1700
1701    /// Regression: a base URL carrying a path lost its last segment, so the
1702    /// API-gateway form used by scoped API tokens dropped the cloud id.
1703    #[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        // A base written with the trailing slash resolves the same way.
1721        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    /// The same fix covers a self-hosted product behind a context path, which is
1738    /// the ordinary way Bamboo is deployed. Before this, `/rest/api/latest/plan`
1739    /// against `https://example.com/bamboo` resolved to `https://example.com/rest/...`
1740    /// and 404'd.
1741    #[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    /// Guard against the fix shifting any URL a working profile already resolves.
1755    /// Every shape below is byte-identical before and after normalisation; only
1756    /// the previously broken path-bearing bases move.
1757    #[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            // Opsgenie's base already carries a path and already ends in a
1776            // slash, and its request paths are relative, so it is untouched.
1777            (
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    /// Regression: comparing scheme and host but not port let any other port on
1793    /// the same host receive the profile's credentials.
1794    #[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    /// `safe_join` only sees the first URL, so a same-origin endpoint could
1820    /// otherwise bounce a write-capable request anywhere. The raw client stops
1821    /// at the redirect and hands the 3xx back instead of following it.
1822    #[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    /// Same-origin redirects are still followed, so ordinary endpoints work.
1861    #[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    /// Attachment downloads depend on the cross-host hop to the media host, so
1892    /// the ordinary client must keep following redirects.
1893    #[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        // 443 is the known default for https, so an explicit port still matches.
1924        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}