Skip to main content

atlassian_cli_api/
lib.rs

1pub mod error;
2pub mod pagination;
3pub mod ratelimit;
4mod response;
5pub mod retry;
6
7use backoff::backoff::Backoff;
8use error::{ApiError, Result};
9use ratelimit::RateLimiter;
10use reqwest::header::HeaderMap;
11use reqwest::{Client, Method, RequestBuilder, StatusCode};
12use response::{error_for_status, log_error_body, log_response, redact_url};
13use retry::{retry_with_backoff, RetryConfig};
14use secrecy::{ExposeSecret, SecretString};
15use serde::de::DeserializeOwned;
16use serde::Serialize;
17use std::fmt;
18use std::time::{Duration, Instant};
19use tracing::{debug, error, warn};
20use url::Url;
21
22#[derive(Clone)]
23pub enum AuthMethod {
24    Basic {
25        username: String,
26        token: SecretString,
27    },
28    Bearer {
29        token: SecretString,
30    },
31    GenieKey {
32        api_key: SecretString,
33    },
34}
35
36impl fmt::Debug for AuthMethod {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            AuthMethod::Basic { username, .. } => f
40                .debug_struct("Basic")
41                .field("username", username)
42                .field("token", &"[REDACTED]")
43                .finish(),
44            AuthMethod::Bearer { .. } => f
45                .debug_struct("Bearer")
46                .field("token", &"[REDACTED]")
47                .finish(),
48            AuthMethod::GenieKey { .. } => f
49                .debug_struct("GenieKey")
50                .field("api_key", &"[REDACTED]")
51                .finish(),
52        }
53    }
54}
55
56/// Compare two URLs by full origin: scheme, host **and port**.
57///
58/// Port matters. Comparing scheme and host alone lets `https://site:8443/x`
59/// through on a `https://site` profile, and on a localhost profile it lets any
60/// other local port receive the profile's credentials.
61fn same_origin(a: &Url, b: &Url) -> bool {
62    a.scheme() == b.scheme()
63        && a.host() == b.host()
64        && a.port_or_known_default() == b.port_or_known_default()
65}
66
67/// Make a base URL end with `/`.
68///
69/// `Url::join` drops the base's last path segment unless the base ends with
70/// `/`. Idempotent.
71pub fn normalize_base_url(mut url: Url) -> Url {
72    if url.cannot_be_a_base() {
73        return url;
74    }
75
76    let path = url.path();
77    if !path.ends_with('/') {
78        url.set_path(&format!("{path}/"));
79    }
80    url
81}
82
83/// What a 401 says when the server offered no explanation of its own.
84const UNAUTHORIZED_FALLBACK: &str = "Invalid or expired credentials";
85
86/// Keep a quoted server message short enough to stay readable on one screen.
87const MAX_DETAIL_LEN: usize = 200;
88
89/// Combine the generic 401 wording with the server's own message, if any.
90///
91/// Atlassian's gateway explains *why* it rejected the call, for example
92/// `{"code":401,"message":"Unauthorized; scope does not match"}`. Reporting
93/// every 401 as "Invalid or expired credentials" hid that, so a token missing
94/// one scope looked identical to an expired one and sent people re-issuing
95/// credentials that were fine all along.
96pub(crate) fn unauthorized_message(body: &str) -> String {
97    match unauthorized_detail(body) {
98        Some(detail) => format!("{UNAUTHORIZED_FALLBACK} ({detail})"),
99        None => UNAUTHORIZED_FALLBACK.to_string(),
100    }
101}
102
103/// Pull the human-readable reason out of a 401 body.
104///
105/// Returns `None` when the body is empty or is an HTML error page, since a
106/// login page's markup tells the user nothing.
107fn unauthorized_detail(body: &str) -> Option<String> {
108    let trimmed = body.trim();
109    if trimmed.is_empty() || trimmed.starts_with('<') {
110        return None;
111    }
112
113    let detail = serde_json::from_str::<serde_json::Value>(trimmed)
114        .ok()
115        .and_then(|value| json_error_detail(&value))
116        .unwrap_or_else(|| trimmed.to_string());
117
118    let detail = detail.trim();
119    if detail.is_empty() {
120        return None;
121    }
122    Some(truncate_detail(&scrub_credentials(detail)))
123}
124
125/// Find the message field, across the several shapes Atlassian returns.
126fn json_error_detail(value: &serde_json::Value) -> Option<String> {
127    let direct = ["message", "error_description", "error"]
128        .iter()
129        .find_map(|key| value.get(*key).and_then(|v| v.as_str()))
130        .map(str::to_string);
131
132    direct
133        // `{"error": {"message": "..."}}`, which some Atlassian services and
134        // most proxies in front of them return. Without this the whole JSON
135        // document ends up quoted at the user as though it were prose.
136        .or_else(|| {
137            value
138                .get("error")
139                .and_then(|e| e.get("message").or_else(|| e.get("description")))
140                .and_then(|v| v.as_str())
141                .map(str::to_string)
142        })
143        // Jira classic instead returns `{"errorMessages": [...]}`.
144        .or_else(|| {
145            value
146                .get("errorMessages")
147                .and_then(|v| v.as_array())
148                .map(|messages| {
149                    messages
150                        .iter()
151                        .filter_map(|m| m.as_str())
152                        .collect::<Vec<_>>()
153                        .join("; ")
154                })
155        })
156        .map(|detail| detail.trim().to_string())
157        .filter(|detail| !detail.is_empty())
158}
159
160/// Replace credential-shaped substrings with a placeholder.
161///
162/// When a body has no field we recognise we quote it whole, and a gateway or
163/// proxy that echoes the request back would then put an `Authorization` value
164/// on the user's terminal, into their shell history and into any CI log
165/// scraping stderr. Tokens are `SecretString` everywhere else precisely so they
166/// cannot leak by accident; this closes the one path that bypasses that.
167///
168/// It looks for the two auth schemes this client sends and drops what follows,
169/// but only when that looks like a credential rather than a word: "Basic auth
170/// is not allowed" is a sentence a server really sends, and mangling it into
171/// "Basic <redacted> is not allowed" would destroy the message to protect
172/// nothing. See `is_credential_shaped`.
173pub(crate) fn scrub_credentials(detail: &str) -> String {
174    const SCHEMES: [&str; 2] = ["bearer ", "basic "];
175
176    // ASCII-lowercase, so byte offsets stay valid in the original. A full
177    // `to_lowercase` can change a string's length and desync the indices.
178    let haystack = detail.to_ascii_lowercase();
179
180    let mut out = String::with_capacity(detail.len());
181    let mut cursor = 0;
182
183    while cursor < detail.len() {
184        let found = SCHEMES
185            .iter()
186            .filter_map(|scheme| {
187                haystack[cursor..]
188                    .find(scheme)
189                    .map(|at| (cursor + at, *scheme))
190            })
191            .min_by_key(|(at, _)| *at);
192
193        let Some((at, scheme)) = found else {
194            out.push_str(&detail[cursor..]);
195            break;
196        };
197
198        let value_start = at + scheme.len();
199        out.push_str(&detail[cursor..value_start]);
200
201        let value_end = detail[value_start..]
202            .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | '}' | ']' | ')'))
203            .map(|offset| value_start + offset)
204            .unwrap_or(detail.len());
205
206        if is_credential_shaped(&detail[value_start..value_end]) {
207            out.push_str("<redacted>");
208            cursor = value_end;
209        } else {
210            // Not a credential. Leave it, but step past the scheme word so the
211            // scan cannot match it again and loop.
212            cursor = value_start;
213        }
214    }
215
216    out
217}
218
219/// Whether the token after an auth scheme is a credential rather than a word.
220///
221/// Credentials here are base64 or a JWT, so: only characters from that
222/// alphabet, and either long enough that no English word reaches it, or
223/// carrying one of the punctuation marks base64 and JWTs use and prose does
224/// not. "auth" and "authentication" fail both tests; `Zm9vOmJhcg==` and
225/// `eyJhbGci....sig` pass on punctuation alone, whatever their length.
226fn is_credential_shaped(token: &str) -> bool {
227    const MIN_OPAQUE_LEN: usize = 16;
228
229    if token.len() < 4 {
230        return false;
231    }
232    if !token
233        .chars()
234        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '.' | '_' | '-'))
235    {
236        return false;
237    }
238
239    token.len() >= MIN_OPAQUE_LEN || token.contains(['+', '/', '=', '.'])
240}
241
242/// Shorten on a char boundary, so multi-byte text cannot panic the slice.
243fn truncate_detail(detail: &str) -> String {
244    if detail.chars().count() <= MAX_DETAIL_LEN {
245        return detail.to_string();
246    }
247    let short: String = detail.chars().take(MAX_DETAIL_LEN).collect();
248    format!("{short}...")
249}
250
251/// The `Retry-After` delay in seconds, when the server sent one. The HTTP-date
252/// form is ignored; Atlassian sends seconds.
253fn retry_after(response: &reqwest::Response) -> Option<Duration> {
254    response
255        .headers()
256        .get(reqwest::header::RETRY_AFTER)?
257        .to_str()
258        .ok()?
259        .trim()
260        .parse::<u64>()
261        .ok()
262        .map(Duration::from_secs)
263}
264
265/// An arbitrary request for [`ApiClient::request_raw`].
266pub struct RawRequest<'a> {
267    pub method: Method,
268    /// Path (and optional query) relative to the client's base URL.
269    pub path: &'a str,
270    pub headers: HeaderMap,
271    pub body: Option<&'a [u8]>,
272    /// Overrides the client-wide 30s timeout for this request only.
273    pub timeout: Option<Duration>,
274}
275
276/// A response with no status-to-error mapping applied.
277#[derive(Debug, Clone)]
278pub struct RawResponse {
279    pub status: u16,
280    pub headers: Vec<(String, String)>,
281    pub body: Vec<u8>,
282}
283
284impl RawResponse {
285    pub fn is_success(&self) -> bool {
286        (200..300).contains(&self.status)
287    }
288
289    /// Case-insensitive header lookup. Returns the first match.
290    pub fn header(&self, name: &str) -> Option<&str> {
291        self.headers
292            .iter()
293            .find(|(key, _)| key.eq_ignore_ascii_case(name))
294            .map(|(_, value)| value.as_str())
295    }
296}
297
298#[derive(Clone)]
299pub struct ApiClient {
300    client: Client,
301    /// Same-origin-only redirect policy; used by `request_raw`.
302    raw_client: Client,
303    base_url: Url,
304    auth: Option<AuthMethod>,
305    retry_config: RetryConfig,
306    rate_limiter: RateLimiter,
307}
308
309/// Refuse a request path that the URL parser would restructure.
310///
311/// Every command builds its path with `format!`, so keeping them safe has meant
312/// each call site remembering to encode what it interpolates. That discipline
313/// failed repeatedly, and roughly forty interpolation sites across Jira, JSM and
314/// Opsgenie have never been audited at all. So the check lives here, at the one
315/// point every request passes through.
316///
317/// **Derived from what the parser does, not from a list of characters.** The
318/// first version of this function compared segments against the literal strings
319/// `"."` and `".."`, which missed `%2e%2e` — verified to resolve to the parent
320/// against the `url` version in the lockfile. The WHATWG parser percent-decodes
321/// a segment *once* before deciding whether it is a dot segment, so this does
322/// the same. That also gets the negative case right: `%252e%252e` decodes to
323/// the literal `%2e%2e`, is not a dot segment, and is correctly allowed —
324/// verified, it does not traverse.
325///
326/// Rejected in the path portion:
327///
328/// - any segment that percent-decodes to `.` or `..`;
329/// - a backslash, which the parser treats as a separator;
330/// - a control character, which the parser strips *before* parsing, so
331///   `.<TAB>.` becomes `..`;
332/// - a space, which the parser strips from the ends of the input, so a trailing
333///   one silently addresses the collection.
334///
335/// The query is exempt: JQL and CQL legitimately contain dots, spaces and `..`
336/// ranges, and a query cannot move the request to another resource. A caller
337/// needing any of these literally in a path must percent-encode it, which is
338/// what `encode_path_segment` in the CLI does.
339fn reject_restructuring_path(path: &str) -> Result<()> {
340    let restructured = |reason: &str| {
341        debug!(
342            path,
343            reason, "Refusing a path the URL parser would restructure"
344        );
345        ApiError::InvalidUrl(url::ParseError::InvalidDomainCharacter)
346    };
347
348    // A `#` anywhere is refused before the split, not exempted by it. A
349    // fragment is never sent on the wire, so an interpolated value containing
350    // one silently truncates the path: a repository slug of `r#x` turns
351    // `DELETE .../hooks/{uuid}` into `DELETE /2.0/repositories/w/r`. No path
352    // this CLI builds contains a literal `#`, so refusing it costs nothing and
353    // closes the whole class centrally, rather than one call site at a time.
354    if path.contains('#') {
355        return Err(restructured("fragment marker truncates the path"));
356    }
357
358    // Everything below concerns the path; a query may legitimately contain a
359    // dot, a space or a backslash, and cannot move the request elsewhere.
360    let path_only = path.split('?').next().unwrap_or(path);
361
362    if path_only.chars().any(|c| c.is_control()) {
363        return Err(restructured("control character"));
364    }
365    if path_only.contains('\\') {
366        return Err(restructured("backslash is a path separator"));
367    }
368    // Only the ends. The parser strips spaces from the ends of the whole input,
369    // so a trailing one addresses the collection -- but an interior space is
370    // encoded harmlessly, and refusing it broke `bb commit browse` on the
371    // ordinary case of a repository file whose name contains a space.
372    if path_only.trim_matches(' ') != path_only {
373        return Err(restructured("leading or trailing space is stripped"));
374    }
375
376    for segment in path_only.split('/') {
377        if is_dot_segment(segment) {
378            return Err(restructured("dot component"));
379        }
380    }
381
382    Ok(())
383}
384
385/// Whether a raw path segment is a dot segment once decoded.
386///
387/// Decodes exactly once, because that is what the URL parser does: `%2e%2e` is
388/// `..`, while the double-encoded `%252e%252e` is the literal text `%2e%2e` and
389/// addresses a real resource.
390fn is_dot_segment(segment: &str) -> bool {
391    let decoded = decode_once(segment);
392    decoded == "." || decoded == ".."
393}
394
395/// Value of one ASCII hex digit.
396fn hex_value(byte: u8) -> Option<u8> {
397    match byte {
398        b'0'..=b'9' => Some(byte - b'0'),
399        b'a'..=b'f' => Some(byte - b'a' + 10),
400        b'A'..=b'F' => Some(byte - b'A' + 10),
401        _ => None,
402    }
403}
404
405/// Percent-decode a single pass, leaving invalid escapes as written.
406fn decode_once(segment: &str) -> String {
407    // Read the two hex digits from the bytes, never by slicing the `str`.
408    // Slicing panicked on `x-%2é`: byte index 5 lands inside the `é`, so a
409    // perfectly ordinary identifier aborted the process before any request.
410    let bytes = segment.as_bytes();
411    let mut out = Vec::with_capacity(bytes.len());
412    let mut i = 0;
413    while i < bytes.len() {
414        if bytes[i] == b'%' && i + 2 < bytes.len() {
415            if let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2])) {
416                out.push(hi * 16 + lo);
417                i += 3;
418                continue;
419            }
420        }
421        out.push(bytes[i]);
422        i += 1;
423    }
424    String::from_utf8_lossy(&out).into_owned()
425}
426
427impl ApiClient {
428    pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
429        let url = Url::parse(base_url.as_ref()).map_err(ApiError::InvalidUrl)?;
430
431        // Enforce HTTPS for security (prevent accidental credential leaks over HTTP)
432        // Allow HTTP only for localhost/127.0.0.1 (for testing)
433        if url.scheme() != "https" {
434            let is_localhost = url
435                .host_str()
436                .map(|h| h == "localhost" || h == "127.0.0.1" || h.starts_with("127."))
437                .unwrap_or(false);
438
439            if !is_localhost {
440                return Err(ApiError::InvalidUrl(
441                    url::ParseError::InvalidDomainCharacter,
442                ));
443            }
444        }
445
446        let url = normalize_base_url(url);
447
448        let client = Client::builder()
449            .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
450            .timeout(Duration::from_secs(30))
451            .build()
452            .map_err(ApiError::RequestFailed)?;
453
454        // `request_raw` sends user-chosen methods, bodies and headers, so it gets
455        // a client that refuses to leave the profile's origin. The default
456        // policy would follow a `Location` anywhere: reqwest strips
457        // `Authorization` cross-host, but 307/308 replay the body and custom
458        // `-H` headers are not stripped. A stopped redirect is returned to the
459        // caller as the 3xx itself, which is the transparent answer for a
460        // passthrough. The normal `client` keeps following redirects, because
461        // attachment downloads depend on the cross-host hop to Atlassian's
462        // media host.
463        let origin = url.clone();
464        let raw_client = Client::builder()
465            .user_agent(format!("atlassian-cli/{}", env!("CARGO_PKG_VERSION")))
466            .timeout(Duration::from_secs(30))
467            .redirect(reqwest::redirect::Policy::custom(move |attempt| {
468                if attempt.previous().len() >= 10 {
469                    attempt.error("too many redirects")
470                } else if same_origin(attempt.url(), &origin) {
471                    attempt.follow()
472                } else {
473                    attempt.stop()
474                }
475            }))
476            .build()
477            .map_err(ApiError::RequestFailed)?;
478
479        Ok(Self {
480            client,
481            raw_client,
482            base_url: url,
483            auth: None,
484            retry_config: RetryConfig::default(),
485            rate_limiter: RateLimiter::new(),
486        })
487    }
488
489    /// Safely join a path to the base URL, ensuring the origin remains unchanged
490    /// to prevent SSRF attacks.
491    fn safe_join(&self, path: &str) -> Result<Url> {
492        reject_restructuring_path(path)?;
493
494        let joined = self
495            .base_url
496            .join(path.strip_prefix('/').unwrap_or(path))
497            .map_err(ApiError::InvalidUrl)?;
498
499        if !same_origin(&joined, &self.base_url) {
500            return Err(ApiError::InvalidUrl(
501                url::ParseError::InvalidDomainCharacter,
502            ));
503        }
504
505        Ok(joined)
506    }
507
508    pub fn with_basic_auth(
509        mut self,
510        username: impl Into<String>,
511        token: impl Into<String>,
512    ) -> Self {
513        self.auth = Some(AuthMethod::Basic {
514            username: username.into(),
515            token: SecretString::from(token.into()),
516        });
517        self
518    }
519
520    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
521        self.auth = Some(AuthMethod::Bearer {
522            token: SecretString::from(token.into()),
523        });
524        self
525    }
526
527    pub fn with_genie_key(mut self, api_key: impl Into<String>) -> Self {
528        self.auth = Some(AuthMethod::GenieKey {
529            api_key: SecretString::from(api_key.into()),
530        });
531        self
532    }
533
534    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
535        self.retry_config = config;
536        self
537    }
538
539    pub fn base_url(&self) -> &str {
540        self.base_url.as_str()
541    }
542
543    /// Returns a reference to the underlying HTTP client for raw requests (e.g., multipart uploads).
544    pub fn http_client(&self) -> &Client {
545        &self.client
546    }
547
548    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
549        self.request(Method::GET, path, Option::<&()>::None).await
550    }
551
552    pub async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
553        &self,
554        path: &str,
555        body: &B,
556    ) -> Result<T> {
557        self.request(Method::POST, path, Some(body)).await
558    }
559
560    pub async fn put<T: DeserializeOwned, B: Serialize + ?Sized>(
561        &self,
562        path: &str,
563        body: &B,
564    ) -> Result<T> {
565        self.request(Method::PUT, path, Some(body)).await
566    }
567
568    pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
569        self.request(Method::DELETE, path, Option::<&()>::None)
570            .await
571    }
572
573    pub async fn delete_with_body<T: DeserializeOwned, B: Serialize + ?Sized>(
574        &self,
575        path: &str,
576        body: &B,
577    ) -> Result<T> {
578        self.request(Method::DELETE, path, Some(body)).await
579    }
580
581    /// DELETE that expects 204 No Content (no response body).
582    pub async fn delete_no_content(&self, path: &str) -> Result<()> {
583        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
584            warn!(wait_secs, "Rate limit reached, waiting");
585            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
586        }
587
588        let joined = self.safe_join(path)?;
589
590        debug!(method = "DELETE", url = %redact_url(&joined), "Sending delete (no content) request");
591
592        retry_with_backoff(&self.retry_config, || async {
593            let mut req = self.client.request(Method::DELETE, joined.clone());
594            req = self.apply_auth(req);
595
596            let started = Instant::now();
597            let response = req.send().await.map_err(ApiError::RequestFailed)?;
598
599            self.rate_limiter.update_from_response(&response).await;
600
601            let status = response.status();
602            log_response(&Method::DELETE, &joined, status, started.elapsed());
603            if !status.is_success() {
604                return Err(error_for_status(response, &joined).await);
605            }
606            Ok(())
607        })
608        .await
609    }
610
611    /// Get plain text content from an endpoint.
612    /// Sets Accept: text/plain; charset=utf-8 header.
613    /// Includes retry logic and rate limiting.
614    /// Issue a GET and return one response header, discarding the body.
615    ///
616    /// Exists for `auth scopes`: Bitbucket reports a token's granted scopes in
617    /// `x-oauth-scopes` on any authenticated response, so the scopes can be
618    /// read without a dedicated endpoint and without caching anything locally.
619    ///
620    /// Deliberately no retry wrapper: this is a diagnostic, and a caller asking
621    /// "what does my token grant" wants the answer or the error now, not three
622    /// backed-off attempts.
623    pub async fn response_header(&self, path: &str, header: &str) -> Result<Option<String>> {
624        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
625            warn!(wait_secs, "Rate limit reached, waiting");
626            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
627        }
628
629        let joined = self.safe_join(path)?;
630        debug!(method = "GET", url = %redact_url(&joined), header, "Reading response header");
631
632        let mut req = self.client.request(Method::GET, joined.clone());
633        req = self.apply_auth(req);
634        let started = Instant::now();
635        let response = req.send().await.map_err(ApiError::RequestFailed)?;
636
637        self.rate_limiter.update_from_response(&response).await;
638
639        let status = response.status();
640        log_response(&Method::GET, &joined, status, started.elapsed());
641        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
642            return Err(error_for_status(response, &joined).await);
643        }
644
645        Ok(response
646            .headers()
647            .get(header)
648            .and_then(|value| value.to_str().ok())
649            .map(str::to_string))
650    }
651
652    pub async fn get_text(&self, path: &str) -> Result<String> {
653        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
654            warn!(wait_secs, "Rate limit reached, waiting");
655            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
656        }
657
658        let joined = self.safe_join(path)?;
659
660        debug!(method = "GET", url = %redact_url(&joined), "Sending text request");
661
662        let result = retry_with_backoff(&self.retry_config, || async {
663            let mut req = self.client.request(Method::GET, joined.clone());
664            req = self.apply_auth(req);
665            req = req.header("Accept", "text/plain, */*;q=0.1");
666
667            let started = Instant::now();
668            let response = req.send().await.map_err(ApiError::RequestFailed)?;
669
670            self.rate_limiter.update_from_response(&response).await;
671
672            let status = response.status();
673            log_response(&Method::GET, &joined, status, started.elapsed());
674            if !status.is_success() {
675                return Err(error_for_status(response, &joined).await);
676            }
677            response.text().await.map_err(|e| {
678                error!("Failed to read text response: {}", e);
679                ApiError::InvalidResponse(e.to_string())
680            })
681        })
682        .await?;
683
684        Ok(result)
685    }
686
687    /// Resolve `path` against the base URL, applying the same-origin (SSRF)
688    /// check used by every request. Public so callers can validate or preview a
689    /// path without sending anything.
690    pub fn resolve_url(&self, path: &str) -> Result<Url> {
691        self.safe_join(path)
692    }
693
694    /// Send an arbitrary request and return the status, headers and body bytes.
695    ///
696    /// Unlike [`ApiClient::request`], a non-2xx status is returned as
697    /// `Ok(RawResponse)` rather than mapped to an [`ApiError`], so callers can
698    /// surface the API's own error body. Only transport failures and URL
699    /// validation produce `Err`. Same-origin validation, auth and rate limiting
700    /// still apply.
701    ///
702    /// Retries on 429/5xx are limited to idempotent methods. `request` retries
703    /// POSTs, which can double-create; a raw passthrough must not inherit that.
704    pub async fn request_raw(&self, req: RawRequest<'_>) -> Result<RawResponse> {
705        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
706            warn!(wait_secs, "Rate limit reached, waiting");
707            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
708        }
709
710        let joined = self.safe_join(req.path)?;
711        // The body's size only: a raw request body can carry anything.
712        debug!(
713            method = %req.method,
714            url = %redact_url(&joined),
715            body_bytes = req.body.map(<[u8]>::len),
716            "Sending raw request"
717        );
718
719        let idempotent = matches!(
720            req.method,
721            Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
722        );
723        // retry_with_backoff cannot be used here: its closure must signal a
724        // retryable outcome as Err, which would discard the RawResponse we have
725        // to return on the final attempt.
726        let mut backoff = self.retry_config.backoff();
727        let mut attempts = 0usize;
728
729        loop {
730            attempts += 1;
731
732            let mut builder = self.raw_client.request(req.method.clone(), joined.clone());
733            builder = self.apply_auth(builder);
734            builder = builder.headers(req.headers.clone());
735            if let Some(body) = req.body {
736                builder = builder.body(body.to_vec());
737            }
738            if let Some(timeout) = req.timeout {
739                builder = builder.timeout(timeout);
740            }
741
742            let started = Instant::now();
743            let response = builder.send().await.map_err(ApiError::RequestFailed)?;
744            self.rate_limiter.update_from_response(&response).await;
745            let status = response.status();
746            log_response(&req.method, &joined, status, started.elapsed());
747
748            let retryable = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error();
749            if idempotent && retryable && attempts < self.retry_config.max_retries {
750                if let Some(wait) = backoff.next_backoff() {
751                    // A 429 says how long to wait; obey it rather than racing
752                    // back in after a short exponential sleep.
753                    let wait = retry_after(&response).unwrap_or(wait);
754                    warn!(
755                        status = status.as_u16(),
756                        attempt = attempts,
757                        wait_ms = wait.as_millis(),
758                        "Raw request failed, retrying"
759                    );
760                    tokio::time::sleep(wait).await;
761                    continue;
762                }
763            }
764
765            let headers = response
766                .headers()
767                .iter()
768                .map(|(name, value)| {
769                    (
770                        name.as_str().to_string(),
771                        value.to_str().unwrap_or_default().to_string(),
772                    )
773                })
774                .collect();
775            let body = response
776                .bytes()
777                .await
778                .map_err(|err| ApiError::InvalidResponse(err.to_string()))?
779                .to_vec();
780            if !status.is_success() {
781                log_error_body(status, &joined, &String::from_utf8_lossy(&body));
782            }
783
784            return Ok(RawResponse {
785                status: status.as_u16(),
786                headers,
787                body,
788            });
789        }
790    }
791
792    /// Get binary content from an endpoint.
793    /// Includes retry logic and rate limiting.
794    pub async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
795        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
796            warn!(wait_secs, "Rate limit reached, waiting");
797            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
798        }
799
800        let joined = self.safe_join(path)?;
801
802        debug!(method = "GET", url = %redact_url(&joined), "Sending bytes request");
803
804        let result = retry_with_backoff(&self.retry_config, || async {
805            let mut req = self.client.request(Method::GET, joined.clone());
806            req = self.apply_auth(req);
807
808            let started = Instant::now();
809            let response = req.send().await.map_err(ApiError::RequestFailed)?;
810
811            self.rate_limiter.update_from_response(&response).await;
812
813            let status = response.status();
814            log_response(&Method::GET, &joined, status, started.elapsed());
815            if !status.is_success() {
816                return Err(error_for_status(response, &joined).await);
817            }
818            response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
819                error!("Failed to read bytes response: {}", e);
820                ApiError::InvalidResponse(e.to_string())
821            })
822        })
823        .await?;
824
825        Ok(result)
826    }
827
828    pub async fn request<T: DeserializeOwned, B: Serialize + ?Sized>(
829        &self,
830        method: Method,
831        path: &str,
832        body: Option<&B>,
833    ) -> Result<T> {
834        if let Some(wait_secs) = self.rate_limiter.check_limit().await {
835            warn!(wait_secs, "Rate limit reached, waiting");
836            tokio::time::sleep(Duration::from_secs(wait_secs)).await;
837        }
838
839        let joined = self.safe_join(path)?;
840
841        // Whether there is a body, never its content: request bodies carry
842        // secured pipeline variables, issue text and the like.
843        debug!(
844            method = %method,
845            url = %redact_url(&joined),
846            has_body = body.is_some(),
847            "Sending request"
848        );
849
850        let result = retry_with_backoff(&self.retry_config, || async {
851            let mut req = self.client.request(method.clone(), joined.clone());
852            req = self.apply_auth(req);
853
854            if let Some(body) = body {
855                req = req.json(body);
856            }
857
858            let started = Instant::now();
859            let response = req.send().await.map_err(ApiError::RequestFailed)?;
860
861            self.rate_limiter.update_from_response(&response).await;
862
863            let status = response.status();
864            log_response(&method, &joined, status, started.elapsed());
865            if !status.is_success() {
866                return Err(error_for_status(response, &joined).await);
867            }
868
869            let bytes = response
870                .bytes()
871                .await
872                .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
873            // Successful responses with an empty (or whitespace-only) body,
874            // e.g. HTTP 204 No Content from Jira update/transition/assign and
875            // most DELETEs, are treated as JSON `null`. Callers that discard
876            // the body (`let _: Value`) then succeed instead of failing to
877            // parse an empty body as JSON.
878            let slice: &[u8] = if bytes.iter().all(|b| b.is_ascii_whitespace()) {
879                b"null"
880            } else {
881                &bytes
882            };
883            serde_json::from_slice::<T>(slice).map_err(|e| {
884                error!("Failed to parse JSON response: {}", e);
885                ApiError::InvalidResponse(e.to_string())
886            })
887        })
888        .await?;
889
890        Ok(result)
891    }
892
893    pub fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
894        match &self.auth {
895            Some(AuthMethod::Basic { username, token }) => {
896                request.basic_auth(username, Some(token.expose_secret()))
897            }
898            Some(AuthMethod::Bearer { token }) => request.bearer_auth(token.expose_secret()),
899            Some(AuthMethod::GenieKey { api_key }) => request.header(
900                "Authorization",
901                format!("GenieKey {}", api_key.expose_secret()),
902            ),
903            None => request,
904        }
905    }
906
907    pub fn rate_limiter(&self) -> &RateLimiter {
908        &self.rate_limiter
909    }
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915
916    /// The net beneath per-call-site encoding. Each of these was verified
917    /// against the `url` version in the lockfile to resolve somewhere other
918    /// than the segment it names.
919    #[test]
920    fn a_restructuring_path_is_refused_before_it_is_joined() {
921        for bad in [
922            "/2.0/repositories/w/r/hooks/..",
923            "/2.0/repositories/w/r/hooks/.",
924            "/2.0/repositories/w/r/hooks/..\\",
925            "/2.0/repositories/w/r/hooks/a\\..\\x",
926            "/2.0/repositories/w/r/hooks/.\t.",
927            "/2.0/repositories/w/r/hooks/.\n.",
928            "/rest/api/3/issue/../../admin",
929            // Percent-encoded dot segments: the first version of this guard
930            // compared against the literal strings and let every one of these
931            // through, each of which resolves to the parent.
932            "/2.0/repositories/w/r/hooks/%2e%2e",
933            "/2.0/repositories/w/r/hooks/%2E%2e",
934            "/2.0/repositories/w/r/hooks/.%2e",
935            "/2.0/repositories/w/r/hooks/%2e",
936            // A trailing space is stripped from the input, addressing the
937            // collection rather than a member.
938            "/rest/api/3/issue/ ",
939        ] {
940            assert!(
941                reject_restructuring_path(bad).is_err(),
942                "{bad:?} must be refused"
943            );
944        }
945    }
946
947    /// `decode_once` byte-sliced the `str` and aborted the process on an
948    /// ordinary identifier: byte index 5 of `x-%2é` lands inside the `é`.
949    #[test]
950    fn decode_once_does_not_panic_on_a_multibyte_char_after_a_percent() {
951        assert_eq!(decode_once("x-%2é"), "x-%2é");
952        assert_eq!(decode_once("%2é"), "%2é");
953        assert_eq!(decode_once("é%"), "é%");
954        assert_eq!(decode_once("%é2"), "%é2");
955        // And the request path built from one is refused or accepted, not a crash.
956        assert!(reject_restructuring_path("/rest/api/3/issue/x-%2é").is_ok());
957    }
958
959    /// A `#` in an interpolated value truncates the path, so it is refused
960    /// before the query split rather than exempted by it. A slug of `r#x`
961    /// turned a webhook delete into a repository delete.
962    #[test]
963    fn a_fragment_marker_is_refused_anywhere() {
964        assert!(reject_restructuring_path("/2.0/repositories/w/r#x/hooks/u").is_err());
965        assert!(reject_restructuring_path("/rest/api/3/issue/KEY-1#x").is_err());
966        assert!(reject_restructuring_path("/x?jql=a#b").is_err());
967    }
968
969    /// An interior space is encoded harmlessly by the parser; only the ends are
970    /// stripped. Refusing every space broke `bb commit browse` for a repository
971    /// file whose name contains one.
972    #[test]
973    fn only_edge_spaces_are_refused() {
974        assert!(reject_restructuring_path("/2.0/repositories/w/r/src/main/my file.txt").is_ok());
975        assert!(reject_restructuring_path("/rest/api/3/issue/ ").is_err());
976        assert!(reject_restructuring_path("/rest/api/3/issue/x ").is_err());
977        assert!(reject_restructuring_path(" /rest/api/3/issue/x").is_err());
978        // A space in the query is untouched.
979        assert!(reject_restructuring_path("/rest/api/3/search/jql?jql=a = b").is_ok());
980    }
981
982    /// Double-encoding is not traversal: `%252e%252e` is the literal text
983    /// `%2e%2e` and addresses a real resource. Rejecting it would be a false
984    /// positive, and decoding more than once would cause one.
985    #[test]
986    fn a_double_encoded_dot_is_a_real_segment() {
987        assert!(reject_restructuring_path("/2.0/repositories/w/r/hooks/%252e%252e").is_ok());
988        assert!(!is_dot_segment("%252e%252e"));
989        assert_eq!(decode_once("%252e%252e"), "%2e%2e");
990    }
991
992    /// The decoder must not mangle a segment that merely contains a `%`.
993    #[test]
994    fn decode_once_leaves_invalid_escapes_alone() {
995        assert_eq!(decode_once("100%"), "100%");
996        assert_eq!(decode_once("a%zzb"), "a%zzb");
997        assert_eq!(decode_once("%7Babc%7D"), "{abc}");
998    }
999
1000    /// A query value is not part of the path and cannot move the request, so
1001    /// JQL containing a dot or a `..` range must still be allowed through.
1002    #[test]
1003    fn a_dot_in_the_query_is_not_a_path_component() {
1004        assert!(
1005            reject_restructuring_path("/rest/api/3/search/jql?jql=fixVersion%20in%20(1.0)").is_ok()
1006        );
1007        assert!(reject_restructuring_path("/x?range=a..b").is_ok());
1008    }
1009
1010    use wiremock::matchers::{body_string, header, method, path};
1011    use wiremock::{Mock, MockServer, ResponseTemplate};
1012
1013    #[tokio::test]
1014    async fn test_403_returns_forbidden() {
1015        let server = MockServer::start().await;
1016        Mock::given(method("GET"))
1017            .and(path("test"))
1018            .respond_with(ResponseTemplate::new(403).set_body_string("You do not have access"))
1019            .mount(&server)
1020            .await;
1021
1022        let client = ApiClient::new(server.uri()).unwrap();
1023        let result: error::Result<serde_json::Value> = client.get("/test").await;
1024
1025        match result {
1026            Err(ApiError::Forbidden { message }) => {
1027                assert!(message.contains("You do not have access"));
1028            }
1029            other => panic!("Expected Forbidden, got: {:?}", other),
1030        }
1031    }
1032
1033    #[tokio::test]
1034    async fn test_401_returns_authentication_failed() {
1035        let server = MockServer::start().await;
1036        Mock::given(method("GET"))
1037            .and(path("test"))
1038            .respond_with(ResponseTemplate::new(401))
1039            .mount(&server)
1040            .await;
1041
1042        let client = ApiClient::new(server.uri()).unwrap();
1043        let result: error::Result<serde_json::Value> = client.get("/test").await;
1044
1045        match result {
1046            Err(ApiError::AuthenticationFailed { message }) => {
1047                // No body, so the generic wording is all we can say.
1048                assert_eq!(message, UNAUTHORIZED_FALLBACK);
1049            }
1050            other => panic!("Expected AuthenticationFailed, got: {:?}", other),
1051        }
1052    }
1053
1054    /// The scope-mismatch case that cost a full debugging session: the gateway
1055    /// explained itself and the CLI threw the explanation away.
1056    #[tokio::test]
1057    async fn test_401_surfaces_gateway_scope_message() {
1058        let server = MockServer::start().await;
1059        Mock::given(method("GET"))
1060            .and(path("test"))
1061            .respond_with(
1062                ResponseTemplate::new(401).set_body_string(
1063                    r#"{"code":401,"message":"Unauthorized; scope does not match"}"#,
1064                ),
1065            )
1066            .mount(&server)
1067            .await;
1068
1069        let client = ApiClient::new(server.uri()).unwrap();
1070        let result: error::Result<serde_json::Value> = client.get("/test").await;
1071
1072        match result {
1073            Err(ApiError::AuthenticationFailed { message }) => {
1074                assert!(
1075                    message.contains("scope does not match"),
1076                    "gateway reason was dropped: {message}"
1077                );
1078            }
1079            other => panic!("Expected AuthenticationFailed, got: {:?}", other),
1080        }
1081    }
1082
1083    #[test]
1084    fn unauthorized_message_falls_back_when_body_is_empty() {
1085        assert_eq!(unauthorized_message(""), UNAUTHORIZED_FALLBACK);
1086        assert_eq!(unauthorized_message("   "), UNAUTHORIZED_FALLBACK);
1087    }
1088
1089    #[test]
1090    fn unauthorized_message_keeps_gateway_reason() {
1091        let body = r#"{"code":401,"message":"Unauthorized; scope does not match"}"#;
1092        let message = unauthorized_message(body);
1093        assert!(message.starts_with(UNAUTHORIZED_FALLBACK));
1094        assert!(message.contains("Unauthorized; scope does not match"));
1095    }
1096
1097    #[test]
1098    fn unauthorized_message_reads_jira_error_messages() {
1099        let body = r#"{"errorMessages":["Client must be authenticated"],"errors":{}}"#;
1100        assert!(unauthorized_message(body).contains("Client must be authenticated"));
1101    }
1102
1103    #[test]
1104    fn unauthorized_message_reads_oauth_error_description() {
1105        let body = r#"{"error":"invalid_token","error_description":"The token expired"}"#;
1106        assert!(unauthorized_message(body).contains("The token expired"));
1107    }
1108
1109    #[test]
1110    fn unauthorized_message_reads_a_nested_error_object() {
1111        let body = r#"{"error":{"message":"Token does not have the required scope"}}"#;
1112        let message = unauthorized_message(body);
1113        assert!(message.contains("required scope"));
1114        // The whole document must not be quoted back as though it were prose.
1115        assert!(
1116            !message.contains("{\"error\""),
1117            "raw JSON leaked: {message}"
1118        );
1119    }
1120
1121    /// A body we cannot parse is quoted whole, so a proxy echoing the request
1122    /// back would otherwise print the token we just sent it.
1123    #[test]
1124    fn unauthorized_message_redacts_an_echoed_authorization_header() {
1125        let body =
1126            "rejected request: Authorization: Basic Zm9vOmJhcnNlY3JldA== to /rest/api/3/myself";
1127        let message = unauthorized_message(body);
1128        assert!(
1129            !message.contains("Zm9vOmJhcnNlY3JldA=="),
1130            "the credential survived: {message}"
1131        );
1132        assert!(message.contains("Basic <redacted>"));
1133        assert!(
1134            message.contains("/rest/api/3/myself"),
1135            "the useful part of the body was lost: {message}"
1136        );
1137    }
1138
1139    #[test]
1140    fn unauthorized_message_redacts_a_bearer_token_inside_json() {
1141        let body = r#"{"message":"bad header \"Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig\""}"#;
1142        let message = unauthorized_message(body);
1143        assert!(!message.contains("eyJhbGciOiJIUzI1NiJ9"), "{message}");
1144        assert!(message.contains("Bearer <redacted>"));
1145    }
1146
1147    #[test]
1148    fn unauthorized_message_redacts_every_occurrence() {
1149        let body = "Bearer aGVsbG8gd29ybGQgdG9rZW4= and basic dXNlcjpwYXNzd29yZA==";
1150        let message = unauthorized_message(body);
1151        for secret in ["aGVsbG8gd29ybGQgdG9rZW4=", "dXNlcjpwYXNzd29yZA=="] {
1152            assert!(!message.contains(secret), "{secret} survived: {message}");
1153        }
1154        assert_eq!(message.matches("<redacted>").count(), 2);
1155    }
1156
1157    /// "Basic auth is not allowed" is a sentence servers really send. Redacting
1158    /// the word after the scheme would destroy the message to protect nothing.
1159    #[test]
1160    fn scrub_leaves_ordinary_prose_alone() {
1161        for prose in [
1162            "basic authentication is not permitted here",
1163            "Basic auth is not allowed",
1164            "use Bearer tokens instead",
1165            "no credentials at all",
1166        ] {
1167            assert_eq!(scrub_credentials(prose), prose, "prose was mangled");
1168        }
1169    }
1170
1171    #[test]
1172    fn credential_shape_separates_words_from_secrets() {
1173        for word in ["auth", "authentication", "tokens", "a", ""] {
1174            assert!(
1175                !is_credential_shaped(word),
1176                "{word} is a word, not a secret"
1177            );
1178        }
1179        for secret in [
1180            "Zm9vOmJhcg==",
1181            "eyJhbGciOiJIUzI1NiJ9.payload.sig",
1182            "abcdefghijklmnop",
1183            "ATATT3xFfGF0abc_def-123",
1184        ] {
1185            assert!(is_credential_shaped(secret), "{secret} should be redacted");
1186        }
1187    }
1188
1189    #[test]
1190    fn unauthorized_message_keeps_plain_text_body() {
1191        assert!(unauthorized_message("Basic auth is not allowed").contains("Basic auth"));
1192    }
1193
1194    #[test]
1195    fn unauthorized_message_ignores_html_login_page() {
1196        let body = "<!DOCTYPE html><html><body>Sign in</body></html>";
1197        assert_eq!(unauthorized_message(body), UNAUTHORIZED_FALLBACK);
1198    }
1199
1200    #[test]
1201    fn unauthorized_message_truncates_long_bodies() {
1202        let body = format!(r#"{{"message":"{}"}}"#, "x".repeat(500));
1203        let message = unauthorized_message(&body);
1204        assert!(message.contains("..."));
1205        assert!(message.len() < 300, "message was not truncated: {message}");
1206    }
1207
1208    /// Multi-byte text must not panic the truncation slice.
1209    #[test]
1210    fn unauthorized_message_truncates_on_char_boundary() {
1211        let body = format!(r#"{{"message":"{}"}}"#, "é".repeat(500));
1212        assert!(unauthorized_message(&body).contains("..."));
1213    }
1214
1215    #[tokio::test]
1216    async fn test_403_get_text_returns_forbidden() {
1217        let server = MockServer::start().await;
1218        Mock::given(method("GET"))
1219            .and(path("text-endpoint"))
1220            .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden resource"))
1221            .mount(&server)
1222            .await;
1223
1224        let client = ApiClient::new(server.uri()).unwrap();
1225        let result = client.get_text("/text-endpoint").await;
1226
1227        match result {
1228            Err(ApiError::Forbidden { message }) => {
1229                assert!(message.contains("Forbidden resource"));
1230            }
1231            other => panic!("Expected Forbidden, got: {:?}", other),
1232        }
1233    }
1234
1235    #[tokio::test]
1236    async fn test_403_get_bytes_returns_forbidden() {
1237        let server = MockServer::start().await;
1238        Mock::given(method("GET"))
1239            .and(path("bytes-endpoint"))
1240            .respond_with(ResponseTemplate::new(403).set_body_string("Access denied"))
1241            .mount(&server)
1242            .await;
1243
1244        let client = ApiClient::new(server.uri()).unwrap();
1245        let result = client.get_bytes("/bytes-endpoint").await;
1246
1247        match result {
1248            Err(ApiError::Forbidden { message }) => {
1249                assert!(message.contains("Access denied"));
1250            }
1251            other => panic!("Expected Forbidden, got: {:?}", other),
1252        }
1253    }
1254
1255    // Regression for #45: a successful PUT/POST returning HTTP 204 No Content (empty
1256    // body) must not fail JSON parsing. Callers discard the body as `Value`.
1257    #[tokio::test]
1258    async fn test_204_no_content_put_succeeds() {
1259        let server = MockServer::start().await;
1260        Mock::given(method("PUT"))
1261            .and(path("issue/AEA-1"))
1262            .respond_with(ResponseTemplate::new(204))
1263            .mount(&server)
1264            .await;
1265
1266        let client = ApiClient::new(server.uri()).unwrap();
1267        let result: error::Result<serde_json::Value> = client
1268            .put("/issue/AEA-1", &serde_json::json!({"fields": {}}))
1269            .await;
1270
1271        match result {
1272            Ok(serde_json::Value::Null) => {}
1273            other => panic!("Expected Ok(Null) for 204, got: {:?}", other),
1274        }
1275    }
1276
1277    // A 200 with an empty/whitespace-only body is also treated as null.
1278    #[tokio::test]
1279    async fn test_200_empty_body_succeeds() {
1280        let server = MockServer::start().await;
1281        Mock::given(method("POST"))
1282            .and(path("transitions"))
1283            .respond_with(ResponseTemplate::new(200).set_body_string("  \n"))
1284            .mount(&server)
1285            .await;
1286
1287        let client = ApiClient::new(server.uri()).unwrap();
1288        let result: error::Result<serde_json::Value> =
1289            client.post("/transitions", &serde_json::json!({})).await;
1290
1291        match result {
1292            Ok(serde_json::Value::Null) => {}
1293            other => panic!("Expected Ok(Null) for empty 200, got: {:?}", other),
1294        }
1295    }
1296
1297    // A non-empty JSON body on success still parses normally.
1298    #[tokio::test]
1299    async fn test_200_json_body_still_parses() {
1300        let server = MockServer::start().await;
1301        Mock::given(method("GET"))
1302            .and(path("issue/AEA-1"))
1303            .respond_with(
1304                ResponseTemplate::new(200).set_body_json(serde_json::json!({"key": "AEA-1"})),
1305            )
1306            .mount(&server)
1307            .await;
1308
1309        let client = ApiClient::new(server.uri()).unwrap();
1310        let result: serde_json::Value = client.get("/issue/AEA-1").await.unwrap();
1311        assert_eq!(result["key"], "AEA-1");
1312    }
1313
1314    // -----------------------------------------------------------------------
1315    // request_raw
1316    // -----------------------------------------------------------------------
1317
1318    /// The point of the raw path: a non-2xx status is data, not an error, so the
1319    /// API's own error body survives instead of being replaced by ApiError.
1320    #[tokio::test]
1321    async fn test_request_raw_surfaces_non_2xx_without_erroring() {
1322        let server = MockServer::start().await;
1323        Mock::given(method("GET"))
1324            .and(path("/rest/api/3/issue/NOPE-1"))
1325            .respond_with(
1326                ResponseTemplate::new(404)
1327                    .set_body_json(serde_json::json!({"errorMessages": ["Issue does not exist"]})),
1328            )
1329            .mount(&server)
1330            .await;
1331
1332        let client = ApiClient::new(server.uri()).unwrap();
1333        let response = client
1334            .request_raw(RawRequest {
1335                method: Method::GET,
1336                path: "/rest/api/3/issue/NOPE-1",
1337                headers: HeaderMap::new(),
1338                body: None,
1339                timeout: None,
1340            })
1341            .await
1342            .unwrap();
1343
1344        assert_eq!(response.status, 404);
1345        assert!(!response.is_success());
1346        assert!(response
1347            .header("Content-Type")
1348            .unwrap()
1349            .contains("application/json"));
1350        assert!(String::from_utf8_lossy(&response.body).contains("Issue does not exist"));
1351    }
1352
1353    #[tokio::test]
1354    async fn test_request_raw_applies_headers_and_body() {
1355        let server = MockServer::start().await;
1356        Mock::given(method("POST"))
1357            .and(path("/rest/api/3/issue"))
1358            .and(header("X-Atlassian-Token", "no-check"))
1359            .and(body_string("{\"fields\":{}}"))
1360            .respond_with(
1361                ResponseTemplate::new(201).set_body_json(serde_json::json!({"key": "A-1"})),
1362            )
1363            .mount(&server)
1364            .await;
1365
1366        let mut headers = HeaderMap::new();
1367        headers.insert("X-Atlassian-Token", "no-check".parse().unwrap());
1368
1369        let client = ApiClient::new(server.uri()).unwrap();
1370        let response = client
1371            .request_raw(RawRequest {
1372                method: Method::POST,
1373                path: "/rest/api/3/issue",
1374                headers,
1375                body: Some(b"{\"fields\":{}}"),
1376                timeout: None,
1377            })
1378            .await
1379            .unwrap();
1380
1381        assert_eq!(response.status, 201);
1382    }
1383
1384    #[tokio::test]
1385    async fn test_request_raw_retries_5xx_for_get() {
1386        let server = MockServer::start().await;
1387        Mock::given(method("GET"))
1388            .and(path("/flaky"))
1389            .respond_with(ResponseTemplate::new(500))
1390            .expect(3)
1391            .mount(&server)
1392            .await;
1393
1394        let client = ApiClient::new(server.uri())
1395            .unwrap()
1396            .with_retry_config(RetryConfig {
1397                initial_interval: Duration::from_millis(1),
1398                ..RetryConfig::default()
1399            });
1400        let response = client
1401            .request_raw(RawRequest {
1402                method: Method::GET,
1403                path: "/flaky",
1404                headers: HeaderMap::new(),
1405                body: None,
1406                timeout: None,
1407            })
1408            .await
1409            .unwrap();
1410
1411        assert_eq!(response.status, 500);
1412    }
1413
1414    /// Replaying a POST can double-create. `request` does retry POSTs; the raw
1415    /// path deliberately does not inherit that.
1416    #[tokio::test]
1417    async fn test_request_raw_never_retries_post() {
1418        let server = MockServer::start().await;
1419        Mock::given(method("POST"))
1420            .and(path("/create"))
1421            .respond_with(ResponseTemplate::new(503))
1422            .expect(1)
1423            .mount(&server)
1424            .await;
1425
1426        let client = ApiClient::new(server.uri())
1427            .unwrap()
1428            .with_retry_config(RetryConfig {
1429                initial_interval: Duration::from_millis(1),
1430                ..RetryConfig::default()
1431            });
1432        let response = client
1433            .request_raw(RawRequest {
1434                method: Method::POST,
1435                path: "/create",
1436                headers: HeaderMap::new(),
1437                body: Some(b"{}"),
1438                timeout: None,
1439            })
1440            .await
1441            .unwrap();
1442
1443        assert_eq!(response.status, 503);
1444    }
1445
1446    #[tokio::test]
1447    async fn test_request_raw_rejects_cross_host_path() {
1448        let server = MockServer::start().await;
1449        Mock::given(method("GET"))
1450            .respond_with(ResponseTemplate::new(200))
1451            .expect(0)
1452            .mount(&server)
1453            .await;
1454
1455        let client = ApiClient::new(server.uri()).unwrap();
1456        let err = client
1457            .request_raw(RawRequest {
1458                method: Method::GET,
1459                path: "https://evil.example.com/steal",
1460                headers: HeaderMap::new(),
1461                body: None,
1462                timeout: None,
1463            })
1464            .await
1465            .unwrap_err();
1466
1467        assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1468    }
1469
1470    #[test]
1471    fn test_resolve_url_enforces_same_origin() {
1472        let client = ApiClient::new("https://site.atlassian.net").unwrap();
1473
1474        assert_eq!(
1475            client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1476            "https://site.atlassian.net/rest/api/3/myself"
1477        );
1478        // Relative paths work with or without the leading slash.
1479        assert_eq!(
1480            client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1481            "https://site.atlassian.net/rest/api/3/myself"
1482        );
1483        // Other hosts, scheme downgrades and userinfo tricks are all rejected.
1484        for bad in [
1485            "https://evil.example.com/x",
1486            "http://site.atlassian.net/x",
1487            "https://site.atlassian.net@evil.example.com/",
1488            "//evil.example.com/x",
1489        ] {
1490            let resolved = client.resolve_url(bad);
1491            match resolved {
1492                Err(_) => {}
1493                // A protocol-relative path is not treated as a host by `join`;
1494                // pin the behaviour so a future change cannot silently open it up.
1495                Ok(url) => assert_eq!(url.host_str(), Some("site.atlassian.net"), "{bad}"),
1496            }
1497        }
1498    }
1499
1500    /// Regression: a base URL carrying a path lost its last segment, so the
1501    /// API-gateway form used by scoped API tokens dropped the cloud id.
1502    #[test]
1503    fn test_resolve_url_keeps_the_base_path() {
1504        let client = ApiClient::new("https://api.atlassian.com/ex/jira/cloud-id").unwrap();
1505
1506        assert_eq!(
1507            client.base_url(),
1508            "https://api.atlassian.com/ex/jira/cloud-id/"
1509        );
1510        assert_eq!(
1511            client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1512            "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1513        );
1514        assert_eq!(
1515            client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1516            "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1517        );
1518
1519        // A base written with the trailing slash resolves the same way.
1520        let client = ApiClient::new("https://api.atlassian.com/ex/jira/cloud-id/").unwrap();
1521
1522        assert_eq!(
1523            client.base_url(),
1524            "https://api.atlassian.com/ex/jira/cloud-id/"
1525        );
1526        assert_eq!(
1527            client.resolve_url("/rest/api/3/myself").unwrap().as_str(),
1528            "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1529        );
1530        assert_eq!(
1531            client.resolve_url("rest/api/3/myself").unwrap().as_str(),
1532            "https://api.atlassian.com/ex/jira/cloud-id/rest/api/3/myself"
1533        );
1534    }
1535
1536    /// The same fix covers a self-hosted product behind a context path, which is
1537    /// the ordinary way Bamboo is deployed. Before this, `/rest/api/latest/plan`
1538    /// against `https://example.com/bamboo` resolved to `https://example.com/rest/...`
1539    /// and 404'd.
1540    #[test]
1541    fn test_resolve_url_keeps_a_context_path() {
1542        let client = ApiClient::new("https://example.com/bamboo").unwrap();
1543
1544        assert_eq!(
1545            client
1546                .resolve_url("/rest/api/latest/plan")
1547                .unwrap()
1548                .as_str(),
1549            "https://example.com/bamboo/rest/api/latest/plan"
1550        );
1551    }
1552
1553    /// Guard against the fix shifting any URL a working profile already resolves.
1554    /// Every shape below is byte-identical before and after normalisation; only
1555    /// the previously broken path-bearing bases move.
1556    #[test]
1557    fn test_normalisation_does_not_move_existing_product_urls() {
1558        for (base, path, expected) in [
1559            (
1560                "https://x.atlassian.net",
1561                "/rest/api/3/myself",
1562                "https://x.atlassian.net/rest/api/3/myself",
1563            ),
1564            (
1565                "https://x.atlassian.net",
1566                "/wiki/download/attachments/1/f.png?version=1",
1567                "https://x.atlassian.net/wiki/download/attachments/1/f.png?version=1",
1568            ),
1569            (
1570                "https://api.bitbucket.org",
1571                "/2.0/repositories/w/r",
1572                "https://api.bitbucket.org/2.0/repositories/w/r",
1573            ),
1574            // Opsgenie's base already carries a path and already ends in a
1575            // slash, and its request paths are relative, so it is untouched.
1576            (
1577                "https://api.opsgenie.com/v2/",
1578                "alerts/123",
1579                "https://api.opsgenie.com/v2/alerts/123",
1580            ),
1581        ] {
1582            let client = ApiClient::new(base).unwrap();
1583            assert_eq!(
1584                client.resolve_url(path).unwrap().as_str(),
1585                expected,
1586                "{base} + {path}"
1587            );
1588        }
1589    }
1590
1591    /// Regression: comparing scheme and host but not port let any other port on
1592    /// the same host receive the profile's credentials.
1593    #[tokio::test]
1594    async fn test_request_raw_rejects_a_different_port_on_the_same_host() {
1595        let victim = MockServer::start().await;
1596        Mock::given(method("GET"))
1597            .respond_with(ResponseTemplate::new(200).set_body_string("secrets"))
1598            .expect(0)
1599            .mount(&victim)
1600            .await;
1601
1602        let server = MockServer::start().await;
1603        let client = ApiClient::new(server.uri()).unwrap();
1604        let err = client
1605            .request_raw(RawRequest {
1606                method: Method::GET,
1607                path: &format!("{}/steal", victim.uri()),
1608                headers: HeaderMap::new(),
1609                body: None,
1610                timeout: None,
1611            })
1612            .await
1613            .unwrap_err();
1614
1615        assert!(matches!(err, ApiError::InvalidUrl(_)), "got {err:?}");
1616    }
1617
1618    /// `safe_join` only sees the first URL, so a same-origin endpoint could
1619    /// otherwise bounce a write-capable request anywhere. The raw client stops
1620    /// at the redirect and hands the 3xx back instead of following it.
1621    #[tokio::test]
1622    async fn test_request_raw_does_not_follow_a_cross_origin_redirect() {
1623        let evil = MockServer::start().await;
1624        Mock::given(method("POST"))
1625            .respond_with(ResponseTemplate::new(200).set_body_string("pwned"))
1626            .expect(0)
1627            .mount(&evil)
1628            .await;
1629
1630        let server = MockServer::start().await;
1631        Mock::given(method("POST"))
1632            .and(path("/rest/api/3/bounce"))
1633            .respond_with(
1634                ResponseTemplate::new(307)
1635                    .insert_header("location", format!("{}/steal", evil.uri()).as_str()),
1636            )
1637            .mount(&server)
1638            .await;
1639
1640        let client = ApiClient::new(server.uri())
1641            .unwrap()
1642            .with_basic_auth("dev@example.com", "token");
1643        let response = client
1644            .request_raw(RawRequest {
1645                method: Method::POST,
1646                path: "/rest/api/3/bounce",
1647                headers: HeaderMap::new(),
1648                body: Some(b"{}"),
1649                timeout: None,
1650            })
1651            .await
1652            .unwrap();
1653
1654        assert_eq!(response.status, 307);
1655        assert!(response.header("location").unwrap().contains("/steal"));
1656        assert_ne!(response.body, b"pwned".to_vec());
1657    }
1658
1659    /// Same-origin redirects are still followed, so ordinary endpoints work.
1660    #[tokio::test]
1661    async fn test_request_raw_follows_a_same_origin_redirect() {
1662        let server = MockServer::start().await;
1663        Mock::given(method("GET"))
1664            .and(path("/from"))
1665            .respond_with(ResponseTemplate::new(302).insert_header("location", "/to"))
1666            .mount(&server)
1667            .await;
1668        Mock::given(method("GET"))
1669            .and(path("/to"))
1670            .respond_with(ResponseTemplate::new(200).set_body_string("arrived"))
1671            .mount(&server)
1672            .await;
1673
1674        let client = ApiClient::new(server.uri()).unwrap();
1675        let response = client
1676            .request_raw(RawRequest {
1677                method: Method::GET,
1678                path: "/from",
1679                headers: HeaderMap::new(),
1680                body: None,
1681                timeout: None,
1682            })
1683            .await
1684            .unwrap();
1685
1686        assert_eq!(response.status, 200);
1687        assert_eq!(response.body, b"arrived".to_vec());
1688    }
1689
1690    /// Attachment downloads depend on the cross-host hop to the media host, so
1691    /// the ordinary client must keep following redirects.
1692    #[tokio::test]
1693    async fn test_get_bytes_still_follows_cross_host_redirects() {
1694        let media = MockServer::start().await;
1695        Mock::given(method("GET"))
1696            .and(path("/file/binary"))
1697            .respond_with(ResponseTemplate::new(200).set_body_bytes(b"BYTES".to_vec()))
1698            .mount(&media)
1699            .await;
1700
1701        let server = MockServer::start().await;
1702        Mock::given(method("GET"))
1703            .and(path("/content/1"))
1704            .respond_with(
1705                ResponseTemplate::new(302)
1706                    .insert_header("location", format!("{}/file/binary", media.uri()).as_str()),
1707            )
1708            .mount(&server)
1709            .await;
1710
1711        let client = ApiClient::new(server.uri()).unwrap();
1712        assert_eq!(client.get_bytes("/content/1").await.unwrap(), b"BYTES");
1713    }
1714
1715    #[test]
1716    fn test_same_origin_compares_scheme_host_and_port() {
1717        let base = Url::parse("https://site.atlassian.net").unwrap();
1718        assert!(same_origin(
1719            &Url::parse("https://site.atlassian.net/x").unwrap(),
1720            &base
1721        ));
1722        // 443 is the known default for https, so an explicit port still matches.
1723        assert!(same_origin(
1724            &Url::parse("https://site.atlassian.net:443/x").unwrap(),
1725            &base
1726        ));
1727        for other in [
1728            "https://site.atlassian.net:8443/x",
1729            "http://site.atlassian.net/x",
1730            "https://evil.example.com/x",
1731        ] {
1732            assert!(
1733                !same_origin(&Url::parse(other).unwrap(), &base),
1734                "{other} must not match"
1735            );
1736        }
1737    }
1738}